debian-watch-0.4.12/.cargo_vcs_info.json0000644000000001521046102023000134660ustar { "git": { "sha1": "2cb196e5f8951b266dace4e562a3b5d32f625ba0" }, "path_in_vcs": "debian-watch" }debian-watch-0.4.12/.codespellrc000064400000000000000000000000461046102023000145360ustar 00000000000000[codespell] ignore-words-list = crate debian-watch-0.4.12/.gitignore000064400000000000000000000000131046102023000142200ustar 00000000000000target .*~ debian-watch-0.4.12/Cargo.lock0000644000002150441046102023000114510ustar # This file is automatically @generated by Cargo. # It is not intended for manual editing. version = 4 [[package]] name = "aho-corasick" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] [[package]] name = "android_system_properties" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" dependencies = [ "libc", ] [[package]] name = "anstream" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", "anstyle-query", "anstyle-wincon", "colorchoice", "is_terminal_polyfill", "utf8parse", ] [[package]] name = "anstyle" version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" 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.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "argon2" version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" dependencies = [ "base64ct", "blake2", "cpufeatures", "password-hash", ] [[package]] name = "ascii" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" [[package]] name = "ascii-canvas" version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef1e3e699d84ab1b0911a1010c5c106aa34ae89aeac103be5ce0c3859db1e891" dependencies = [ "term", ] [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bindgen" version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ "bitflags", "cexpr", "clang-sys", "itertools 0.13.0", "proc-macro2", "quote", "regex", "rustc-hash 2.1.2", "shlex", "syn", ] [[package]] name = "bit-set" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ "bit-vec", ] [[package]] name = "bit-vec" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "blake2" version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ "digest", ] [[package]] name = "block-buffer" version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ "generic-array 0.14.7", ] [[package]] name = "buffered-reader" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db26bf1f092fd5e05b5ab3be2f290915aeb6f3f20c4e9f86ce0f07f336c2412f" dependencies = [ "libc", ] [[package]] name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", "shlex", ] [[package]] name = "cexpr" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" dependencies = [ "nom", ] [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "chrono" version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "js-sys", "num-traits", "wasm-bindgen", "windows-link", ] [[package]] name = "chunked_transfer" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" [[package]] name = "clang-sys" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" dependencies = [ "glob", "libc", "libloading", ] [[package]] name = "clap" version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", ] [[package]] name = "clap_builder" version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", "clap_lex", "strsim", ] [[package]] name = "clap_derive" version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck", "proc-macro2", "quote", "syn", ] [[package]] name = "clap_lex" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "colorchoice" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "core-foundation" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" dependencies = [ "core-foundation-sys", "libc", ] [[package]] name = "core-foundation" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" dependencies = [ "core-foundation-sys", "libc", ] [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "countme" version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" [[package]] name = "cpufeatures" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ "libc", ] [[package]] name = "crypto-common" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array 0.14.7", "typenum", ] [[package]] name = "cssparser" version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" dependencies = [ "cssparser-macros", "dtoa-short", "itoa", "phf", "smallvec", ] [[package]] name = "cssparser-macros" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", "syn", ] [[package]] name = "deb822-lossless" version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3d30d7b078b5d4ef37be971b8ac29ca3147bf8ff5803e4adf1fdd6c276969b5" dependencies = [ "regex", "rowan", "serde", ] [[package]] name = "debian-watch" version = "0.4.12" dependencies = [ "anyhow", "clap", "deb822-lossless", "debversion", "m_lexer", "maplit", "regex", "reqwest", "rowan", "scraper", "sequoia-openpgp", "tiny_http", "tokio", "url", ] [[package]] name = "debversion" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8ba0e270fb9f27dbb4c46e08d2ad27e69501d6ca573bfdf9e0aa793e7377929" dependencies = [ "chrono", "lazy-regex", "num-bigint", ] [[package]] name = "derive_more" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" dependencies = [ "derive_more-impl", ] [[package]] name = "derive_more-impl" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ "proc-macro2", "quote", "rustc_version", "syn", ] [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", "subtle", ] [[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 = "dtoa" version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" [[package]] name = "dtoa-short" version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" dependencies = [ "dtoa", ] [[package]] name = "dyn-clone" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "ego-tree" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b04dc5a38e4f151a79d9f2451ae6037fb6eaf5cba34771f44781f80e508498e3" [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "ena" version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1" dependencies = [ "log", ] [[package]] name = "encoding_rs" version = "0.8.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" dependencies = [ "cfg-if", ] [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", "windows-sys 0.61.2", ] [[package]] name = "fastrand" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "fixedbitset" version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] name = "foreign-types" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" dependencies = [ "foreign-types-shared", ] [[package]] name = "foreign-types-shared" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] name = "form_urlencoded" version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] [[package]] name = "futures-channel" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", ] [[package]] name = "futures-core" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-io" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-sink" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", "futures-io", "futures-sink", "futures-task", "memchr", "pin-project-lite", "slab", ] [[package]] name = "generic-array" version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", ] [[package]] name = "generic-array" version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dab9e9188e97a93276e1fe7b56401b851e2b45a46d045ca658100c1303ada649" dependencies = [ "rustversion", "typenum", ] [[package]] name = "getopts" version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" dependencies = [ "unicode-width", ] [[package]] name = "getrandom" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "js-sys", "libc", "wasi", "wasm-bindgen", ] [[package]] name = "getrandom" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", "r-efi", "wasip2", "wasip3", ] [[package]] name = "glob" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "h2" version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" dependencies = [ "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", "http", "indexmap", "slab", "tokio", "tokio-util", "tracing", ] [[package]] name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" [[package]] name = "hashbrown" version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "foldhash", ] [[package]] name = "hashbrown" version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "html5ever" version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" dependencies = [ "log", "markup5ever", ] [[package]] name = "http" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", "itoa", ] [[package]] name = "http-body" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", "http", ] [[package]] name = "http-body-util" version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", "http", "http-body", "pin-project-lite", ] [[package]] name = "httparse" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "httpdate" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", "h2", "http", "http-body", "httparse", "itoa", "pin-project-lite", "smallvec", "tokio", "want", ] [[package]] name = "hyper-rustls" version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http", "hyper", "hyper-util", "rustls", "tokio", "tokio-rustls", "tower-service", ] [[package]] name = "hyper-tls" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", "hyper", "hyper-util", "native-tls", "tokio", "tokio-native-tls", "tower-service", ] [[package]] name = "hyper-util" version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "base64", "bytes", "futures-channel", "futures-util", "http", "http-body", "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", "socket2", "system-configuration", "tokio", "tower-service", "tracing", "windows-registry", ] [[package]] name = "iana-time-zone" version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", "iana-time-zone-haiku", "js-sys", "log", "wasm-bindgen", "windows-core", ] [[package]] name = "iana-time-zone-haiku" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" dependencies = [ "cc", ] [[package]] name = "icu_collections" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", "utf8_iter", "yoke", "zerofrom", "zerovec", ] [[package]] name = "icu_locale_core" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", "tinystr", "writeable", "zerovec", ] [[package]] name = "icu_normalizer" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", "icu_properties", "icu_provider", "smallvec", "zerovec", ] [[package]] name = "icu_normalizer_data" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", "icu_properties_data", "icu_provider", "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", "writeable", "yoke", "zerofrom", "zerotrie", "zerovec", ] [[package]] name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" [[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.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", ] [[package]] name = "indexmap" version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown 0.17.0", "serde", "serde_core", ] [[package]] name = "ipnet" version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" dependencies = [ "either", ] [[package]] name = "itertools" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" dependencies = [ "either", ] [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" dependencies = [ "cfg-if", "futures-util", "once_cell", "wasm-bindgen", ] [[package]] name = "keccak" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" dependencies = [ "cpufeatures", ] [[package]] name = "lalrpop" version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba4ebbd48ce411c1d10fb35185f5a51a7bfa3d8b24b4e330d30c9e3a34129501" dependencies = [ "ascii-canvas", "bit-set", "ena", "itertools 0.14.0", "lalrpop-util", "petgraph", "regex", "regex-syntax", "sha3", "string_cache 0.8.9", "term", "unicode-xid", "walkdir", ] [[package]] name = "lalrpop-util" version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5baa5e9ff84f1aefd264e6869907646538a52147a755d494517a8007fb48733" dependencies = [ "regex-automata", "rustversion", ] [[package]] name = "lazy-regex" version = "3.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6bae91019476d3ec7147de9aa291cadb6d870abf2f3015d2da73a90325ac1496" dependencies = [ "lazy-regex-proc_macros", "once_cell", "regex", ] [[package]] name = "lazy-regex-proc_macros" version = "3.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4de9c1e1439d8b7b3061b2d209809f447ca33241733d9a3c01eabf2dc8d94358" dependencies = [ "proc-macro2", "quote", "regex", "syn", ] [[package]] name = "leb128fmt" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libloading" version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", "windows-link", ] [[package]] name = "linux-raw-sys" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "lock_api" version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ "scopeguard", ] [[package]] name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "m_lexer" version = "0.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7e51ebf91162d585a5bae05e4779efc4a276171cb880d61dd6fab11c98467a7" dependencies = [ "regex", ] [[package]] name = "maplit" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" [[package]] name = "markup5ever" version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" dependencies = [ "log", "tendril", "web_atoms", ] [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memsec" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c797b9d6bb23aab2fc369c65f871be49214f5c759af65bde26ffaaa2b646b492" [[package]] name = "mime" version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "minimal-lexical" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "mio" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi", "windows-sys 0.61.2", ] [[package]] name = "native-tls" version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" dependencies = [ "libc", "log", "openssl", "openssl-probe", "openssl-sys", "schannel", "security-framework", "security-framework-sys", "tempfile", ] [[package]] name = "nettle" version = "7.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2578a3627c28fefb60f1680e20e85f38bd8c8bf98c288b370489a573b0640907" dependencies = [ "getrandom 0.4.2", "libc", "nettle-sys", "thiserror", "typenum", ] [[package]] name = "nettle-sys" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f35502358aa77e598570bbf9a79ad19c4985a1bcd157b5d70197688dafca9b48" dependencies = [ "bindgen", "cc", "libc", "pkg-config", "tempfile", "vcpkg", ] [[package]] name = "new_debug_unreachable" version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" [[package]] name = "nom" version = "7.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" dependencies = [ "memchr", "minimal-lexical", ] [[package]] name = "num-bigint" version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", "num-traits", ] [[package]] name = "num-integer" version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" dependencies = [ "num-traits", ] [[package]] name = "num-traits" version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", ] [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openssl" version = "0.10.79" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" dependencies = [ "bitflags", "cfg-if", "foreign-types", "libc", "openssl-macros", "openssl-sys", ] [[package]] name = "openssl-macros" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" version = "0.9.115" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" dependencies = [ "cc", "libc", "pkg-config", "vcpkg", ] [[package]] name = "parking_lot" version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", "parking_lot_core", ] [[package]] name = "parking_lot_core" version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", "windows-link", ] [[package]] name = "password-hash" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" dependencies = [ "base64ct", "rand_core", "subtle", ] [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "petgraph" version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" dependencies = [ "fixedbitset", "indexmap", ] [[package]] name = "phf" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ "phf_macros", "phf_shared 0.13.1", "serde", ] [[package]] name = "phf_codegen" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" dependencies = [ "phf_generator", "phf_shared 0.13.1", ] [[package]] name = "phf_generator" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", "phf_shared 0.13.1", ] [[package]] name = "phf_macros" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ "phf_generator", "phf_shared 0.13.1", "proc-macro2", "quote", "syn", ] [[package]] name = "phf_shared" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" dependencies = [ "siphasher", ] [[package]] name = "phf_shared" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" dependencies = [ "siphasher", ] [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "potential_utf" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] [[package]] name = "precomputed-hash" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" [[package]] name = "prettyplease" version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", "syn", ] [[package]] name = "proc-macro2" version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quote" version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ "bitflags", ] [[package]] name = "regex" version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", "regex-automata", "regex-syntax", ] [[package]] name = "regex-automata" version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", "regex-syntax", ] [[package]] name = "regex-syntax" version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", "encoding_rs", "futures-channel", "futures-core", "futures-util", "h2", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", "hyper-tls", "hyper-util", "js-sys", "log", "mime", "native-tls", "percent-encoding", "pin-project-lite", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", "tokio-native-tls", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", ] [[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.17", "libc", "untrusted", "windows-sys 0.52.0", ] [[package]] name = "rowan" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "417a3a9f582e349834051b8a10c8d71ca88da4211e4093528e36b9845f6b5f21" dependencies = [ "countme", "hashbrown 0.14.5", "rustc-hash 1.1.0", "text-size", ] [[package]] name = "rustc-hash" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "rustc-hash" version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustc_version" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" dependencies = [ "semver", ] [[package]] name = "rustix" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ "bitflags", "errno", "libc", "linux-raw-sys", "windows-sys 0.61.2", ] [[package]] name = "rustls" version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "once_cell", "rustls-pki-types", "rustls-webpki", "subtle", "zeroize", ] [[package]] name = "rustls-pki-types" version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "zeroize", ] [[package]] name = "rustls-webpki" version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" 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.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[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 = "schannel" version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ "windows-sys 0.61.2", ] [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "scraper" version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f5297102b8b62b4454ee8561601b2d551b4913148feb4241ca9d1a04bf4526" dependencies = [ "cssparser", "ego-tree", "getopts", "html5ever", "precomputed-hash", "selectors", "tendril", ] [[package]] name = "security-framework" version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags", "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", ] [[package]] name = "security-framework-sys" version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", ] [[package]] name = "selectors" version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" dependencies = [ "bitflags", "cssparser", "derive_more", "log", "new_debug_unreachable", "phf", "phf_codegen", "precomputed-hash", "rustc-hash 2.1.2", "servo_arc", "smallvec", ] [[package]] name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "sequoia-openpgp" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0620e44a7d514adf7df87b44db235f13b81fed7ddc265adb26f014d42626ac47" dependencies = [ "anyhow", "argon2", "base64", "buffered-reader", "chrono", "dyn-clone", "getrandom 0.2.17", "idna", "lalrpop", "lalrpop-util", "libc", "memsec", "nettle", "regex", "regex-syntax", "sha1collisiondetection", "thiserror", "xxhash-rust", ] [[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.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", "serde", "serde_core", "zmij", ] [[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 = "servo_arc" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" dependencies = [ "stable_deref_trait", ] [[package]] name = "sha1collisiondetection" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f606421e4a6012877e893c399822a4ed4b089164c5969424e1b9d1e66e6964b" dependencies = [ "digest", "generic-array 1.4.1", ] [[package]] name = "sha3" version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" dependencies = [ "digest", "keccak", ] [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "siphasher" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "slab" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", "windows-sys 0.61.2", ] [[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "string_cache" version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" dependencies = [ "new_debug_unreachable", "parking_lot", "phf_shared 0.11.3", "precomputed-hash", ] [[package]] name = "string_cache" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" dependencies = [ "new_debug_unreachable", "parking_lot", "phf_shared 0.13.1", "precomputed-hash", ] [[package]] name = "string_cache_codegen" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" dependencies = [ "phf_generator", "phf_shared 0.13.1", "proc-macro2", "quote", ] [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" 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 = "system-configuration" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ "bitflags", "core-foundation 0.9.4", "system-configuration-sys", ] [[package]] name = "system-configuration-sys" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" dependencies = [ "core-foundation-sys", "libc", ] [[package]] name = "tempfile" version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", ] [[package]] name = "tendril" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" dependencies = [ "new_debug_unreachable", "utf-8", ] [[package]] name = "term" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" dependencies = [ "windows-sys 0.61.2", ] [[package]] name = "text-size" version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233" [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "tiny_http" version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" dependencies = [ "ascii", "chunked_transfer", "httpdate", "log", ] [[package]] name = "tinystr" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", ] [[package]] name = "tokio" version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", "mio", "pin-project-lite", "socket2", "tokio-macros", "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "tokio-native-tls" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" dependencies = [ "native-tls", "tokio", ] [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ "rustls", "tokio", ] [[package]] name = "tokio-util" version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", "futures-sink", "pin-project-lite", "tokio", ] [[package]] name = "tower" version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", "pin-project-lite", "sync_wrapper", "tokio", "tower-layer", "tower-service", ] [[package]] name = "tower-http" version = "0.6.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" dependencies = [ "bitflags", "bytes", "futures-util", "http", "http-body", "pin-project-lite", "tower", "tower-layer", "tower-service", "url", ] [[package]] name = "tower-layer" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" [[package]] name = "tower-service" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", "tracing-core", ] [[package]] name = "tracing-core" version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", ] [[package]] name = "try-lock" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typenum" version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-width" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "unicode-xid" version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", "percent-encoding", "serde", ] [[package]] name = "utf-8" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" [[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 = "vcpkg" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "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.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ "wit-bindgen 0.57.1", ] [[package]] name = "wasip3" version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ "wit-bindgen 0.51.0", ] [[package]] name = "wasm-bindgen" version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" version = "0.4.71" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" dependencies = [ "js-sys", "wasm-bindgen", ] [[package]] name = "wasm-bindgen-macro" version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" dependencies = [ "quote", "wasm-bindgen-macro-support", ] [[package]] name = "wasm-bindgen-macro-support" version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" dependencies = [ "bumpalo", "proc-macro2", "quote", "syn", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" dependencies = [ "unicode-ident", ] [[package]] name = "wasm-encoder" version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ "leb128fmt", "wasmparser", ] [[package]] name = "wasm-metadata" version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", "indexmap", "wasm-encoder", "wasmparser", ] [[package]] name = "wasmparser" version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags", "hashbrown 0.15.5", "indexmap", "semver", ] [[package]] name = "web-sys" version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" dependencies = [ "js-sys", "wasm-bindgen", ] [[package]] name = "web_atoms" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" dependencies = [ "phf", "phf_codegen", "string_cache 0.9.0", "string_cache_codegen", ] [[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 = "windows-core" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", "windows-link", "windows-result", "windows-strings", ] [[package]] name = "windows-implement" version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "windows-interface" version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-registry" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ "windows-link", "windows-result", "windows-strings", ] [[package]] name = "windows-result" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ "windows-link", ] [[package]] name = "windows-strings" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ "windows-link", ] [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ "windows-targets", ] [[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.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ "windows_aarch64_gnullvm", "windows_aarch64_msvc", "windows_i686_gnu", "windows_i686_gnullvm", "windows_i686_msvc", "windows_x86_64_gnu", "windows_x86_64_gnullvm", "windows_x86_64_msvc", ] [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[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_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" dependencies = [ "wit-bindgen-rust-macro", ] [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "wit-bindgen-core" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", "heck", "wit-parser", ] [[package]] name = "wit-bindgen-rust" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", "indexmap", "prettyplease", "syn", "wasm-metadata", "wit-bindgen-core", "wit-component", ] [[package]] name = "wit-bindgen-rust-macro" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" dependencies = [ "anyhow", "prettyplease", "proc-macro2", "quote", "syn", "wit-bindgen-core", "wit-bindgen-rust", ] [[package]] name = "wit-component" version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags", "indexmap", "log", "serde", "serde_derive", "serde_json", "wasm-encoder", "wasm-metadata", "wasmparser", "wit-parser", ] [[package]] name = "wit-parser" version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", "indexmap", "log", "semver", "serde", "serde_derive", "serde_json", "unicode-xid", "wasmparser", ] [[package]] name = "writeable" version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "xxhash-rust" version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" [[package]] name = "yoke" version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", "zerofrom", ] [[package]] name = "yoke-derive" version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", "syn", "synstructure", ] [[package]] name = "zerofrom" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" 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.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", "zerofrom", ] [[package]] name = "zerovec" version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", "zerovec-derive", ] [[package]] name = "zerovec-derive" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" debian-watch-0.4.12/Cargo.toml0000644000000040741046102023000114730ustar # THIS FILE IS AUTOMATICALLY GENERATED BY CARGO # # When uploading crates to the registry Cargo will automatically # "normalize" Cargo.toml files for maximal compatibility # with all versions of Cargo and also rewrite `path` dependencies # to registry (e.g., crates.io) dependencies. # # If you are reading this file be aware that the original Cargo.toml # will likely look very different (and much more reasonable). # See Cargo.toml.orig for the original contents. [package] edition = "2021" name = "debian-watch" version = "0.4.12" authors = ["Jelmer Vernooij "] build = false autolib = false autobins = false autoexamples = false autotests = false autobenches = false description = "parser for Debian watch files" homepage = "https://github.com/jelmer/debian-parsers" readme = "README.md" license = "Apache-2.0" repository = "https://github.com/jelmer/debian-parsers" [features] blocking = ["reqwest?/blocking"] deb822 = ["dep:deb822-lossless"] default = [ "linebased", "deb822", ] discover = [ "scraper", "reqwest", ] linebased = [] pgp = [ "sequoia-openpgp", "anyhow", ] [lib] name = "debian_watch" path = "src/lib.rs" [[bin]] name = "convert-watch-v5" path = "src/bin/convert-watch-v5.rs" required-features = [ "deb822", "linebased", ] [dependencies.anyhow] version = "1.0" optional = true [dependencies.clap] version = "4.5" features = ["derive"] [dependencies.deb822-lossless] version = "0.5.16" optional = true [dependencies.debversion] version = ">=0.4.7, <0.6" [dependencies.m_lexer] version = "0.0.4" [dependencies.regex] version = "1" [dependencies.reqwest] version = ">=0.12,<0.14" optional = true [dependencies.rowan] version = "0.16.1" [dependencies.scraper] version = ">=0.22, <0.27" optional = true [dependencies.sequoia-openpgp] version = "2.0" features = ["crypto-nettle"] optional = true default-features = false [dependencies.url] version = "2.5.7" [dev-dependencies.maplit] version = "1.0.2" [dev-dependencies.tiny_http] version = "0.12" [dev-dependencies.tokio] version = "1" features = [ "macros", "rt", ] debian-watch-0.4.12/Cargo.toml.orig000064400000000000000000000022031046102023000151220ustar 00000000000000[package] name = "debian-watch" version = "0.4.12" authors = [ "Jelmer Vernooij ",] edition = "2021" license = "Apache-2.0" description = "parser for Debian watch files" repository = { workspace = true } homepage = { workspace = true } [features] default = ["linebased", "deb822"] linebased = [] discover = ["scraper", "reqwest"] blocking = ["reqwest?/blocking"] pgp = ["sequoia-openpgp", "anyhow"] deb822 = ["dep:deb822-lossless"] [dependencies] rowan = "0.16.1" m_lexer = "0.0.4" debversion = ">=0.4.7, <0.6" url = "2.5.7" deb822-lossless = { version = "0.5.16", optional = true, path = "../deb822-lossless" } clap = { version = "4.5", features = ["derive"] } regex = "1" scraper = { version = ">=0.22, <0.27", optional = true } reqwest = { version = ">=0.12,<0.14", optional = true } sequoia-openpgp = { version = "2.0", optional = true, default-features = false, features = ["crypto-nettle"] } anyhow = { version = "1.0", optional = true } [dev-dependencies] maplit = "1.0.2" tokio = { version = "1", features = ["macros", "rt"] } tiny_http = "0.12" [[bin]] name = "convert-watch-v5" required-features = ["deb822", "linebased"] debian-watch-0.4.12/README.md000064400000000000000000000021061046102023000135140ustar 00000000000000Format-preserving parser and editor for Debian watch files ========================================================== This crate supports reading, editing and writing Debian watch files, while preserving the original contents byte-for-byte. Example: ```rust let wf = debian_watch::WatchFile::new(None); assert_eq!(wf.version(), debian_watch::DEFAULT_VERSION); assert_eq!("", wf.to_string()); let wf = debian_watch::WatchFile::new(Some(4)); assert_eq!(wf.version(), 4); assert_eq!("version=4\n", wf.to_string()); let wf: debian_watch::WatchFile = r#"version=4 opts=foo=blah https://foo.com/bar .*/v?(\d\S+)\.tar\.gz "#.parse().unwrap(); assert_eq!(wf.version(), 4); assert_eq!(wf.entries().collect::>().len(), 1); let entry = wf.entries().next().unwrap(); assert_eq!(entry.opts(), maplit::hashmap! { "foo".to_string() => "blah".to_string(), }); assert_eq!(&entry.url(), "https://foo.com/bar"); assert_eq!(entry.matching_pattern().as_deref(), Some(".*/v?(\\d\\S+)\\.tar\\.gz")); ``` It also supports partial parsing (with some error nodes), which could be useful for e.g. IDEs. debian-watch-0.4.12/src/bin/convert-watch-v5.rs000064400000000000000000000054161046102023000172650ustar 00000000000000//! Command-line tool to convert Debian watch files from formats 1-4 to format 5 use clap::Parser; use debian_watch::convert_to_v5; use debian_watch::linebased::WatchFile; use std::fs; use std::io::{self, Read}; use std::path::PathBuf; #[derive(Parser)] #[command(name = "convert-watch-v5")] #[command(version, about = "Convert Debian watch files from formats 1-4 to format 5", long_about = None)] struct Cli { /// Input watch file (use '-' for stdin) #[arg(value_name = "INPUT")] input: String, /// Output file (defaults to stdout if not specified) #[arg(short, long, value_name = "OUTPUT")] output: Option, /// Overwrite output file if it exists #[arg(short = 'f', long)] force: bool, } fn main() { let cli = Cli::parse(); // Read input let input_content = if cli.input == "-" { let mut buffer = String::new(); io::stdin().read_to_string(&mut buffer).unwrap_or_else(|e| { eprintln!("Error reading from stdin: {}", e); std::process::exit(1); }); buffer } else { fs::read_to_string(&cli.input).unwrap_or_else(|e| { eprintln!("Error reading file '{}': {}", cli.input, e); std::process::exit(1); }) }; // Parse the watch file let watch_file: WatchFile = input_content.parse().unwrap_or_else(|e| { eprintln!("Error parsing watch file: {}", e); std::process::exit(1); }); // Check version let version = watch_file.version(); if version == 5 { eprintln!("Warning: Watch file is already version 5, no conversion needed"); if cli.output.is_none() { // Just output the original content print!("{}", input_content); return; } } // Convert to v5 let v5_file = convert_to_v5(&watch_file).unwrap_or_else(|e| { eprintln!("Error converting watch file: {}", e); std::process::exit(1); }); let output_content = v5_file.to_string(); // Write output if let Some(output_path) = &cli.output { // Check if file exists and force flag is not set if output_path.exists() && !cli.force { eprintln!( "Error: Output file '{}' already exists. Use -f/--force to overwrite.", output_path.display() ); std::process::exit(1); } fs::write(output_path, &output_content).unwrap_or_else(|e| { eprintln!("Error writing to file '{}': {}", output_path.display(), e); std::process::exit(1); }); eprintln!( "Successfully converted watch file from version {} to version 5: {}", version, output_path.display() ); } else { print!("{}", output_content); } } debian-watch-0.4.12/src/convert.rs000064400000000000000000000370141046102023000150600ustar 00000000000000//! Conversion between watch file formats use crate::linebased::{Entry, WatchFile}; use crate::SyntaxKind::*; use deb822_lossless::{Deb822, Paragraph}; /// Error type for conversion failures #[derive(Debug)] pub enum ConversionError { /// Unknown option that cannot be converted to v5 field name UnknownOption(String), /// Invalid version policy value InvalidVersionPolicy(String), } impl std::fmt::Display for ConversionError { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { ConversionError::UnknownOption(opt) => { write!(f, "Unknown option '{}' cannot be converted to v5", opt) } ConversionError::InvalidVersionPolicy(err) => { write!(f, "Invalid version policy: {}", err) } } } } impl std::error::Error for ConversionError {} /// Convert a watch file from formats 1-4 to format 5 /// /// This function preserves comments from the original file by inserting them /// into the CST of the generated v5 watch file. pub fn convert_to_v5(watch_file: &WatchFile) -> Result { // Create a Deb822 with version header as first paragraph let mut paragraphs = vec![vec![("Version", "5")].into_iter().collect()]; // Extract leading comments (before any entries) let leading_comments = extract_leading_comments(watch_file); // Convert each entry to a paragraph for _entry in watch_file.entries() { let para: deb822_lossless::Paragraph = vec![("Source", "placeholder")].into_iter().collect(); paragraphs.push(para); } let deb822: Deb822 = paragraphs.into_iter().collect(); // Now populate the entry paragraphs let mut para_iter = deb822.paragraphs(); para_iter.next(); // Skip version paragraph for (entry, mut para) in watch_file.entries().zip(para_iter) { // Extract and insert comments associated with this entry let entry_comments = extract_entry_comments(&entry); for comment in entry_comments { para.insert_comment_before(&comment); } // Convert entry to v5 format convert_entry_to_v5(&entry, &mut para)?; } // Insert leading comments before the first entry paragraph if any if !leading_comments.is_empty() { if let Some(mut first_entry_para) = deb822.paragraphs().nth(1) { for comment in leading_comments.iter().rev() { first_entry_para.insert_comment_before(comment); } } } // Convert to crate::deb822::WatchFile let output = deb822.to_string(); output .parse() .map_err(|_| ConversionError::UnknownOption("Failed to parse generated v5".to_string())) } /// Extract leading comments from the watch file (before any entries) fn extract_leading_comments(watch_file: &WatchFile) -> Vec { let mut comments = Vec::new(); let syntax = watch_file.syntax(); for child in syntax.children_with_tokens() { match child { rowan::NodeOrToken::Token(token) => { if token.kind() == COMMENT { // Extract comment text without the leading '# ' since // insert_comment_before() will add "# {comment}" let text = token.text(); let comment = text .strip_prefix("# ") .or_else(|| text.strip_prefix('#')) .unwrap_or(text); comments.push(comment.to_string()); } } rowan::NodeOrToken::Node(node) => { // Stop when we hit an entry if node.kind() == ENTRY { break; } } } } comments } /// Extract comments associated with an entry fn extract_entry_comments(entry: &Entry) -> Vec { let mut comments = Vec::new(); let syntax = entry.syntax(); // Get comments that appear before or within this entry for child in syntax.children_with_tokens() { if let rowan::NodeOrToken::Token(token) = child { if token.kind() == COMMENT { // Extract comment text without the leading '# ' since // insert_comment_before() will add "# {comment}" let text = token.text(); let comment = text .strip_prefix("# ") .or_else(|| text.strip_prefix('#')) .unwrap_or(text); comments.push(comment.to_string()); } } } comments } /// Convert a single entry from v1-v4 format to v5 format fn convert_entry_to_v5(entry: &Entry, para: &mut Paragraph) -> Result<(), ConversionError> { // Source field (URL) let url = entry.url(); if !url.is_empty() { para.set("Source", &url); } // Matching-Pattern field if let Some(pattern) = entry.matching_pattern() { para.set("Matching-Pattern", &pattern); } // Version policy match entry.version() { Ok(Some(version_policy)) => { para.set("Version-Policy", &version_policy.to_string()); } Err(err) => return Err(ConversionError::InvalidVersionPolicy(err)), Ok(None) => {} } // Script if let Some(script) = entry.script() { para.set("Script", &script); } // Convert all options to fields if let Some(opts_list) = entry.option_list() { for (key, value) in opts_list.iter_key_values() { // Convert option names to Title-Case with hyphens let field_name = option_to_field_name(&key)?; para.set(&field_name, &value); } } Ok(()) } /// Convert option names from v1-v4 format to v5 field names /// /// Returns an error for unknown options instead of using heuristics. /// /// Uscan's v4→v5 converter (Devscripts::Uscan::Version4) applies `ucfirst` /// to the option name and capitalizes letters after hyphens. Since most v4 /// option names have no hyphens, the result is simply the first letter /// capitalized. The exceptions are `user-agent` → `User-Agent`, and the /// renamed options `date` → `Git-Date` and `pretty` → `Git-Pretty`. /// /// Examples: /// - "filenamemangle" -> "Filenamemangle" /// - "mode" -> "Mode" /// - "pgpmode" -> "Pgpmode" /// - "user-agent" -> "User-Agent" /// - "date" -> "Git-Date" /// - "pretty" -> "Git-Pretty" fn option_to_field_name(option: &str) -> Result { // Options renamed in v5 (from uscan's %RENAMED hash) match option { "date" => return Ok("Git-Date".to_string()), "pretty" => return Ok("Git-Pretty".to_string()), _ => {} } // Known options: apply ucfirst + capitalize after hyphens (matching uscan) match option { "mode" => Ok("Mode".to_string()), "component" => Ok("Component".to_string()), "ctype" => Ok("Ctype".to_string()), "compression" => Ok("Compression".to_string()), "repack" => Ok("Repack".to_string()), "repacksuffix" => Ok("Repacksuffix".to_string()), "bare" => Ok("Bare".to_string()), "user-agent" => Ok("User-Agent".to_string()), "pasv" | "passive" => Ok("Passive".to_string()), "active" | "nopasv" => Ok("Active".to_string()), "unzipopt" => Ok("Unzipopt".to_string()), "decompress" => Ok("Decompress".to_string()), "dversionmangle" => Ok("Dversionmangle".to_string()), "uversionmangle" => Ok("Uversionmangle".to_string()), "downloadurlmangle" => Ok("Downloadurlmangle".to_string()), "filenamemangle" => Ok("Filenamemangle".to_string()), "pgpsigurlmangle" => Ok("Pgpsigurlmangle".to_string()), "oversionmangle" => Ok("Oversionmangle".to_string()), "pagemangle" => Ok("Pagemangle".to_string()), "dirversionmangle" => Ok("Dirversionmangle".to_string()), "versionmangle" => Ok("Versionmangle".to_string()), "hrefdecode" => Ok("Hrefdecode".to_string()), "pgpmode" => Ok("Pgpmode".to_string()), "gitmode" => Ok("Gitmode".to_string()), "gitexport" => Ok("Gitexport".to_string()), "searchmode" => Ok("Searchmode".to_string()), // Return error for unknown options _ => Err(ConversionError::UnknownOption(option.to_string())), } } #[cfg(test)] mod tests { use super::*; #[test] fn test_simple_conversion() { let v4_input = r#"version=4 https://example.com/files .*/v?(\d+\.\d+)\.tar\.gz "#; let v4_file: WatchFile = v4_input.parse().unwrap(); let v5_file = convert_to_v5(&v4_file).unwrap(); assert_eq!(v5_file.version(), 5); let entries: Vec<_> = v5_file.entries().collect(); assert_eq!(entries.len(), 1); assert_eq!(entries[0].url(), "https://example.com/files"); assert_eq!( entries[0].matching_pattern().unwrap(), Some(".*/v?(\\d+\\.\\d+)\\.tar\\.gz".to_string()) ); } #[test] fn test_conversion_with_options() { let v4_input = r#"version=4 opts=filenamemangle=s/.*\/(.*)/$1/,compression=xz https://example.com/files .*/v?(\d+)\.tar\.gz "#; let v4_file: WatchFile = v4_input.parse().unwrap(); let v5_file = convert_to_v5(&v4_file).unwrap(); let entries: Vec<_> = v5_file.entries().collect(); assert_eq!(entries.len(), 1); let entry = &entries[0]; assert_eq!( entry.get_option("Filenamemangle"), Some("s/.*\\/(.*)/$1/".to_string()) ); assert_eq!(entry.get_option("Compression"), Some("xz".to_string())); } #[test] fn test_conversion_with_comments() { // Use a simpler case for now - comment at the beginning before version let v4_input = r#"# This is a comment about the package version=4 opts=filenamemangle=s/.*\/(.*)/$1/ https://example.com/files .*/v?(\d+)\.tar\.gz "#; let v4_file: WatchFile = v4_input.parse().unwrap(); let v5_file = convert_to_v5(&v4_file).unwrap(); let output = ToString::to_string(&v5_file); // Check that comment is preserved and output structure is correct let expected = "Version: 5 # This is a comment about the package Source: https://example.com/files Matching-Pattern: .*/v?(\\d+)\\.tar\\.gz Filenamemangle: s/.*\\/(.*)/$1/ "; assert_eq!(output, expected); } #[test] fn test_conversion_multiple_entries() { let v4_input = r#"version=4 https://example.com/repo1 .*/v?(\d+)\.tar\.gz https://example.com/repo2 .*/release-(\d+)\.tar\.gz "#; let v4_file: WatchFile = v4_input.parse().unwrap(); let v5_file = convert_to_v5(&v4_file).unwrap(); let entries: Vec<_> = v5_file.entries().collect(); assert_eq!(entries.len(), 2); assert_eq!(entries[0].url(), "https://example.com/repo1"); assert_eq!(entries[1].url(), "https://example.com/repo2"); } #[test] fn test_option_to_field_name() { assert_eq!(option_to_field_name("mode").unwrap(), "Mode"); assert_eq!( option_to_field_name("filenamemangle").unwrap(), "Filenamemangle" ); assert_eq!(option_to_field_name("pgpmode").unwrap(), "Pgpmode"); assert_eq!(option_to_field_name("user-agent").unwrap(), "User-Agent"); assert_eq!(option_to_field_name("compression").unwrap(), "Compression"); assert_eq!(option_to_field_name("date").unwrap(), "Git-Date"); assert_eq!(option_to_field_name("pretty").unwrap(), "Git-Pretty"); } #[test] fn test_option_to_field_name_unknown() { let result = option_to_field_name("unknownoption"); assert!(result.is_err()); match result { Err(ConversionError::UnknownOption(opt)) => { assert_eq!(opt, "unknownoption"); } _ => panic!("Expected UnknownOption error"), } } #[test] fn test_roundtrip_conversion() { let v4_input = r#"version=4 opts=compression=xz,component=foo https://example.com/files .*/(\d+)\.tar\.gz "#; let v4_file: WatchFile = v4_input.parse().unwrap(); let v5_file = convert_to_v5(&v4_file).unwrap(); // Verify the v5 file can be parsed back let v5_str = ToString::to_string(&v5_file); let v5_reparsed: crate::deb822::WatchFile = v5_str.parse().unwrap(); let entries: Vec<_> = v5_reparsed.entries().collect(); assert_eq!(entries.len(), 1); assert_eq!(entries[0].component(), Some("foo".to_string())); } #[test] fn test_conversion_with_version_policy_and_script() { let v4_input = r#"version=4 https://example.com/files .*/v?(\d+)\.tar\.gz debian uupdate "#; let v4_file: WatchFile = v4_input.parse().unwrap(); let v5_file = convert_to_v5(&v4_file).unwrap(); let entries: Vec<_> = v5_file.entries().collect(); assert_eq!(entries.len(), 1); let entry = &entries[0]; assert_eq!(entry.url(), "https://example.com/files"); assert_eq!( entry.version_policy().unwrap(), Some(crate::VersionPolicy::Debian) ); assert_eq!(entry.script(), Some("uupdate".to_string())); // Verify the output structure is exactly as expected let output = v5_file.to_string(); let expected = "Version: 5 Source: https://example.com/files Matching-Pattern: .*/v?(\\d+)\\.tar\\.gz Version-Policy: debian Script: uupdate "; assert_eq!(output, expected); } #[test] fn test_conversion_with_mangle_options() { let v4_input = r#"version=4 opts=uversionmangle=s/-/~/g,dversionmangle=s/\+dfsg// https://example.com/files .*/(\d+)\.tar\.gz "#; let v4_file: WatchFile = v4_input.parse().unwrap(); let v5_file = convert_to_v5(&v4_file).unwrap(); let entries: Vec<_> = v5_file.entries().collect(); assert_eq!(entries.len(), 1); let entry = &entries[0]; assert_eq!( entry.get_option("Uversionmangle"), Some("s/-/~/g".to_string()) ); assert_eq!( entry.get_option("Dversionmangle"), Some("s/\\+dfsg//".to_string()) ); // Verify exact output structure let output = v5_file.to_string(); let expected = "Version: 5 Source: https://example.com/files Matching-Pattern: .*/(\\d+)\\.tar\\.gz Uversionmangle: s/-/~/g Dversionmangle: s/\\+dfsg// "; assert_eq!(output, expected); } #[test] fn test_conversion_with_comment_before_entry() { // Regression test for https://bugs.debian.org/1128319: // A comment line before an entry with a continuation line was not converted correctly // - the entry was silently dropped and only "Version: 5" was produced. let v4_input = concat!( "version=4\n", "# try also https://pypi.debian.net/tomoscan/watch\n", "opts=uversionmangle=s/(rc|a|b|c)/~$1/;s/\\.dev/~dev/ \\\n", "https://pypi.debian.net/tomoscan/tomoscan-(.+)\\.(?:zip|tgz|tbz|txz|(?:tar\\.(?:gz|bz2|xz)))\n" ); let v4_file: WatchFile = v4_input.parse().unwrap(); let v5_file = convert_to_v5(&v4_file).unwrap(); assert_eq!(v5_file.version(), 5); let entries: Vec<_> = v5_file.entries().collect(); assert_eq!(entries.len(), 1); assert_eq!( entries[0].url(), "https://pypi.debian.net/tomoscan/tomoscan-(.+)\\.(?:zip|tgz|tbz|txz|(?:tar\\.(?:gz|bz2|xz)))" ); assert_eq!( entries[0].get_option("Uversionmangle"), Some("s/(rc|a|b|c)/~$1/;s/\\.dev/~dev/".to_string()) ); } } debian-watch-0.4.12/src/deb822.rs000064400000000000000000001266451046102023000143770ustar 00000000000000//! Watch file implementation for format 5 (RFC822/deb822 style) use crate::types::ParseError as TypesParseError; use crate::VersionPolicy; use deb822_lossless::{Deb822, Paragraph}; use std::str::FromStr; /// Get the deb822 field name for a WatchOption variant fn watch_option_to_key(option: &crate::types::WatchOption) -> &'static str { use crate::types::WatchOption; match option { WatchOption::Component(_) => "Component", WatchOption::Compression(_) => "Compression", WatchOption::UserAgent(_) => "User-Agent", WatchOption::Pagemangle(_) => "Pagemangle", WatchOption::Uversionmangle(_) => "Uversionmangle", WatchOption::Dversionmangle(_) => "Dversionmangle", WatchOption::Dirversionmangle(_) => "Dirversionmangle", WatchOption::Oversionmangle(_) => "Oversionmangle", WatchOption::Downloadurlmangle(_) => "Downloadurlmangle", WatchOption::Pgpsigurlmangle(_) => "Pgpsigurlmangle", WatchOption::Filenamemangle(_) => "Filenamemangle", WatchOption::VersionPolicy(_) => "Version-Policy", WatchOption::Searchmode(_) => "Searchmode", WatchOption::Mode(_) => "Mode", WatchOption::Pgpmode(_) => "Pgpmode", WatchOption::Gitexport(_) => "Gitexport", WatchOption::Gitmode(_) => "Gitmode", WatchOption::Pretty(_) => "Pretty", WatchOption::Ctype(_) => "Ctype", WatchOption::Repacksuffix(_) => "Repacksuffix", WatchOption::Unzipopt(_) => "Unzipopt", WatchOption::Script(_) => "Script", WatchOption::Decompress => "Decompress", WatchOption::Bare => "Bare", WatchOption::Repack => "Repack", } } #[derive(Debug)] /// Parse error for watch file parsing pub struct ParseError(String); impl std::error::Error for ParseError {} impl std::fmt::Display for ParseError { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { write!(f, "ParseError: {}", self.0) } } /// A watch file in format 5 (RFC822/deb822 style) #[derive(Debug, Clone)] pub struct WatchFile(Deb822); /// An entry in a format 5 watch file #[derive(Debug, Clone)] pub struct Entry { paragraph: Paragraph, defaults: Option, } impl WatchFile { /// Returns a reference to the underlying deb822 document. pub fn as_deb822(&self) -> &Deb822 { &self.0 } /// Capture an independent snapshot of this watch file. /// See [`crate::parse::ParsedWatchFile::snapshot`] for semantics. pub fn snapshot(&self) -> Self { WatchFile(self.0.snapshot()) } /// Returns true iff the syntax trees of `self` and `other` are /// value-equal. An O(1) pointer-identity fast path makes this free for /// trees that still share state with a recent `snapshot()`. pub fn tree_eq(&self, other: &Self) -> bool { self.0.tree_eq(&other.0) } /// Construct a WatchFile from an already-parsed Deb822 document. /// /// This avoids re-parsing when the caller already has a parsed tree. /// No version validation is performed — the caller is responsible for /// ensuring the document is a valid version 5 watch file. pub(crate) fn from_deb822(deb822: Deb822) -> Self { WatchFile(deb822) } /// Create a new empty format 5 watch file pub fn new() -> Self { // Create a minimal format 5 watch file from a string let content = "Version: 5\n"; WatchFile::from_str(content).expect("Failed to create empty watch file") } /// Returns the version of the watch file (always 5 for this type) pub fn version(&self) -> u32 { 5 } /// Returns the defaults paragraph if it exists. /// The defaults paragraph is the second paragraph (after Version) if it has no Source field. pub fn defaults(&self) -> Option { let paragraphs: Vec<_> = self.0.paragraphs().collect(); if paragraphs.len() > 1 { // Check if second paragraph looks like defaults (no Source field) if !paragraphs[1].contains_key("Source") && !paragraphs[1].contains_key("source") { return Some(paragraphs[1].clone()); } } None } /// Returns an iterator over all entries in the watch file. /// The first paragraph contains defaults, subsequent paragraphs are entries. pub fn entries(&self) -> impl Iterator + '_ { let paragraphs: Vec<_> = self.0.paragraphs().collect(); let defaults = self.defaults(); // Skip the first paragraph (version) // The second paragraph (if it exists and has specific fields) contains defaults // Otherwise all paragraphs are entries let start_index = if paragraphs.len() > 1 { // Check if second paragraph looks like defaults (no Source or Template field) let has_source = paragraphs[1].contains_key("Source") || paragraphs[1].contains_key("source"); let has_template = paragraphs[1].contains_key("Template") || paragraphs[1].contains_key("template"); if !has_source && !has_template { 2 // Skip version and defaults } else { 1 // Skip only version } } else { 1 }; paragraphs .into_iter() .skip(start_index) .map(move |p| Entry { paragraph: p, defaults: defaults.clone(), }) } /// Get the underlying Deb822 object pub fn inner(&self) -> &Deb822 { &self.0 } /// Get a mutable reference to the underlying Deb822 object pub fn inner_mut(&mut self) -> &mut Deb822 { &mut self.0 } /// Add a new entry to the watch file with the given source and matching pattern. /// Returns the newly created Entry. /// /// # Example /// /// ``` /// # #[cfg(feature = "deb822")] /// # { /// use debian_watch::deb822::WatchFile; /// use debian_watch::WatchOption; /// /// let mut wf = WatchFile::new(); /// let mut entry = wf.add_entry("https://github.com/foo/bar/tags", ".*/v?([\\d.]+)\\.tar\\.gz"); /// entry.set_option(WatchOption::Component("upstream".to_string())); /// # } /// ``` pub fn add_entry(&mut self, source: &str, matching_pattern: &str) -> Entry { let mut para = self.0.add_paragraph(); para.set("Source", source); para.set("Matching-Pattern", matching_pattern); // Create an Entry from the paragraph we just added // Get the defaults paragraph if it exists let defaults = self.defaults(); Entry { paragraph: para.clone(), defaults, } } } impl Default for WatchFile { fn default() -> Self { Self::new() } } impl FromStr for WatchFile { type Err = ParseError; fn from_str(s: &str) -> Result { match Deb822::from_str(s) { Ok(deb822) => { // Verify it's version 5 let version = deb822 .paragraphs() .next() .and_then(|p| p.get("Version")) .unwrap_or_else(|| "1".to_string()); if version != "5" { return Err(ParseError(format!("Expected version 5, got {}", version))); } Ok(WatchFile(deb822)) } Err(e) => Err(ParseError(e.to_string())), } } } impl std::fmt::Display for WatchFile { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { write!(f, "{}", self.0) } } impl Entry { /// Get a field value from the entry, with fallback to defaults paragraph. /// First checks the entry's own fields, then falls back to the defaults paragraph if present. pub(crate) fn get_field(&self, key: &str) -> Option { // Try the key as-is first in the entry if let Some(value) = self.paragraph.get(key) { return Some(value); } // If not found, try with different case variations in the entry // deb822-lossless is case-preserving, so we need to check all field names let normalized_key = normalize_key(key); // Iterate through all keys in the paragraph and check for normalized match for (k, v) in self.paragraph.items() { if normalize_key(&k) == normalized_key { return Some(v); } } // If not found in entry, check the defaults paragraph if let Some(ref defaults) = self.defaults { // Try the key as-is first in defaults if let Some(value) = defaults.get(key) { return Some(value); } // Try with case variations in defaults for (k, v) in defaults.items() { if normalize_key(&k) == normalized_key { return Some(v); } } } None } /// Returns the source URL, expanding templates if present /// /// Returns `Ok(None)` if no Source field is set and no template is present. /// Returns `Err` if template expansion fails. pub fn source(&self) -> Result, crate::templates::TemplateError> { // First check if explicitly set if let Some(source) = self.get_field("Source") { return Ok(Some(source)); } // If not set, check if there's a template to expand if self.get_field("Template").is_none() { return Ok(None); } // Template exists, expand it (propagate any errors) self.expand_template().map(|t| t.source) } /// Returns the matching pattern, expanding templates if present /// /// Returns `Ok(None)` if no Matching-Pattern field is set and no template is present. /// Returns `Err` if template expansion fails. pub fn matching_pattern(&self) -> Result, crate::templates::TemplateError> { // First check if explicitly set if let Some(pattern) = self.get_field("Matching-Pattern") { return Ok(Some(pattern)); } // If not set, check if there's a template to expand if self.get_field("Template").is_none() { return Ok(None); } // Template exists, expand it (propagate any errors) self.expand_template().map(|t| t.matching_pattern) } /// Get the underlying paragraph pub fn as_deb822(&self) -> &Paragraph { &self.paragraph } /// Name of the component, if specified pub fn component(&self) -> Option { self.get_field("Component") } /// Get the an option value from the entry, with fallback to defaults paragraph. pub fn get_option(&self, key: &str) -> Option { match key { "Source" => None, // Source is not an option "Matching-Pattern" => None, // Matching-Pattern is not an option "Component" => None, // Component is not an option "Version" => None, // Version is not an option key => self.get_field(key), } } /// Set an option value in the entry using a WatchOption enum pub fn set_option(&mut self, option: crate::types::WatchOption) { use crate::types::WatchOption; let (key, value) = match option { WatchOption::Component(v) => ("Component", Some(v)), WatchOption::Compression(v) => ("Compression", Some(v.to_string())), WatchOption::UserAgent(v) => ("User-Agent", Some(v)), WatchOption::Pagemangle(v) => ("Pagemangle", Some(v)), WatchOption::Uversionmangle(v) => ("Uversionmangle", Some(v)), WatchOption::Dversionmangle(v) => ("Dversionmangle", Some(v)), WatchOption::Dirversionmangle(v) => ("Dirversionmangle", Some(v)), WatchOption::Oversionmangle(v) => ("Oversionmangle", Some(v)), WatchOption::Downloadurlmangle(v) => ("Downloadurlmangle", Some(v)), WatchOption::Pgpsigurlmangle(v) => ("Pgpsigurlmangle", Some(v)), WatchOption::Filenamemangle(v) => ("Filenamemangle", Some(v)), WatchOption::VersionPolicy(v) => ("Version-Policy", Some(v.to_string())), WatchOption::Searchmode(v) => ("Searchmode", Some(v.to_string())), WatchOption::Mode(v) => ("Mode", Some(v.to_string())), WatchOption::Pgpmode(v) => ("Pgpmode", Some(v.to_string())), WatchOption::Gitexport(v) => ("Gitexport", Some(v.to_string())), WatchOption::Gitmode(v) => ("Gitmode", Some(v.to_string())), WatchOption::Pretty(v) => ("Pretty", Some(v.to_string())), WatchOption::Ctype(v) => ("Ctype", Some(v.to_string())), WatchOption::Repacksuffix(v) => ("Repacksuffix", Some(v)), WatchOption::Unzipopt(v) => ("Unzipopt", Some(v)), WatchOption::Script(v) => ("Script", Some(v)), WatchOption::Decompress => ("Decompress", None), WatchOption::Bare => ("Bare", None), WatchOption::Repack => ("Repack", None), }; if let Some(v) = value { self.paragraph.set(key, &v); } else { // For boolean flags, set the key with empty value self.paragraph.set(key, ""); } } /// Set an option value in the entry using string key and value (for backward compatibility) pub fn set_option_str(&mut self, key: &str, value: &str) { self.paragraph.set(key, value); } /// Delete an option from the entry using a WatchOption enum pub fn delete_option(&mut self, option: crate::types::WatchOption) { let key = watch_option_to_key(&option); self.paragraph.remove(key); } /// Delete an option from the entry using a string key (for backward compatibility) pub fn delete_option_str(&mut self, key: &str) { self.paragraph.remove(key); } /// Get the URL (same as source() but named url() for consistency) pub fn url(&self) -> String { self.source().unwrap_or(None).unwrap_or_default() } /// Get the version policy pub fn version_policy(&self) -> Result, TypesParseError> { match self.get_field("Version-Policy") { Some(policy) => Ok(Some(policy.parse()?)), None => Ok(None), } } /// Get the script pub fn script(&self) -> Option { self.get_field("Script") } /// Set the source URL pub fn set_source(&mut self, url: &str) { self.paragraph.set("Source", url); } /// Set the matching pattern pub fn set_matching_pattern(&mut self, pattern: &str) { self.paragraph.set("Matching-Pattern", pattern); } /// Get the line number (0-indexed) where this entry starts pub fn line(&self) -> usize { self.paragraph.line() } /// Retrieve the mode of the watch file entry with detailed error information. pub fn mode(&self) -> Result { Ok(self .get_field("Mode") .map(|s| s.parse()) .transpose()? .unwrap_or_default()) } /// Expand template if present fn expand_template( &self, ) -> Result { use crate::templates::{expand_template, parse_github_url, Template, TemplateError}; // Check if there's a Template field let template_str = self.get_field("Template") .ok_or_else(|| TemplateError::MissingField { template: "any".to_string(), field: "Template".to_string(), })?; let release_only = self .get_field("Release-Only") .map(|v| v.to_lowercase() == "yes") .unwrap_or(false); let version_type = self.get_field("Version-Type"); // Build the appropriate Template enum variant let template = match template_str.to_lowercase().as_str() { "github" => { // GitHub requires either Dist or Owner+Project let (owner, repository) = if let (Some(o), Some(p)) = (self.get_field("Owner"), self.get_field("Project")) { (o, p) } else if let Some(dist) = self.get_field("Dist") { parse_github_url(&dist)? } else { return Err(TemplateError::MissingField { template: "GitHub".to_string(), field: "Dist or Owner+Project".to_string(), }); }; Template::GitHub { owner, repository, release_only, version_type, } } "gitlab" => { let dist = self .get_field("Dist") .ok_or_else(|| TemplateError::MissingField { template: "GitLab".to_string(), field: "Dist".to_string(), })?; Template::GitLab { dist, release_only, version_type, } } "pypi" => { let package = self.get_field("Dist") .ok_or_else(|| TemplateError::MissingField { template: "PyPI".to_string(), field: "Dist".to_string(), })?; Template::PyPI { package, version_type, } } "npmregistry" => { let package = self.get_field("Dist") .ok_or_else(|| TemplateError::MissingField { template: "Npmregistry".to_string(), field: "Dist".to_string(), })?; Template::Npmregistry { package, version_type, } } "metacpan" => { let dist = self .get_field("Dist") .ok_or_else(|| TemplateError::MissingField { template: "Metacpan".to_string(), field: "Dist".to_string(), })?; Template::Metacpan { dist, version_type } } "cran" => { let package = self.get_field("Package") .ok_or_else(|| TemplateError::MissingField { template: "CRAN".to_string(), field: "Package".to_string(), })?; Template::Cran { package, version_type, } } "bioconductor" => { let package = self.get_field("Package") .ok_or_else(|| TemplateError::MissingField { template: "Bioconductor".to_string(), field: "Package".to_string(), })?; Template::Bioconductor { package, version_type, } } _ => return Err(TemplateError::UnknownTemplate(template_str)), }; Ok(expand_template(template)) } /// Try to detect if this entry matches a template pattern and convert it to use that template. /// /// This analyzes the Source, Matching-Pattern, Searchmode, and Mode fields to determine /// if they match a known template pattern. If a match is found, the entry is converted /// to use the template syntax instead. /// /// # Returns /// /// Returns `Some(template)` if a template was detected and applied, `None` if no /// template matches the current entry configuration. /// /// # Example /// /// ``` /// # #[cfg(feature = "deb822")] /// # { /// use debian_watch::deb822::WatchFile; /// /// let mut wf = WatchFile::new(); /// let mut entry = wf.add_entry( /// "https://github.com/torvalds/linux/tags", /// r".*/(?:refs/tags/)?v?@ANY_VERSION@@ARCHIVE_EXT@" /// ); /// entry.set_option_str("Searchmode", "html"); /// /// // Convert to template /// if let Some(template) = entry.try_convert_to_template() { /// println!("Converted to {:?}", template); /// } /// # } /// ``` pub fn try_convert_to_template(&mut self) -> Option { use crate::templates::detect_template; // Get current field values let source = self.source().ok().flatten(); let matching_pattern = self.matching_pattern().ok().flatten(); let searchmode = self.get_field("Searchmode"); let mode = self.get_field("Mode"); // Try to detect template let template = detect_template( source.as_deref(), matching_pattern.as_deref(), searchmode.as_deref(), mode.as_deref(), )?; // Apply the template - remove old fields and add template fields self.paragraph.remove("Source"); self.paragraph.remove("Matching-Pattern"); self.paragraph.remove("Searchmode"); self.paragraph.remove("Mode"); // Set template fields based on the detected template match &template { crate::templates::Template::GitHub { owner, repository, release_only, version_type, } => { self.paragraph.set("Template", "GitHub"); self.paragraph.set("Owner", owner); self.paragraph.set("Project", repository); if *release_only { self.paragraph.set("Release-Only", "yes"); } if let Some(vt) = version_type { self.paragraph.set("Version-Type", vt); } } crate::templates::Template::GitLab { dist, release_only: _, version_type, } => { self.paragraph.set("Template", "GitLab"); self.paragraph.set("Dist", dist); if let Some(vt) = version_type { self.paragraph.set("Version-Type", vt); } } crate::templates::Template::PyPI { package, version_type, } => { self.paragraph.set("Template", "PyPI"); self.paragraph.set("Dist", package); if let Some(vt) = version_type { self.paragraph.set("Version-Type", vt); } } crate::templates::Template::Npmregistry { package, version_type, } => { self.paragraph.set("Template", "Npmregistry"); self.paragraph.set("Dist", package); if let Some(vt) = version_type { self.paragraph.set("Version-Type", vt); } } crate::templates::Template::Metacpan { dist, version_type } => { self.paragraph.set("Template", "Metacpan"); self.paragraph.set("Dist", dist); if let Some(vt) = version_type { self.paragraph.set("Version-Type", vt); } } crate::templates::Template::Cran { package, version_type, } => { self.paragraph.set("Template", "CRAN"); self.paragraph.set("Package", &package); if let Some(vt) = version_type { self.paragraph.set("Version-Type", vt); } } crate::templates::Template::Bioconductor { package, version_type, } => { self.paragraph.set("Template", "Bioconductor"); self.paragraph.set("Package", &package); if let Some(vt) = version_type { self.paragraph.set("Version-Type", vt); } } } Some(template) } } /// Normalize a field key according to RFC822 rules: /// - Convert to lowercase /// - Hyphens and underscores are treated as equivalent fn normalize_key(key: &str) -> String { key.to_lowercase().replace(['-', '_'], "") } #[cfg(test)] mod tests { use super::*; #[test] fn test_as_deb822() { let input = r#"Version: 5 Source: https://github.com/owner/repo/tags Matching-Pattern: .*/v?(\d\S+)\.tar\.gz "#; let wf: WatchFile = input.parse().unwrap(); let deb822 = wf.as_deb822(); // Should have 2 paragraphs: version header + entry assert_eq!(deb822.paragraphs().count(), 2); } #[test] fn test_create_v5_watchfile() { let wf = WatchFile::new(); assert_eq!(wf.version(), 5); let output = wf.to_string(); assert!(output.contains("Version")); assert!(output.contains("5")); } #[test] fn test_parse_v5_basic() { let input = r#"Version: 5 Source: https://github.com/owner/repo/tags Matching-Pattern: .*/v?(\d\S+)\.tar\.gz "#; let wf: WatchFile = input.parse().unwrap(); assert_eq!(wf.version(), 5); let entries: Vec<_> = wf.entries().collect(); assert_eq!(entries.len(), 1); let entry = &entries[0]; assert_eq!( entry.source().unwrap().as_deref(), Some("https://github.com/owner/repo/tags") ); assert_eq!( entry.matching_pattern().unwrap(), Some(".*/v?(\\d\\S+)\\.tar\\.gz".to_string()) ); } #[test] fn test_parse_v5_multiple_entries() { let input = r#"Version: 5 Source: https://github.com/owner/repo1/tags Matching-Pattern: .*/v?(\d\S+)\.tar\.gz Source: https://github.com/owner/repo2/tags Matching-Pattern: .*/release-(\d\S+)\.tar\.gz "#; let wf: WatchFile = input.parse().unwrap(); let entries: Vec<_> = wf.entries().collect(); assert_eq!(entries.len(), 2); assert_eq!( entries[0].source().unwrap().as_deref(), Some("https://github.com/owner/repo1/tags") ); assert_eq!( entries[1].source().unwrap().as_deref(), Some("https://github.com/owner/repo2/tags") ); } #[test] fn test_v5_case_insensitive_fields() { let input = r#"Version: 5 source: https://example.com/files matching-pattern: .*\.tar\.gz "#; let wf: WatchFile = input.parse().unwrap(); let entries: Vec<_> = wf.entries().collect(); assert_eq!(entries.len(), 1); let entry = &entries[0]; assert_eq!( entry.source().unwrap().as_deref(), Some("https://example.com/files") ); assert_eq!( entry.matching_pattern().unwrap().as_deref(), Some(".*\\.tar\\.gz") ); } #[test] fn test_v5_with_compression_option() { let input = r#"Version: 5 Source: https://example.com/files Matching-Pattern: .*\.tar\.gz Compression: xz "#; let wf: WatchFile = input.parse().unwrap(); let entries: Vec<_> = wf.entries().collect(); assert_eq!(entries.len(), 1); let entry = &entries[0]; let compression = entry.get_option("compression"); assert!(compression.is_some()); } #[test] fn test_v5_with_component() { let input = r#"Version: 5 Source: https://example.com/files Matching-Pattern: .*\.tar\.gz Component: foo "#; let wf: WatchFile = input.parse().unwrap(); let entries: Vec<_> = wf.entries().collect(); assert_eq!(entries.len(), 1); let entry = &entries[0]; assert_eq!(entry.component(), Some("foo".to_string())); } #[test] fn test_v5_rejects_wrong_version() { let input = r#"Version: 4 Source: https://example.com/files Matching-Pattern: .*\.tar\.gz "#; let result: Result = input.parse(); assert!(result.is_err()); } #[test] fn test_v5_roundtrip() { let input = r#"Version: 5 Source: https://example.com/files Matching-Pattern: .*\.tar\.gz "#; let wf: WatchFile = input.parse().unwrap(); let output = wf.to_string(); // The output should be parseable again let wf2: WatchFile = output.parse().unwrap(); assert_eq!(wf2.version(), 5); let entries: Vec<_> = wf2.entries().collect(); assert_eq!(entries.len(), 1); } #[test] fn test_normalize_key() { assert_eq!(normalize_key("Matching-Pattern"), "matchingpattern"); assert_eq!(normalize_key("matching_pattern"), "matchingpattern"); assert_eq!(normalize_key("MatchingPattern"), "matchingpattern"); assert_eq!(normalize_key("MATCHING-PATTERN"), "matchingpattern"); } #[test] fn test_defaults_paragraph() { let input = r#"Version: 5 Compression: xz User-Agent: Custom/1.0 Source: https://example.com/repo1 Matching-Pattern: .*\.tar\.gz Source: https://example.com/repo2 Matching-Pattern: .*\.tar\.gz Compression: gz "#; let wf: WatchFile = input.parse().unwrap(); // Check that defaults paragraph is detected let defaults = wf.defaults(); assert!(defaults.is_some()); let defaults = defaults.unwrap(); assert_eq!(defaults.get("Compression"), Some("xz".to_string())); assert_eq!(defaults.get("User-Agent"), Some("Custom/1.0".to_string())); // Check that entries inherit from defaults let entries: Vec<_> = wf.entries().collect(); assert_eq!(entries.len(), 2); // First entry should inherit Compression and User-Agent from defaults assert_eq!(entries[0].get_option("Compression"), Some("xz".to_string())); assert_eq!( entries[0].get_option("User-Agent"), Some("Custom/1.0".to_string()) ); // Second entry overrides Compression but inherits User-Agent assert_eq!(entries[1].get_option("Compression"), Some("gz".to_string())); assert_eq!( entries[1].get_option("User-Agent"), Some("Custom/1.0".to_string()) ); } #[test] fn test_no_defaults_paragraph() { let input = r#"Version: 5 Source: https://example.com/repo1 Matching-Pattern: .*\.tar\.gz "#; let wf: WatchFile = input.parse().unwrap(); // Check that there's no defaults paragraph (first paragraph has Source) assert!(wf.defaults().is_none()); let entries: Vec<_> = wf.entries().collect(); assert_eq!(entries.len(), 1); } #[test] fn test_set_source() { let mut wf = WatchFile::new(); let mut entry = wf.add_entry("https://example.com/repo1", ".*\\.tar\\.gz"); assert_eq!( entry.source().unwrap(), Some("https://example.com/repo1".to_string()) ); entry.set_source("https://example.com/repo2"); assert_eq!( entry.source().unwrap(), Some("https://example.com/repo2".to_string()) ); } #[test] fn test_set_matching_pattern() { let mut wf = WatchFile::new(); let mut entry = wf.add_entry("https://example.com/repo1", ".*\\.tar\\.gz"); assert_eq!( entry.matching_pattern().unwrap(), Some(".*\\.tar\\.gz".to_string()) ); entry.set_matching_pattern(".*/v?([\\d.]+)\\.tar\\.gz"); assert_eq!( entry.matching_pattern().unwrap(), Some(".*/v?([\\d.]+)\\.tar\\.gz".to_string()) ); } #[test] fn test_entry_line() { let input = r#"Version: 5 Source: https://example.com/repo1 Matching-Pattern: .*\.tar\.gz Source: https://example.com/repo2 Matching-Pattern: .*\.tar\.xz "#; let wf: WatchFile = input.parse().unwrap(); let entries: Vec<_> = wf.entries().collect(); // First entry starts at line 2 (0-indexed) assert_eq!(entries[0].line(), 2); // Second entry starts at line 5 (0-indexed) assert_eq!(entries[1].line(), 5); } #[test] fn test_defaults_with_case_variations() { let input = r#"Version: 5 compression: xz user-agent: Custom/1.0 Source: https://example.com/repo1 Matching-Pattern: .*\.tar\.gz "#; let wf: WatchFile = input.parse().unwrap(); // Check that defaults work with different case let entries: Vec<_> = wf.entries().collect(); assert_eq!(entries.len(), 1); // Should find defaults even with different case assert_eq!(entries[0].get_option("Compression"), Some("xz".to_string())); assert_eq!( entries[0].get_option("User-Agent"), Some("Custom/1.0".to_string()) ); } #[test] fn test_v5_with_uversionmangle() { let input = r#"Version: 5 Source: https://pypi.org/project/foo/ Matching-Pattern: foo-(\d+\.\d+)\.tar\.gz Uversionmangle: s/\.0+$// "#; let wf: WatchFile = input.parse().unwrap(); let entries: Vec<_> = wf.entries().collect(); assert_eq!(entries.len(), 1); let entry = &entries[0]; assert_eq!( entry.get_option("Uversionmangle"), Some("s/\\.0+$//".to_string()) ); } #[test] fn test_v5_with_filenamemangle() { let input = r#"Version: 5 Source: https://example.com/files Matching-Pattern: .*\.tar\.gz Filenamemangle: s/.*\///;s/@PACKAGE@-(.*)\.tar\.gz/foo_$1.orig.tar.gz/ "#; let wf: WatchFile = input.parse().unwrap(); let entries: Vec<_> = wf.entries().collect(); assert_eq!(entries.len(), 1); let entry = &entries[0]; assert_eq!( entry.get_option("Filenamemangle"), Some("s/.*\\///;s/@PACKAGE@-(.*)\\.tar\\.gz/foo_$1.orig.tar.gz/".to_string()) ); } #[test] fn test_v5_with_searchmode() { let input = r#"Version: 5 Source: https://example.com/files Matching-Pattern: foo-(\d[\d.]*)\.tar\.gz Searchmode: plain "#; let wf: WatchFile = input.parse().unwrap(); let entries: Vec<_> = wf.entries().collect(); assert_eq!(entries.len(), 1); let entry = &entries[0]; assert_eq!(entry.get_field("Searchmode").as_deref(), Some("plain")); } #[test] fn test_v5_with_version_policy() { let input = r#"Version: 5 Source: https://example.com/files Matching-Pattern: .*\.tar\.gz Version-Policy: debian "#; let wf: WatchFile = input.parse().unwrap(); let entries: Vec<_> = wf.entries().collect(); assert_eq!(entries.len(), 1); let entry = &entries[0]; let policy = entry.version_policy(); assert!(policy.is_ok()); assert_eq!(format!("{:?}", policy.unwrap().unwrap()), "Debian"); } #[test] fn test_v5_multiple_mangles() { let input = r#"Version: 5 Source: https://example.com/files Matching-Pattern: .*\.tar\.gz Uversionmangle: s/^v//;s/\.0+$// Dversionmangle: s/\+dfsg\d*$// Filenamemangle: s/.*/foo-$1.tar.gz/ "#; let wf: WatchFile = input.parse().unwrap(); let entries: Vec<_> = wf.entries().collect(); assert_eq!(entries.len(), 1); let entry = &entries[0]; assert_eq!( entry.get_option("Uversionmangle"), Some("s/^v//;s/\\.0+$//".to_string()) ); assert_eq!( entry.get_option("Dversionmangle"), Some("s/\\+dfsg\\d*$//".to_string()) ); assert_eq!( entry.get_option("Filenamemangle"), Some("s/.*/foo-$1.tar.gz/".to_string()) ); } #[test] fn test_v5_with_pgpmode() { let input = r#"Version: 5 Source: https://example.com/files Matching-Pattern: .*\.tar\.gz Pgpmode: auto "#; let wf: WatchFile = input.parse().unwrap(); let entries: Vec<_> = wf.entries().collect(); assert_eq!(entries.len(), 1); let entry = &entries[0]; assert_eq!(entry.get_option("Pgpmode"), Some("auto".to_string())); } #[test] fn test_v5_with_comments() { let input = r#"Version: 5 # This is a comment about the entry Source: https://example.com/files Matching-Pattern: .*\.tar\.gz "#; let wf: WatchFile = input.parse().unwrap(); let entries: Vec<_> = wf.entries().collect(); assert_eq!(entries.len(), 1); // Verify roundtrip preserves comments let output = wf.to_string(); assert!(output.contains("# This is a comment about the entry")); } #[test] fn test_v5_empty_after_version() { let input = "Version: 5\n"; let wf: WatchFile = input.parse().unwrap(); assert_eq!(wf.version(), 5); let entries: Vec<_> = wf.entries().collect(); assert_eq!(entries.len(), 0); } #[test] fn test_v5_trait_url() { let input = r#"Version: 5 Source: https://example.com/files/@PACKAGE@ Matching-Pattern: .*\.tar\.gz "#; let wf: WatchFile = input.parse().unwrap(); let entries: Vec<_> = wf.entries().collect(); assert_eq!(entries.len(), 1); let entry = &entries[0]; // Test url() method assert_eq!( entry.source().unwrap().as_deref(), Some("https://example.com/files/@PACKAGE@") ); } #[test] fn test_github_template() { let input = r#"Version: 5 Template: GitHub Owner: torvalds Project: linux "#; let wf: WatchFile = input.parse().unwrap(); let entries: Vec<_> = wf.entries().collect(); assert_eq!(entries.len(), 1); let entry = &entries[0]; assert_eq!( entry.source().unwrap(), Some("https://github.com/torvalds/linux/tags".to_string()) ); assert_eq!( entry.matching_pattern().unwrap(), Some(r".*/(?:refs/tags/)?v?@ANY_VERSION@@ARCHIVE_EXT@".to_string()) ); } #[test] fn test_github_template_with_dist() { let input = r#"Version: 5 Template: GitHub Dist: https://github.com/guimard/llng-docker "#; let wf: WatchFile = input.parse().unwrap(); let entries: Vec<_> = wf.entries().collect(); assert_eq!(entries.len(), 1); let entry = &entries[0]; assert_eq!( entry.source().unwrap(), Some("https://github.com/guimard/llng-docker/tags".to_string()) ); } #[test] fn test_pypi_template() { let input = r#"Version: 5 Template: PyPI Dist: bitbox02 "#; let wf: WatchFile = input.parse().unwrap(); let entries: Vec<_> = wf.entries().collect(); assert_eq!(entries.len(), 1); let entry = &entries[0]; assert_eq!( entry.source().unwrap(), Some("https://pypi.debian.net/bitbox02/".to_string()) ); assert_eq!( entry.matching_pattern().unwrap(), Some( r"https://pypi\.debian\.net/bitbox02/[^/]+\.tar\.gz#/.*-@ANY_VERSION@\.tar\.gz" .to_string() ) ); } #[test] fn test_gitlab_template() { let input = r#"Version: 5 Template: GitLab Dist: https://salsa.debian.org/debian/devscripts "#; let wf: WatchFile = input.parse().unwrap(); let entries: Vec<_> = wf.entries().collect(); assert_eq!(entries.len(), 1); let entry = &entries[0]; assert_eq!( entry.source().unwrap(), Some("https://salsa.debian.org/debian/devscripts".to_string()) ); assert_eq!( entry.matching_pattern().unwrap(), Some(r".*/v?@ANY_VERSION@@ARCHIVE_EXT@".to_string()) ); } #[test] fn test_template_with_explicit_source() { // Explicit Source should override template expansion let input = r#"Version: 5 Template: GitHub Owner: test Project: project Source: https://custom.example.com/ "#; let wf: WatchFile = input.parse().unwrap(); let entries: Vec<_> = wf.entries().collect(); assert_eq!(entries.len(), 1); let entry = &entries[0]; assert_eq!( entry.source().unwrap(), Some("https://custom.example.com/".to_string()) ); } #[test] fn test_convert_to_template_github() { let mut wf = WatchFile::new(); let mut entry = wf.add_entry( "https://github.com/torvalds/linux/tags", r".*/(?:refs/tags/)?v?@ANY_VERSION@@ARCHIVE_EXT@", ); entry.set_option_str("Searchmode", "html"); // Convert to template let template = entry.try_convert_to_template(); assert_eq!( template, Some(crate::templates::Template::GitHub { owner: "torvalds".to_string(), repository: "linux".to_string(), release_only: false, version_type: None, }) ); // Verify the entry now uses template syntax assert_eq!(entry.get_field("Template"), Some("GitHub".to_string())); assert_eq!(entry.get_field("Owner"), Some("torvalds".to_string())); assert_eq!(entry.get_field("Project"), Some("linux".to_string())); assert_eq!(entry.get_field("Source"), None); assert_eq!(entry.get_field("Matching-Pattern"), None); } #[test] fn test_convert_to_template_pypi() { let mut wf = WatchFile::new(); let mut entry = wf.add_entry( "https://pypi.debian.net/bitbox02/", r"https://pypi\.debian\.net/bitbox02/[^/]+\.tar\.gz#/.*-@ANY_VERSION@\.tar\.gz", ); entry.set_option_str("Searchmode", "plain"); // Convert to template let template = entry.try_convert_to_template(); assert_eq!( template, Some(crate::templates::Template::PyPI { package: "bitbox02".to_string(), version_type: None, }) ); // Verify the entry now uses template syntax assert_eq!(entry.get_field("Template"), Some("PyPI".to_string())); assert_eq!(entry.get_field("Dist"), Some("bitbox02".to_string())); } #[test] fn test_convert_to_template_no_match() { let mut wf = WatchFile::new(); let mut entry = wf.add_entry( "https://example.com/downloads/", r".*/v?(\d+\.\d+)\.tar\.gz", ); // Try to convert - should return None let template = entry.try_convert_to_template(); assert_eq!(template, None); // Entry should remain unchanged assert_eq!( entry.source().unwrap(), Some("https://example.com/downloads/".to_string()) ); } #[test] fn test_convert_to_template_roundtrip() { let mut wf = WatchFile::new(); let mut entry = wf.add_entry( "https://github.com/test/project/releases", r".*/(?:refs/tags/)?v?@ANY_VERSION@@ARCHIVE_EXT@", ); entry.set_option_str("Searchmode", "html"); // Convert to template entry.try_convert_to_template().unwrap(); // Now the entry should be able to expand back to the same values let source = entry.source().unwrap(); let matching_pattern = entry.matching_pattern().unwrap(); assert_eq!( source, Some("https://github.com/test/project/releases".to_string()) ); assert_eq!( matching_pattern, Some(r".*/(?:refs/tags/)?v?@ANY_VERSION@@ARCHIVE_EXT@".to_string()) ); } } debian-watch-0.4.12/src/discover.rs000064400000000000000000000426551046102023000152250ustar 00000000000000//! Discover upstream releases from watch file entries //! //! This module provides methods for discovering upstream releases by fetching URLs //! and searching for version patterns. use crate::parse::{ParsedEntry, ParsedWatchFile}; use crate::release::Release; use crate::DEFAULT_USER_AGENT; use std::error::Error; /// Default matching pattern used when none is specified /// Expands to: (?:package-name)?[-_]?(\d[\-+\.:\~\da-zA-Z]*)(?i)\.(?:tar\.xz|tar\.bz2|tar\.gz|zip|tgz|tbz|txz) const DEFAULT_MATCHING_PATTERN: &str = "(?:@PACKAGE@)?@ANY_VERSION@@ARCHIVE_EXT@"; /// Error type for discovery operations #[derive(Debug)] pub enum DiscoveryError { /// HTTP request failed HttpError(reqwest::Error), /// Pattern matching failed PatternError(MangleError), /// Missing required field MissingField(String), /// URL parsing error UrlError(url::ParseError), /// IO error IoError(std::io::Error), } use crate::mangle::MangleError; impl std::fmt::Display for DiscoveryError { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { DiscoveryError::HttpError(e) => write!(f, "HTTP error: {}", e), DiscoveryError::PatternError(e) => write!(f, "Pattern error: {}", e), DiscoveryError::MissingField(msg) => write!(f, "Missing field: {}", msg), DiscoveryError::UrlError(e) => write!(f, "URL error: {}", e), DiscoveryError::IoError(e) => write!(f, "IO error: {}", e), } } } impl Error for DiscoveryError { fn source(&self) -> Option<&(dyn Error + 'static)> { match self { DiscoveryError::HttpError(e) => Some(e), DiscoveryError::PatternError(e) => Some(e), DiscoveryError::MissingField(_) => None, DiscoveryError::UrlError(e) => Some(e), DiscoveryError::IoError(e) => Some(e), } } } impl From for DiscoveryError { fn from(err: std::io::Error) -> Self { DiscoveryError::IoError(err) } } impl From for DiscoveryError { fn from(err: reqwest::Error) -> Self { DiscoveryError::HttpError(err) } } impl From for DiscoveryError { fn from(err: url::ParseError) -> Self { DiscoveryError::UrlError(err) } } impl From for DiscoveryError { fn from(err: MangleError) -> Self { DiscoveryError::PatternError(err) } } impl ParsedEntry { /// Discover all available releases for this watch entry (async version) /// /// Fetches the URL specified in the watch entry and searches for releases /// matching the configured pattern. /// /// # Arguments /// /// * `package` - Closure that returns the package name to use for substitution in URLs /// /// # Examples /// /// ```ignore /// use debian_watch::parse::ParsedWatchFile; /// /// # async fn example() -> Result<(), Box> { /// let wf: ParsedWatchFile = debian_watch::parse::parse(r#"version=4 /// https://example.com/files .*/v?(\\d\\S+)\\.tar\\.gz /// "#)?; /// /// let entry = wf.entries().next().unwrap(); /// let releases = entry.discover(|| "mypackage".to_string()).await?; /// for release in releases { /// println!("Found version: {}", release.version); /// } /// # Ok(()) /// # } /// ``` pub async fn discover( &self, package: impl FnOnce() -> String + Send, ) -> Result, DiscoveryError> { self.discover_impl(package, None).await } /// Discover all available releases with a custom HTTP client (async version) /// /// This is the same as `discover()` but allows providing a custom reqwest client /// for more control over HTTP requests. pub async fn discover_with_client( &self, package: impl FnOnce() -> String + Send, client: &reqwest::Client, ) -> Result, DiscoveryError> { self.discover_impl(package, Some(client)).await } /// Internal implementation for discovering releases async fn discover_impl( &self, package: impl FnOnce() -> String + Send, client: Option<&reqwest::Client>, ) -> Result, DiscoveryError> { let component = self.component().unwrap_or_default(); let url = self.format_url(package, || component.clone())?; let user_agent = self .user_agent() .unwrap_or_else(|| DEFAULT_USER_AGENT.to_string()); // Build HTTP client if not provided let default_client; let http_client = if let Some(c) = client { c } else { default_client = reqwest::Client::builder().user_agent(user_agent).build()?; &default_client }; // Fetch the URL let response = http_client.get(url.as_str()).send().await?; let body = response.bytes().await?; // Apply pagemangle if present let mangled_body = if let Some(mangle) = self.pagemangle() { let page_str = String::from_utf8_lossy(&body); let result = crate::mangle::apply_mangle(&mangle, &page_str)?; result.into_bytes() } else { body.to_vec() }; // Get the matching pattern, using default if not specified let pattern_str = self .matching_pattern() .unwrap_or_else(|| DEFAULT_MATCHING_PATTERN.to_string()); // Apply substitution to the matching pattern let package_name = String::new(); let component_name = String::new(); let pattern = crate::subst::subst( &pattern_str, || package_name.clone(), || component_name.clone(), ); // Determine search mode let searchmode = self.searchmode(); let searchmode_str = searchmode.to_string(); // Search for versions let results = crate::search::search( &searchmode_str, std::io::Cursor::new(mangled_body.as_ref() as &[u8]), &pattern, &package_name, url.as_str(), )?; // Apply mangles to each result and convert to Release objects let mut releases = Vec::new(); for (version, full_url) in results { // Apply uversionmangle let mangled_version = if let Some(mangle) = self.uversionmangle() { crate::mangle::apply_mangle(&mangle, &version)? } else { version }; // Apply downloadurlmangle let mangled_url = if let Some(mangle) = self.downloadurlmangle() { crate::mangle::apply_mangle(&mangle, &full_url)? } else { full_url }; // Apply pgpsigurlmangle if present let pgpsigurl = if let Some(mangle) = self.pgpsigurlmangle() { Some(crate::mangle::apply_mangle(&mangle, &mangled_url)?) } else { None }; // Apply filenamemangle if present let target_filename = if let Some(mangle) = self.filenamemangle() { Some(crate::mangle::apply_mangle(&mangle, &mangled_url)?) } else { None }; // Apply oversionmangle if present let package_version = if let Some(mangle) = self.oversionmangle() { Some(crate::mangle::apply_mangle(&mangle, &mangled_version)?) } else { None }; releases.push(Release::new_full( mangled_version, mangled_url, pgpsigurl, target_filename, package_version, )); } Ok(releases) } /// Discover all available releases for this watch entry (blocking version) /// /// This is the blocking version of `discover()`. Requires the 'blocking' feature. #[cfg(feature = "blocking")] pub fn discover_blocking( &self, package: impl FnOnce() -> String, ) -> Result, DiscoveryError> { self.discover_blocking_impl(package, None) } /// Discover all available releases with a custom HTTP client (blocking version) #[cfg(feature = "blocking")] pub fn discover_blocking_with_client( &self, package: impl FnOnce() -> String, client: &reqwest::blocking::Client, ) -> Result, DiscoveryError> { self.discover_blocking_impl(package, Some(client)) } /// Internal implementation for blocking discover #[cfg(feature = "blocking")] fn discover_blocking_impl( &self, package: impl FnOnce() -> String, client: Option<&reqwest::blocking::Client>, ) -> Result, DiscoveryError> { // Get the URL and apply package and component substitution let component = self.component().unwrap_or_default(); let url = self.format_url(package, || component.clone())?; // Get user agent let user_agent = self .user_agent() .unwrap_or_else(|| DEFAULT_USER_AGENT.to_string()); // Build HTTP client if not provided let default_client; let http_client = if let Some(c) = client { c } else { default_client = reqwest::blocking::Client::builder() .user_agent(user_agent) .build()?; &default_client }; // Fetch the URL let response = http_client.get(url.as_str()).send()?; let body = response.bytes()?; // Apply pagemangle if present let mangled_body = if let Some(mangle) = self.pagemangle() { let page_str = String::from_utf8_lossy(&body); let result = crate::mangle::apply_mangle(&mangle, &page_str)?; result.into_bytes() } else { body.to_vec() }; // Get the matching pattern, using default if not specified let matching_pattern = self .matching_pattern() .unwrap_or_else(|| DEFAULT_MATCHING_PATTERN.to_string()); // Apply substitution to the matching pattern let package_name = String::new(); let component_name = String::new(); let pattern = crate::subst::subst( &matching_pattern, || package_name.clone(), || component_name.clone(), ); // Determine search mode let searchmode = self.searchmode(); let searchmode_str = searchmode.to_string(); // Search for versions let results = crate::search::search( &searchmode_str, std::io::Cursor::new(mangled_body.as_ref() as &[u8]), &pattern, &package_name, url.as_str(), )?; // Apply mangles to each result and convert to Release objects let mut releases = Vec::new(); for (version, full_url) in results { // Apply uversionmangle let mangled_version = if let Some(mangle) = self.uversionmangle() { crate::mangle::apply_mangle(&mangle, &version)? } else { version }; // Apply downloadurlmangle let mangled_url = if let Some(mangle) = self.downloadurlmangle() { crate::mangle::apply_mangle(&mangle, &full_url)? } else { full_url }; // Apply pgpsigurlmangle if present let pgpsigurl = if let Some(mangle) = self.pgpsigurlmangle() { Some(crate::mangle::apply_mangle(&mangle, &mangled_url)?) } else { None }; // Apply filenamemangle if present let target_filename = if let Some(mangle) = self.filenamemangle() { Some(crate::mangle::apply_mangle(&mangle, &mangled_url)?) } else { None }; // Apply oversionmangle if present let package_version = if let Some(mangle) = self.oversionmangle() { Some(crate::mangle::apply_mangle(&mangle, &mangled_version)?) } else { None }; releases.push(Release::new_full( mangled_version, mangled_url, pgpsigurl, target_filename, package_version, )); } Ok(releases) } } impl ParsedWatchFile { /// Discover releases from all entries in the watch file (async version) /// /// # Arguments /// /// * `package` - Closure that returns the package name to use for substitution in URLs /// /// # Examples /// /// ```ignore /// use debian_watch::parse::ParsedWatchFile; /// /// # async fn example() -> Result<(), Box> { /// let wf: ParsedWatchFile = debian_watch::parse::parse(r#"version=4 /// https://example.com/files .*/v?(\\d\\S+)\\.tar\\.gz /// "#)?; /// /// let all_releases = wf.discover_all(|| "mypackage".to_string()).await?; /// for (entry_idx, releases) in all_releases.iter().enumerate() { /// println!("Entry {}: {} releases found", entry_idx, releases.len()); /// } /// # Ok(()) /// # } /// ``` pub async fn discover_all( &self, package: impl Fn() -> String + Send + Clone + 'static, ) -> Result>, DiscoveryError> { // Collect entries before async block to avoid holding self reference let entries: Vec<_> = self.entries().collect(); let mut all_releases = Vec::new(); for entry in entries { let pkg = package.clone(); let releases = entry.discover(move || pkg()).await?; all_releases.push(releases); } Ok(all_releases) } /// Discover releases from all entries in the watch file (blocking version) #[cfg(feature = "blocking")] pub fn discover_all_blocking( &self, package: impl Fn() -> String, ) -> Result>, DiscoveryError> { let mut all_releases = Vec::new(); for entry in self.entries() { let releases = entry.discover_blocking(&package)?; all_releases.push(releases); } Ok(all_releases) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_discovery_error_display() { let err = DiscoveryError::MissingField("url".to_string()); assert_eq!(err.to_string(), "Missing field: url"); let err = DiscoveryError::PatternError(MangleError::RegexError("invalid regex".to_string())); assert_eq!(err.to_string(), "Pattern error: regex error: invalid regex"); } #[test] fn test_default_matching_pattern_value() { // Verify the default pattern constant matches uscan's default assert_eq!( DEFAULT_MATCHING_PATTERN, "(?:@PACKAGE@)?@ANY_VERSION@@ARCHIVE_EXT@" ); } #[cfg(feature = "blocking")] #[test] fn test_discover_blocking_with_no_matching_pattern() { use crate::parse::parse; use std::thread; // Start a tiny_http server on a random port let server = tiny_http::Server::http("127.0.0.1:0").unwrap(); let addr = server.server_addr(); let server_thread = thread::spawn(move || { let request = server.recv().unwrap(); let response = tiny_http::Response::from_string( "Download", ) .with_header( tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"text/html"[..]).unwrap(), ); request.respond(response).unwrap(); }); // Create a watch file with no matching pattern let watch_content = format!( r#"version=4 http://{} "#, addr ); let parsed = parse(&watch_content).unwrap(); let entries: Vec<_> = parsed.entries().collect(); assert_eq!(entries.len(), 1); let entry = &entries[0]; // Verify matching_pattern is None assert_eq!(entry.matching_pattern(), None); // Call discover_blocking - should use default pattern and find the release let result = entry.discover_blocking(|| "mypackage".to_string()); server_thread.join().unwrap(); // Should successfully find the release using default pattern let releases = result.expect("discover should succeed with default pattern"); assert_eq!(releases.len(), 1); assert_eq!(releases[0].version, "1.2.3"); } #[test] fn test_explicit_pattern_still_works() { // Ensure that when a pattern IS specified, it's still used (not overridden by default) use crate::parse::parse; let watch_content = r#"version=4 https://example.com/releases/ custom-pattern-(\d+)\.zip "#; let parsed = parse(watch_content).unwrap(); let entries: Vec<_> = parsed.entries().collect(); assert_eq!(entries.len(), 1); let entry = &entries[0]; // Verify the explicit pattern is present assert_eq!( entry.matching_pattern(), Some("custom-pattern-(\\d+)\\.zip".to_string()) ); // Verify the logic would use the explicit pattern, not the default let pattern_str = entry .matching_pattern() .unwrap_or_else(|| DEFAULT_MATCHING_PATTERN.to_string()); assert_eq!(pattern_str, "custom-pattern-(\\d+)\\.zip"); } } debian-watch-0.4.12/src/lex.rs000064400000000000000000000100521046102023000141610ustar 00000000000000use crate::SyntaxKind; use crate::SyntaxKind::*; /// Split the input string into a flat list of tokens pub(crate) fn lex(text: &str) -> Vec<(SyntaxKind, String)> { fn tok(t: SyntaxKind) -> m_lexer::TokenKind { let sk = rowan::SyntaxKind::from(t); m_lexer::TokenKind(sk.0) } fn kind(t: m_lexer::TokenKind) -> SyntaxKind { match t.0 { 0 => KEY, 1 => VALUE, 2 => EQUALS, 3 => QUOTE, 4 => COMMA, 5 => CONTINUATION, 6 => NEWLINE, 7 => WHITESPACE, 8 => COMMENT, 9 => ERROR, _ => unreachable!(), } } let lexer = m_lexer::LexerBuilder::new() .error_token(tok(ERROR)) .tokens(&[ (tok(KEY), r"[a-z]+"), (tok(QUOTE), "\""), (tok(VALUE), r#"[^\s=,"]*[^\s=\\,"]"#), (tok(CONTINUATION), r"\\\n"), (tok(EQUALS), r"="), (tok(COMMA), r","), (tok(NEWLINE), r"\n"), (tok(WHITESPACE), r"[ \t\r]+"), (tok(COMMENT), r"#[^\n]*"), ]) .build(); lexer .tokenize(text) .into_iter() .map(|t| (t.len, kind(t.kind))) .scan(0usize, |start_offset, (len, kind)| { let s = text[*start_offset..*start_offset + len].to_string(); *start_offset += len; Some((kind, s)) }) .collect() } #[cfg(test)] mod tests { use crate::SyntaxKind::*; #[test] fn test_empty() { assert_eq!(super::lex(""), vec![]); } #[test] fn test_simple() { assert_eq!( super::lex( r#"version=4 opts=bare,filenamemangle=s/.+\/v?(\d\S+)\.tar\.gz/syncthing-gtk-$1\.tar\.gz/ \ https://github.com/syncthing/syncthing-gtk/tags .*/v?(\d\S+)\.tar\.gz "# ), vec![ (KEY, "version".into()), (EQUALS, "=".into()), (VALUE, "4".into()), (NEWLINE, "\n".into()), (KEY, "opts".into()), (EQUALS, "=".into()), (KEY, "bare".into()), (COMMA, ",".into()), (KEY, "filenamemangle".into()), (EQUALS, "=".into()), ( VALUE, "s/.+\\/v?(\\d\\S+)\\.tar\\.gz/syncthing-gtk-$1\\.tar\\.gz/".into() ), (WHITESPACE, " ".into()), (CONTINUATION, "\\\n".into()), (WHITESPACE, " ".into()), ( VALUE, "https://github.com/syncthing/syncthing-gtk/tags".into() ), (WHITESPACE, " ".into()), (VALUE, ".*/v?(\\d\\S+)\\.tar\\.gz".into()), (NEWLINE, "\n".into()), ] ); } #[test] fn test_quoted() { assert_eq!( super::lex( r#"version=4 opts="bare, filenamemangle=foo" \ https://github.com/syncthing/syncthing-gtk/tags .*/v?(\d\S+)\.tar\.gz "# ), vec![ (KEY, "version".into()), (EQUALS, "=".into()), (VALUE, "4".into()), (NEWLINE, "\n".into()), (KEY, "opts".into()), (EQUALS, "=".into()), (QUOTE, "\"".into()), (KEY, "bare".into()), (COMMA, ",".into()), (WHITESPACE, " ".into()), (KEY, "filenamemangle".into()), (EQUALS, "=".into()), (KEY, "foo".into()), (QUOTE, "\"".into()), (WHITESPACE, " ".into()), (CONTINUATION, "\\\n".into()), (WHITESPACE, " ".into()), ( VALUE, "https://github.com/syncthing/syncthing-gtk/tags".into() ), (WHITESPACE, " ".into()), (VALUE, ".*/v?(\\d\\S+)\\.tar\\.gz".into()), (NEWLINE, "\n".into()), ] ); } } debian-watch-0.4.12/src/lib.rs000064400000000000000000000115671046102023000141530ustar 00000000000000#![deny(missing_docs)] //! Formatting-preserving parser and editor for Debian watch files //! //! # Example //! //! ```rust,ignore //! // For line-based formats (v1-4): //! // Note: This example requires the "linebased" feature (enabled by default) //! let wf = debian_watch::linebased::WatchFile::new(None); //! assert_eq!(wf.version(), debian_watch::DEFAULT_VERSION); //! assert_eq!("", wf.to_string()); //! //! let wf = debian_watch::linebased::WatchFile::new(Some(4)); //! assert_eq!(wf.version(), 4); //! assert_eq!("version=4\n", wf.to_string()); //! //! let wf: debian_watch::linebased::WatchFile = r#"version=4 //! opts=foo=blah https://foo.com/bar .*/v?(\d\S+)\.tar\.gz //! "#.parse().unwrap(); //! assert_eq!(wf.version(), 4); //! assert_eq!(wf.entries().collect::>().len(), 1); //! let entry = wf.entries().next().unwrap(); //! assert_eq!(entry.opts(), maplit::hashmap! { //! "foo".to_string() => "blah".to_string(), //! }); //! assert_eq!(&entry.url(), "https://foo.com/bar"); //! assert_eq!(entry.matching_pattern().as_deref(), Some(".*/v?(\\d\\S+)\\.tar\\.gz")); //! ``` #[cfg(feature = "linebased")] mod lex; #[cfg(feature = "linebased")] /// Line-based watch file format parser (versions 1-4) pub mod linebased; #[cfg(all(feature = "deb822", feature = "linebased"))] mod convert; #[cfg(feature = "deb822")] pub mod deb822; #[cfg(all(feature = "discover", any(feature = "linebased", feature = "deb822")))] pub mod discover; pub mod mangle; #[cfg(feature = "pgp")] pub mod pgp; pub mod release; pub mod search; #[cfg(feature = "deb822")] pub mod templates; /// Any watch files without a version are assumed to be /// version 1. pub const DEFAULT_VERSION: u32 = 1; #[cfg(any(feature = "linebased", feature = "deb822"))] pub mod parse; pub mod subst; mod types; /// Default user agent string used for HTTP requests pub const DEFAULT_USER_AGENT: &str = concat!("debian-watch-rs/", env!("CARGO_PKG_VERSION")); pub use release::Release; pub use types::*; /// Let's start with defining all kinds of tokens and /// composite nodes. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] #[allow(non_camel_case_types, missing_docs, clippy::upper_case_acronyms)] #[repr(u16)] pub enum SyntaxKind { KEY = 0, VALUE, EQUALS, QUOTE, COMMA, CONTINUATION, NEWLINE, WHITESPACE, // whitespaces is explicit COMMENT, // comments ERROR, // as well as errors // composite nodes ROOT, // The entire file VERSION, // "version=x\n" ENTRY, // "opts=foo=blah https://foo.com/bar .*/v?(\d\S+)\.tar\.gz\n" OPTS_LIST, // "opts=foo=blah" OPTION, // "foo=blah" OPTION_SEPARATOR, // "," (comma separator between options) URL, // "https://foo.com/bar" MATCHING_PATTERN, // ".*/v?(\d\S+)\.tar\.gz" VERSION_POLICY, // "debian" SCRIPT, // "uupdate" } /// Convert our `SyntaxKind` into the rowan `SyntaxKind`. impl From for rowan::SyntaxKind { fn from(kind: SyntaxKind) -> Self { Self(kind as u16) } } // Only export traits - specific implementations are in their modules // Users access linebased types via debian_watch::linebased::WatchFile // Users access deb822 types via debian_watch::deb822::WatchFile #[cfg(all(feature = "deb822", feature = "linebased"))] pub use crate::convert::{convert_to_v5, ConversionError}; #[cfg(all(test, feature = "linebased"))] mod tests { use crate::linebased::WatchFile; #[test] fn test_create_watchfile() { let wf = WatchFile::new(None); assert_eq!(wf.version(), super::DEFAULT_VERSION); assert_eq!("", wf.to_string()); let wf = WatchFile::new(Some(4)); assert_eq!(wf.version(), 4); assert_eq!("version=4\n", wf.to_string()); } #[test] fn test_set_version() { let mut wf = WatchFile::new(Some(4)); assert_eq!(wf.version(), 4); wf.set_version(5); assert_eq!(wf.version(), 5); assert_eq!("version=5\n", wf.to_string()); // Test setting version on a file without version let mut wf = WatchFile::new(None); assert_eq!(wf.version(), super::DEFAULT_VERSION); wf.set_version(4); assert_eq!(wf.version(), 4); assert_eq!("version=4\n", wf.to_string()); } #[test] fn test_set_version_on_parsed() { // Test that parsed WatchFiles can be mutated let mut wf: WatchFile = "version=4\n".parse().unwrap(); assert_eq!(wf.version(), 4); wf.set_version(5); assert_eq!(wf.version(), 5); assert_eq!("version=5\n", wf.to_string()); // Test setting version on a parsed file without version let mut wf: WatchFile = "".parse().unwrap(); assert_eq!(wf.version(), super::DEFAULT_VERSION); wf.set_version(4); assert_eq!(wf.version(), 4); assert_eq!("version=4\n", wf.to_string()); } } debian-watch-0.4.12/src/linebased.rs000064400000000000000000004062721046102023000153340ustar 00000000000000use crate::lex::lex; use crate::types::{ ComponentType, Compression, GitExport, GitMode, Mode, PgpMode, Pretty, SearchMode, }; use crate::SyntaxKind; use crate::SyntaxKind::*; use crate::DEFAULT_VERSION; use std::io::Read; use std::marker::PhantomData; use std::str::FromStr; #[cfg(test)] use crate::types::VersionPolicy; /// Get the linebased option key name for a WatchOption variant pub(crate) fn watch_option_to_key(option: &crate::types::WatchOption) -> &'static str { use crate::types::WatchOption; match option { WatchOption::Component(_) => "component", WatchOption::Compression(_) => "compression", WatchOption::UserAgent(_) => "user-agent", WatchOption::Pagemangle(_) => "pagemangle", WatchOption::Uversionmangle(_) => "uversionmangle", WatchOption::Dversionmangle(_) => "dversionmangle", WatchOption::Dirversionmangle(_) => "dirversionmangle", WatchOption::Oversionmangle(_) => "oversionmangle", WatchOption::Downloadurlmangle(_) => "downloadurlmangle", WatchOption::Pgpsigurlmangle(_) => "pgpsigurlmangle", WatchOption::Filenamemangle(_) => "filenamemangle", WatchOption::VersionPolicy(_) => "version-policy", WatchOption::Searchmode(_) => "searchmode", WatchOption::Mode(_) => "mode", WatchOption::Pgpmode(_) => "pgpmode", WatchOption::Gitexport(_) => "gitexport", WatchOption::Gitmode(_) => "gitmode", WatchOption::Pretty(_) => "pretty", WatchOption::Ctype(_) => "ctype", WatchOption::Repacksuffix(_) => "repacksuffix", WatchOption::Unzipopt(_) => "unzipopt", WatchOption::Script(_) => "script", WatchOption::Decompress => "decompress", WatchOption::Bare => "bare", WatchOption::Repack => "repack", } } /// Get the string value for a WatchOption variant pub(crate) fn watch_option_to_value(option: &crate::types::WatchOption) -> String { use crate::types::WatchOption; match option { WatchOption::Component(v) => v.clone(), WatchOption::Compression(v) => v.to_string(), WatchOption::UserAgent(v) => v.clone(), WatchOption::Pagemangle(v) => v.clone(), WatchOption::Uversionmangle(v) => v.clone(), WatchOption::Dversionmangle(v) => v.clone(), WatchOption::Dirversionmangle(v) => v.clone(), WatchOption::Oversionmangle(v) => v.clone(), WatchOption::Downloadurlmangle(v) => v.clone(), WatchOption::Pgpsigurlmangle(v) => v.clone(), WatchOption::Filenamemangle(v) => v.clone(), WatchOption::VersionPolicy(v) => v.to_string(), WatchOption::Searchmode(v) => v.to_string(), WatchOption::Mode(v) => v.to_string(), WatchOption::Pgpmode(v) => v.to_string(), WatchOption::Gitexport(v) => v.to_string(), WatchOption::Gitmode(v) => v.to_string(), WatchOption::Pretty(v) => v.to_string(), WatchOption::Ctype(v) => v.to_string(), WatchOption::Repacksuffix(v) => v.clone(), WatchOption::Unzipopt(v) => v.clone(), WatchOption::Script(v) => v.clone(), WatchOption::Decompress => String::new(), WatchOption::Bare => String::new(), WatchOption::Repack => String::new(), } } #[derive(Debug, Clone, PartialEq, Eq, Hash)] /// Error type for parsing line-based watch files pub struct ParseError(pub Vec); impl std::fmt::Display for ParseError { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { for err in &self.0 { writeln!(f, "{}", err)?; } Ok(()) } } impl std::error::Error for ParseError {} /// Second, implementing the `Language` trait teaches rowan to convert between /// these two SyntaxKind types, allowing for a nicer SyntaxNode API where /// "kinds" are values from our `enum SyntaxKind`, instead of plain u16 values. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Lang {} impl rowan::Language for Lang { type Kind = SyntaxKind; fn kind_from_raw(raw: rowan::SyntaxKind) -> Self::Kind { unsafe { std::mem::transmute::(raw.0) } } fn kind_to_raw(kind: Self::Kind) -> rowan::SyntaxKind { kind.into() } } /// GreenNode is an immutable tree, which is cheap to change, /// but doesn't contain offsets and parent pointers. use rowan::GreenNode; /// You can construct GreenNodes by hand, but a builder /// is helpful for top-down parsers: it maintains a stack /// of currently in-progress nodes use rowan::GreenNodeBuilder; /// Thread-safe parse result that can be stored in incremental computation systems like Salsa. /// The type parameter T represents the root AST node type (e.g., WatchFile). #[derive(Debug, Clone, PartialEq, Eq)] pub struct Parse { /// The immutable green tree that can be shared across threads green: GreenNode, /// Parse errors encountered during parsing errors: Vec, /// Phantom type to associate this parse result with a specific AST type _ty: PhantomData, } impl Parse { /// Create a new parse result pub(crate) fn new(green: GreenNode, errors: Vec) -> Self { Parse { green, errors, _ty: PhantomData, } } /// Get the green node pub fn green(&self) -> &GreenNode { &self.green } /// Get the parse errors pub fn errors(&self) -> &[String] { &self.errors } /// Check if there were any parse errors pub fn is_ok(&self) -> bool { self.errors.is_empty() } } impl Parse { /// Get the root WatchFile node pub fn tree(&self) -> WatchFile { WatchFile::cast(SyntaxNode::new_root_mut(self.green.clone())) .expect("root node should be a WatchFile") } } // Implement Send + Sync since GreenNode is thread-safe // This allows Parse to be stored in Salsa databases unsafe impl Send for Parse {} unsafe impl Sync for Parse {} // The internal parse result used during parsing struct InternalParse { green_node: GreenNode, errors: Vec, } /// Returns true if a token kind can be part of a whitespace-delimited entry /// field (URL, matching pattern, version policy, or script). /// /// Values may contain `=` (e.g. URLs with query strings like `?per_page=100`) /// or `,` and quotes (regex patterns), so we accept any non-structural token. fn is_field_token(kind: Option) -> bool { matches!(kind, Some(KEY | VALUE | EQUALS | COMMA | QUOTE)) } fn parse(text: &str) -> InternalParse { struct Parser { /// input tokens, including whitespace, /// in *reverse* order. tokens: Vec<(SyntaxKind, String)>, /// the in-progress tree. builder: GreenNodeBuilder<'static>, /// the list of syntax errors we've accumulated /// so far. errors: Vec, } impl Parser { fn parse_version(&mut self) -> Option { let mut version = None; if self.tokens.last() == Some(&(KEY, "version".to_string())) { self.builder.start_node(VERSION.into()); self.bump(); self.skip_ws(); if self.current() != Some(EQUALS) { self.builder.start_node(ERROR.into()); self.errors.push("expected `=`".to_string()); self.bump(); self.builder.finish_node(); } else { self.bump(); } if self.current() != Some(VALUE) { self.builder.start_node(ERROR.into()); self.errors .push(format!("expected value, got {:?}", self.current())); self.bump(); self.builder.finish_node(); } else if let Some((_, value)) = self.tokens.last() { let version_str = value; match version_str.parse() { Ok(v) => { version = Some(v); self.bump(); } Err(_) => { self.builder.start_node(ERROR.into()); self.errors .push(format!("invalid version: {}", version_str)); self.bump(); self.builder.finish_node(); } } } else { self.builder.start_node(ERROR.into()); self.errors.push("expected version value".to_string()); self.builder.finish_node(); } if self.current() != Some(NEWLINE) { self.builder.start_node(ERROR.into()); self.errors.push("expected newline".to_string()); self.bump(); self.builder.finish_node(); } else { self.bump(); } self.builder.finish_node(); } version } fn parse_watch_entry(&mut self) -> bool { // Skip whitespace, comments, and blank lines between entries loop { self.skip_ws(); if self.current() == Some(NEWLINE) { self.bump(); } else { break; } } if self.current().is_none() { return false; } self.builder.start_node(ENTRY.into()); self.parse_options_list(); for i in 0..4 { if self.current() == Some(NEWLINE) || self.current().is_none() { break; } if self.current() == Some(CONTINUATION) { self.bump(); self.skip_ws(); continue; } // A field has to start with a KEY or VALUE token; punctuation // like `=` or `,` on its own is not a valid URL/pattern. if !matches!(self.current(), Some(KEY | VALUE)) { self.builder.start_node(ERROR.into()); self.errors.push(format!( "expected value, got {:?} (i={})", self.current(), i )); if self.current().is_some() { self.bump(); } self.builder.finish_node(); } else { // Wrap each field in its appropriate node. // Each field gobbles all consecutive non-whitespace tokens, so // values like URLs containing '=' (e.g. "?per_page=100") or regex // patterns containing '=' or ',' are kept intact. let kind = match i { 0 => URL, 1 => MATCHING_PATTERN, 2 => VERSION_POLICY, 3 => SCRIPT, _ => unreachable!(), }; self.builder.start_node(kind.into()); while is_field_token(self.current()) { self.bump(); } self.builder.finish_node(); } self.skip_ws(); } if self.current() != Some(NEWLINE) && self.current().is_some() { self.builder.start_node(ERROR.into()); self.errors .push(format!("expected newline, not {:?}", self.current())); if self.current().is_some() { self.bump(); } self.builder.finish_node(); } else if self.current().is_some() { // Consume the newline if present (but EOF is also okay) self.bump(); } self.builder.finish_node(); true } /// Parse a single option `key[=value]` inside an `opts=...` list. /// /// `quoted` controls the trailing-token rules: in unquoted mode the /// value stops at the first whitespace, while inside quotes a single /// space before the `,` separator is tolerated. fn parse_option(&mut self, quoted: bool) -> bool { if self.current().is_none() { return false; } while self.current() == Some(CONTINUATION) { self.bump(); } if !quoted && self.current() == Some(WHITESPACE) { return false; } if quoted && self.current() == Some(QUOTE) { return false; } // In unquoted mode, anything that doesn't start a `key[=value]` // belongs to the next field (URL etc.) — don't consume it as a // bogus option. This keeps trailing-comma + line-continuation // patterns like `opts=k=v,\\\nhttps://...` parseable. if !quoted && self.current() != Some(KEY) { return false; } self.builder.start_node(OPTION.into()); if self.current() != Some(KEY) { self.builder.start_node(ERROR.into()); self.errors.push("expected key".to_string()); self.bump(); self.builder.finish_node(); } else { self.bump(); } if self.current() == Some(EQUALS) { self.bump(); // The option value may itself be made up of several lexer // tokens — for example `s/.*ref=//` lexes as VALUE EQUALS VALUE // because `=` is an opts separator. Gobble until the next // option boundary so the value is preserved verbatim. let mut consumed_value = false; loop { match self.current() { Some(KEY) | Some(VALUE) => { self.bump(); consumed_value = true; } Some(EQUALS) if consumed_value => self.bump(), Some(WHITESPACE) if quoted => { // Inside quotes, a space between value and the // next separator (e.g. `"key=v , key2=v"`) is // tolerated; the surrounding loop handles the // following comma or closing quote. break; } _ => break, } } if !consumed_value { self.builder.start_node(ERROR.into()); self.errors .push(format!("expected value, got {:?}", self.current())); if self.current().is_some() { self.bump(); } self.builder.finish_node(); } } else if self.current() == Some(COMMA) { } else { self.builder.start_node(ERROR.into()); self.errors.push("expected `=`".to_string()); if self.current().is_some() { self.bump(); } self.builder.finish_node(); } self.builder.finish_node(); true } fn parse_options_list(&mut self) { self.skip_ws(); if self.tokens.last() == Some(&(KEY, "opts".to_string())) || self.tokens.last() == Some(&(KEY, "options".to_string())) { self.builder.start_node(OPTS_LIST.into()); self.bump(); self.skip_ws(); if self.current() != Some(EQUALS) { self.builder.start_node(ERROR.into()); self.errors.push("expected `=`".to_string()); if self.current().is_some() { self.bump(); } self.builder.finish_node(); } else { self.bump(); } let quoted = if self.current() == Some(QUOTE) { self.bump(); true } else { false }; loop { if quoted { // Inside quotes, line continuations and surrounding // whitespace around commas are common; consume them // before checking for the closing quote so a trailing // `,\` followed by `"` doesn't get parsed as another // (empty) option. self.skip_ws(); if self.current() == Some(QUOTE) { self.bump(); break; } } if !self.parse_option(quoted) { break; } if quoted { // Allow whitespace/continuation between value and the // next comma in quoted opts, e.g. `"a=1 , b=2"`. self.skip_ws(); } if self.current() == Some(COMMA) { self.builder.start_node(OPTION_SEPARATOR.into()); self.bump(); self.builder.finish_node(); } else if !quoted { break; } } self.builder.finish_node(); self.skip_ws(); } } fn parse(mut self) -> InternalParse { // Make sure that the root node covers all source self.builder.start_node(ROOT.into()); // Skip any leading comments/whitespace/newlines before version while self.current() == Some(WHITESPACE) || self.current() == Some(CONTINUATION) || self.current() == Some(COMMENT) || self.current() == Some(NEWLINE) { self.bump(); } if let Some(_v) = self.parse_version() { // Version is stored in the syntax tree, no need to track separately } // TODO: use version to influence parsing loop { if !self.parse_watch_entry() { break; } } // Don't forget to eat *trailing* whitespace self.skip_ws(); // Consume any remaining tokens that were not parsed, recording an error. // This ensures the CST always covers the full input. if self.current().is_some() { self.builder.start_node(ERROR.into()); self.errors .push("unexpected tokens after last entry".to_string()); while self.current().is_some() { self.bump(); } self.builder.finish_node(); } // Close the root node. self.builder.finish_node(); // Turn the builder into a GreenNode InternalParse { green_node: self.builder.finish(), errors: self.errors, } } /// Advance one token, adding it to the current branch of the tree builder. fn bump(&mut self) { if let Some((kind, text)) = self.tokens.pop() { self.builder.token(kind.into(), text.as_str()); } } /// Peek at the first unprocessed token fn current(&self) -> Option { self.tokens.last().map(|(kind, _)| *kind) } fn skip_ws(&mut self) { while self.current() == Some(WHITESPACE) || self.current() == Some(CONTINUATION) || self.current() == Some(COMMENT) { self.bump() } } } let mut tokens = lex(text); tokens.reverse(); Parser { tokens, builder: GreenNodeBuilder::new(), errors: Vec::new(), } .parse() } /// To work with the parse results we need a view into the /// green tree - the Syntax tree. /// It is also immutable, like a GreenNode, /// but it contains parent pointers, offsets, and /// has identity semantics. type SyntaxNode = rowan::SyntaxNode; #[allow(unused)] type SyntaxToken = rowan::SyntaxToken; #[allow(unused)] type SyntaxElement = rowan::NodeOrToken; impl InternalParse { fn syntax(&self) -> SyntaxNode { SyntaxNode::new_root_mut(self.green_node.clone()) } fn root(&self) -> WatchFile { WatchFile::cast(self.syntax()).expect("root node should be a WatchFile") } } /// Calculate line and column (both 0-indexed) for the given offset in the tree. /// Column is measured in bytes from the start of the line. fn line_col_at_offset(node: &SyntaxNode, offset: rowan::TextSize) -> (usize, usize) { let root = node.ancestors().last().unwrap_or_else(|| node.clone()); let mut line = 0; let mut last_newline_offset = rowan::TextSize::from(0); for element in root.preorder_with_tokens() { if let rowan::WalkEvent::Enter(rowan::NodeOrToken::Token(token)) = element { if token.text_range().start() >= offset { break; } // Count newlines and track position of last one for (idx, _) in token.text().match_indices('\n') { line += 1; last_newline_offset = token.text_range().start() + rowan::TextSize::from((idx + 1) as u32); } } } let column: usize = (offset - last_newline_offset).into(); (line, column) } macro_rules! ast_node { ($ast:ident, $kind:ident) => { #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[repr(transparent)] /// A node in the syntax tree for $ast pub struct $ast(SyntaxNode); impl $ast { #[allow(unused)] fn cast(node: SyntaxNode) -> Option { if node.kind() == $kind { Some(Self(node)) } else { None } } /// Byte range covered by this node in the source buffer. /// /// Useful for editors that need to map a logical concept /// (an option, a URL, a matching pattern) back to the /// exact span it occupies — e.g. to filter LSP diagnostics /// by which part of a watch entry was edited. pub fn text_range(&self) -> rowan::TextRange { self.0.text_range() } /// Get the line number (0-indexed) where this node starts. pub fn line(&self) -> usize { line_col_at_offset(&self.0, self.0.text_range().start()).0 } /// Get the column number (0-indexed, in bytes) where this node starts. pub fn column(&self) -> usize { line_col_at_offset(&self.0, self.0.text_range().start()).1 } /// Get both line and column (0-indexed) where this node starts. /// Returns (line, column) where column is measured in bytes from the start of the line. pub fn line_col(&self) -> (usize, usize) { line_col_at_offset(&self.0, self.0.text_range().start()) } } impl std::fmt::Display for $ast { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { write!(f, "{}", self.0.text()) } } }; } ast_node!(WatchFile, ROOT); ast_node!(Version, VERSION); ast_node!(Entry, ENTRY); ast_node!(_Option, OPTION); ast_node!(Url, URL); ast_node!(MatchingPattern, MATCHING_PATTERN); ast_node!(VersionPolicyNode, VERSION_POLICY); ast_node!(ScriptNode, SCRIPT); // OptionList is manually defined to have a custom Debug impl #[derive(Clone, PartialEq, Eq, Hash)] #[repr(transparent)] /// A node in the syntax tree for OptionList pub struct OptionList(SyntaxNode); impl OptionList { #[allow(unused)] fn cast(node: SyntaxNode) -> Option { if node.kind() == OPTS_LIST { Some(Self(node)) } else { None } } /// Byte range covered by this option list in the source buffer. pub fn text_range(&self) -> rowan::TextRange { self.0.text_range() } /// Get the line number (0-indexed) where this node starts. pub fn line(&self) -> usize { line_col_at_offset(&self.0, self.0.text_range().start()).0 } /// Get the column number (0-indexed, in bytes) where this node starts. pub fn column(&self) -> usize { line_col_at_offset(&self.0, self.0.text_range().start()).1 } /// Get both line and column (0-indexed) where this node starts. /// Returns (line, column) where column is measured in bytes from the start of the line. pub fn line_col(&self) -> (usize, usize) { line_col_at_offset(&self.0, self.0.text_range().start()) } } impl std::fmt::Display for OptionList { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { write!(f, "{}", self.0.text()) } } impl WatchFile { /// Access the underlying syntax node pub fn syntax(&self) -> &SyntaxNode { &self.0 } /// Capture an independent snapshot of this watch file. /// See [`crate::parse::ParsedWatchFile::snapshot`] for semantics. pub fn snapshot(&self) -> Self { WatchFile(SyntaxNode::new_root_mut(self.0.green().into_owned())) } /// Returns true iff the syntax trees of `self` and `other` are /// value-equal. An O(1) pointer-identity fast path makes this free for /// trees that still share state with a recent `snapshot()`. pub fn tree_eq(&self, other: &Self) -> bool { let a = self.0.green(); let b = other.0.green(); let a_ref: &rowan::GreenNodeData = &a; let b_ref: &rowan::GreenNodeData = &b; std::ptr::eq(a_ref as *const _, b_ref as *const _) || a_ref == b_ref } /// Create a new watch file with specified version pub fn new(version: Option) -> WatchFile { let mut builder = GreenNodeBuilder::new(); builder.start_node(ROOT.into()); if let Some(version) = version { builder.start_node(VERSION.into()); builder.token(KEY.into(), "version"); builder.token(EQUALS.into(), "="); builder.token(VALUE.into(), version.to_string().as_str()); builder.token(NEWLINE.into(), "\n"); builder.finish_node(); } builder.finish_node(); WatchFile(SyntaxNode::new_root_mut(builder.finish())) } /// Returns the version AST node of the watch file. pub fn version_node(&self) -> Option { self.0.children().find_map(Version::cast) } /// Returns the version of the watch file. pub fn version(&self) -> u32 { self.version_node() .map(|it| it.version()) .unwrap_or(DEFAULT_VERSION) } /// Returns an iterator over all entries in the watch file. pub fn entries(&self) -> impl Iterator + '_ { self.0.children().filter_map(Entry::cast) } /// Set the version of the watch file. pub fn set_version(&mut self, new_version: u32) { // Build the new version node let mut builder = GreenNodeBuilder::new(); builder.start_node(VERSION.into()); builder.token(KEY.into(), "version"); builder.token(EQUALS.into(), "="); builder.token(VALUE.into(), new_version.to_string().as_str()); builder.token(NEWLINE.into(), "\n"); builder.finish_node(); let new_version_green = builder.finish(); // Create a syntax node (splice_children will detach and reattach it) let new_version_node = SyntaxNode::new_root_mut(new_version_green); // Find existing version node if any let version_pos = self.0.children().position(|child| child.kind() == VERSION); if let Some(pos) = version_pos { // Replace existing version node self.0 .splice_children(pos..pos + 1, vec![new_version_node.into()]); } else { // Insert version node at the beginning self.0.splice_children(0..0, vec![new_version_node.into()]); } } /// Discover releases for all entries in the watch file (async version) /// /// Fetches URLs and searches for version matches for all entries. /// Requires the 'discover' feature. /// /// # Examples /// /// ```ignore /// # use debian_watch::WatchFile; /// # async fn example() { /// let wf: WatchFile = r#"version=4 /// https://example.com/releases/ .*/v?(\d+\.\d+)\.tar\.gz /// "#.parse().unwrap(); /// let all_releases = wf.uscan(|| "mypackage".to_string()).await.unwrap(); /// for (entry_idx, releases) in all_releases.iter().enumerate() { /// println!("Entry {}: {} releases found", entry_idx, releases.len()); /// } /// # } /// ``` #[cfg(feature = "discover")] pub async fn uscan( &self, package: impl Fn() -> String + Send + Sync, ) -> Result>, Box> { let mut all_releases = Vec::new(); for entry in self.entries() { let parsed_entry = crate::parse::ParsedEntry::LineBased(entry); let releases = parsed_entry.discover(|| package()).await?; all_releases.push(releases); } Ok(all_releases) } /// Discover releases for all entries in the watch file (blocking version) /// /// Fetches URLs and searches for version matches for all entries. /// Requires both 'discover' and 'blocking' features. /// /// # Examples /// /// ```ignore /// # use debian_watch::WatchFile; /// let wf: WatchFile = r#"version=4 /// https://example.com/releases/ .*/v?(\d+\.\d+)\.tar\.gz /// "#.parse().unwrap(); /// let all_releases = wf.uscan_blocking(|| "mypackage".to_string()).unwrap(); /// for (entry_idx, releases) in all_releases.iter().enumerate() { /// println!("Entry {}: {} releases found", entry_idx, releases.len()); /// } /// ``` #[cfg(all(feature = "discover", feature = "blocking"))] pub fn uscan_blocking( &self, package: impl Fn() -> String, ) -> Result>, Box> { let mut all_releases = Vec::new(); for entry in self.entries() { let parsed_entry = crate::parse::ParsedEntry::LineBased(entry); let releases = parsed_entry.discover_blocking(|| package())?; all_releases.push(releases); } Ok(all_releases) } /// Add an entry to the watch file. /// /// Appends a new entry to the end of the watch file. /// /// # Examples /// /// ``` /// use debian_watch::linebased::{WatchFile, EntryBuilder}; /// /// let mut wf = WatchFile::new(Some(4)); /// /// // Add an entry using EntryBuilder /// let entry = EntryBuilder::new("https://github.com/example/tags") /// .matching_pattern(".*/v?(\\d\\S+)\\.tar\\.gz") /// .build(); /// wf.add_entry(entry); /// /// // Or use the builder pattern directly /// wf.add_entry( /// EntryBuilder::new("https://example.com/releases") /// .matching_pattern(".*/(\\d+\\.\\d+)\\.tar\\.gz") /// .opt("compression", "xz") /// .version_policy("debian") /// .build() /// ); /// ``` pub fn add_entry(&mut self, entry: Entry) -> Entry { // Find the position to insert (after the last entry or after version) let insert_pos = self.0.children_with_tokens().count(); // Detach the entry node from its current parent and get its green node let entry_green = entry.0.green().into_owned(); let new_entry_node = SyntaxNode::new_root_mut(entry_green); // Insert the entry at the end self.0 .splice_children(insert_pos..insert_pos, vec![new_entry_node.into()]); // Get the entry we just inserted by indexing directly to the position Entry::cast( self.0 .children() .nth(insert_pos) .expect("Entry was just inserted"), ) .expect("Inserted node should be an Entry") } /// Read a watch file from a Read object. pub fn from_reader(reader: R) -> Result { let mut buf_reader = std::io::BufReader::new(reader); let mut content = String::new(); buf_reader .read_to_string(&mut content) .map_err(|e| ParseError(vec![e.to_string()]))?; content.parse() } /// Read a watch file from a Read object, allowing syntax errors. pub fn from_reader_relaxed(mut r: R) -> Result { let mut content = String::new(); r.read_to_string(&mut content)?; let parsed = parse(&content); Ok(parsed.root()) } /// Parse a debian watch file from a string, allowing syntax errors. pub fn from_str_relaxed(s: &str) -> Self { let parsed = parse(s); parsed.root() } } impl FromStr for WatchFile { type Err = ParseError; fn from_str(s: &str) -> Result { let parsed = parse(s); if parsed.errors.is_empty() { Ok(parsed.root()) } else { Err(ParseError(parsed.errors)) } } } /// Parse a watch file and return a thread-safe parse result. /// This can be stored in incremental computation systems like Salsa. pub fn parse_watch_file(text: &str) -> Parse { let parsed = parse(text); Parse::new(parsed.green_node, parsed.errors) } impl Version { /// Returns the version of the watch file. pub fn version(&self) -> u32 { self.0 .children_with_tokens() .find_map(|it| match it { SyntaxElement::Token(token) => { if token.kind() == VALUE { token.text().parse().ok() } else { None } } _ => None, }) .unwrap_or(DEFAULT_VERSION) } } /// Builder for creating new watchfile entries. /// /// Provides a fluent API for constructing entries with various components. /// /// # Examples /// /// ``` /// use debian_watch::linebased::EntryBuilder; /// /// // Minimal entry with just URL and pattern /// let entry = EntryBuilder::new("https://github.com/example/tags") /// .matching_pattern(".*/v?(\\d\\S+)\\.tar\\.gz") /// .build(); /// /// // Entry with options /// let entry = EntryBuilder::new("https://github.com/example/tags") /// .matching_pattern(".*/v?(\\d\\S+)\\.tar\\.gz") /// .opt("compression", "xz") /// .flag("repack") /// .version_policy("debian") /// .script("uupdate") /// .build(); /// ``` #[derive(Debug, Clone, Default)] pub struct EntryBuilder { url: Option, matching_pattern: Option, version_policy: Option, script: Option, opts: std::collections::HashMap, } impl EntryBuilder { /// Create a new entry builder with the specified URL. pub fn new(url: impl Into) -> Self { EntryBuilder { url: Some(url.into()), matching_pattern: None, version_policy: None, script: None, opts: std::collections::HashMap::new(), } } /// Set the matching pattern for the entry. pub fn matching_pattern(mut self, pattern: impl Into) -> Self { self.matching_pattern = Some(pattern.into()); self } /// Set the version policy for the entry. pub fn version_policy(mut self, policy: impl Into) -> Self { self.version_policy = Some(policy.into()); self } /// Set the script for the entry. pub fn script(mut self, script: impl Into) -> Self { self.script = Some(script.into()); self } /// Add an option to the entry. pub fn opt(mut self, key: impl Into, value: impl Into) -> Self { self.opts.insert(key.into(), value.into()); self } /// Add a boolean flag option to the entry. /// /// Boolean options like "repack", "bare", "decompress" don't have values. pub fn flag(mut self, key: impl Into) -> Self { self.opts.insert(key.into(), String::new()); self } /// Build the entry. /// /// # Panics /// /// Panics if no URL was provided. pub fn build(self) -> Entry { let url = self.url.expect("URL is required for entry"); let mut builder = GreenNodeBuilder::new(); builder.start_node(ENTRY.into()); // Add options list if provided if !self.opts.is_empty() { builder.start_node(OPTS_LIST.into()); builder.token(KEY.into(), "opts"); builder.token(EQUALS.into(), "="); let mut first = true; for (key, value) in self.opts.iter() { if !first { builder.token(COMMA.into(), ","); } first = false; builder.start_node(OPTION.into()); builder.token(KEY.into(), key); if !value.is_empty() { builder.token(EQUALS.into(), "="); builder.token(VALUE.into(), value); } builder.finish_node(); } builder.finish_node(); builder.token(WHITESPACE.into(), " "); } // Add URL (required) builder.start_node(URL.into()); builder.token(VALUE.into(), &url); builder.finish_node(); // Add matching pattern if provided if let Some(pattern) = self.matching_pattern { builder.token(WHITESPACE.into(), " "); builder.start_node(MATCHING_PATTERN.into()); builder.token(VALUE.into(), &pattern); builder.finish_node(); } // Add version policy if provided if let Some(policy) = self.version_policy { builder.token(WHITESPACE.into(), " "); builder.start_node(VERSION_POLICY.into()); builder.token(VALUE.into(), &policy); builder.finish_node(); } // Add script if provided if let Some(script_val) = self.script { builder.token(WHITESPACE.into(), " "); builder.start_node(SCRIPT.into()); builder.token(VALUE.into(), &script_val); builder.finish_node(); } builder.token(NEWLINE.into(), "\n"); builder.finish_node(); Entry(SyntaxNode::new_root_mut(builder.finish())) } } impl Entry { /// Access the underlying syntax node. pub fn syntax(&self) -> &SyntaxNode { &self.0 } /// Create a new entry builder. /// /// This is a convenience method that returns an `EntryBuilder`. /// /// # Examples /// /// ``` /// use debian_watch::linebased::Entry; /// /// let entry = Entry::builder("https://github.com/example/tags") /// .matching_pattern(".*/v?(\\d\\S+)\\.tar\\.gz") /// .build(); /// ``` pub fn builder(url: impl Into) -> EntryBuilder { EntryBuilder::new(url) } /// List of options pub fn option_list(&self) -> Option { self.0.children().find_map(OptionList::cast) } /// Get the value of an option pub fn get_option(&self, key: &str) -> Option { self.option_list().and_then(|ol| ol.get_option(key)) } /// Check if an option is set pub fn has_option(&self, key: &str) -> bool { self.option_list().is_some_and(|ol| ol.has_option(key)) } /// The name of the secondary source tarball pub fn component(&self) -> Option { self.get_option("component") } /// Component type pub fn ctype(&self) -> Result, ()> { self.try_ctype().map_err(|_| ()) } /// Component type with detailed error information pub fn try_ctype(&self) -> Result, crate::types::ParseError> { self.get_option("ctype").map(|s| s.parse()).transpose() } /// Compression method pub fn compression(&self) -> Result, ()> { self.try_compression().map_err(|_| ()) } /// Compression method with detailed error information pub fn try_compression(&self) -> Result, crate::types::ParseError> { self.get_option("compression") .map(|s| s.parse()) .transpose() } /// Repack the tarball pub fn repack(&self) -> bool { self.has_option("repack") } /// Repack suffix pub fn repacksuffix(&self) -> Option { self.get_option("repacksuffix") } /// Retrieve the mode of the watch file entry. pub fn mode(&self) -> Result { self.try_mode().map_err(|_| ()) } /// Retrieve the mode of the watch file entry with detailed error information. pub fn try_mode(&self) -> Result { Ok(self .get_option("mode") .map(|s| s.parse()) .transpose()? .unwrap_or_default()) } /// Return the git pretty mode pub fn pretty(&self) -> Result { self.try_pretty().map_err(|_| ()) } /// Return the git pretty mode with detailed error information pub fn try_pretty(&self) -> Result { Ok(self .get_option("pretty") .map(|s| s.parse()) .transpose()? .unwrap_or_default()) } /// Set the date string used by the pretty option to an arbitrary format as an optional /// opts argument when the matching-pattern is HEAD or heads/branch for git mode. pub fn date(&self) -> String { self.get_option("date").unwrap_or_else(|| "%Y%m%d".into()) } /// Return the git export mode pub fn gitexport(&self) -> Result { self.try_gitexport().map_err(|_| ()) } /// Return the git export mode with detailed error information pub fn try_gitexport(&self) -> Result { Ok(self .get_option("gitexport") .map(|s| s.parse()) .transpose()? .unwrap_or_default()) } /// Return the git mode pub fn gitmode(&self) -> Result { self.try_gitmode().map_err(|_| ()) } /// Return the git mode with detailed error information pub fn try_gitmode(&self) -> Result { Ok(self .get_option("gitmode") .map(|s| s.parse()) .transpose()? .unwrap_or_default()) } /// Return the pgp mode pub fn pgpmode(&self) -> Result { self.try_pgpmode().map_err(|_| ()) } /// Return the pgp mode with detailed error information pub fn try_pgpmode(&self) -> Result { Ok(self .get_option("pgpmode") .map(|s| s.parse()) .transpose()? .unwrap_or_default()) } /// Return the search mode pub fn searchmode(&self) -> Result { self.try_searchmode().map_err(|_| ()) } /// Return the search mode with detailed error information pub fn try_searchmode(&self) -> Result { Ok(self .get_option("searchmode") .map(|s| s.parse()) .transpose()? .unwrap_or_default()) } /// Return the decompression mode pub fn decompress(&self) -> bool { self.has_option("decompress") } /// Whether to disable all site specific special case code such as URL director uses and page /// content alterations. pub fn bare(&self) -> bool { self.has_option("bare") } /// Set the user-agent string used to contact the HTTP(S) server as user-agent-string. (persistent) pub fn user_agent(&self) -> Option { self.get_option("user-agent") } /// Use PASV mode for the FTP connection. pub fn passive(&self) -> Option { if self.has_option("passive") || self.has_option("pasv") { Some(true) } else if self.has_option("active") || self.has_option("nopasv") { Some(false) } else { None } } /// Add the extra options to use with the unzip command, such as -a, -aa, and -b, when executed /// by mk-origtargz. pub fn unzipoptions(&self) -> Option { self.get_option("unzipopt") } /// Normalize the downloaded web page string. pub fn dversionmangle(&self) -> Option { self.get_option("dversionmangle") .or_else(|| self.get_option("versionmangle")) } /// Normalize the directory path string matching the regex in a set of parentheses of /// http://URL as the sortable version index string. This is used /// as the directory path sorting index only. pub fn dirversionmangle(&self) -> Option { self.get_option("dirversionmangle") } /// Normalize the downloaded web page string. pub fn pagemangle(&self) -> Option { self.get_option("pagemangle") } /// Normalize the candidate upstream version strings extracted from hrefs in the /// source of the web page. This is used as the version sorting index when selecting the /// latest upstream version. pub fn uversionmangle(&self) -> Option { self.get_option("uversionmangle") .or_else(|| self.get_option("versionmangle")) } /// Syntactic shorthand for uversionmangle=rules, dversionmangle=rules pub fn versionmangle(&self) -> Option { self.get_option("versionmangle") } /// Convert the selected upstream tarball href string from the percent-encoded hexadecimal /// string to the decoded normal URL string for obfuscated /// web sites. Only percent-encoding is available and it is decoded with /// s/%([A-Fa-f\d]{2})/chr hex $1/eg. pub fn hrefdecode(&self) -> bool { self.get_option("hrefdecode").is_some() } /// Convert the selected upstream tarball href string into the accessible URL for obfuscated /// web sites. This is run after hrefdecode. pub fn downloadurlmangle(&self) -> Option { self.get_option("downloadurlmangle") } /// Generate the upstream tarball filename from the selected href string if matching-pattern /// can extract the latest upstream version from the selected href string. /// Otherwise, generate the upstream tarball filename from its full URL string and set the /// missing from the generated upstream tarball filename. /// /// Without this option, the default upstream tarball filename is generated by taking the last /// component of the URL and removing everything after any '?' or '#'. pub fn filenamemangle(&self) -> Option { self.get_option("filenamemangle") } /// Generate the candidate upstream signature file URL string from the upstream tarball URL. pub fn pgpsigurlmangle(&self) -> Option { self.get_option("pgpsigurlmangle") } /// Generate the version string of the source tarball _.orig.tar.gz /// from . This should be used to add a suffix such as +dfsg to a MUT package. pub fn oversionmangle(&self) -> Option { self.get_option("oversionmangle") } /// Apply uversionmangle to a version string /// /// # Examples /// /// ``` /// # use debian_watch::linebased::WatchFile; /// let wf: WatchFile = r#"version=4 /// opts=uversionmangle=s/\+ds// https://example.com/ .* /// "#.parse().unwrap(); /// let entry = wf.entries().next().unwrap(); /// assert_eq!(entry.apply_uversionmangle("1.0+ds").unwrap(), "1.0"); /// ``` pub fn apply_uversionmangle( &self, version: &str, ) -> Result { if let Some(vm) = self.uversionmangle() { crate::mangle::apply_mangle(&vm, version) } else { Ok(version.to_string()) } } /// Apply dversionmangle to a version string /// /// # Examples /// /// ``` /// # use debian_watch::linebased::WatchFile; /// let wf: WatchFile = r#"version=4 /// opts=dversionmangle=s/\+dfsg$// https://example.com/ .* /// "#.parse().unwrap(); /// let entry = wf.entries().next().unwrap(); /// assert_eq!(entry.apply_dversionmangle("1.0+dfsg").unwrap(), "1.0"); /// ``` pub fn apply_dversionmangle( &self, version: &str, ) -> Result { if let Some(vm) = self.dversionmangle() { crate::mangle::apply_mangle(&vm, version) } else { Ok(version.to_string()) } } /// Apply oversionmangle to a version string /// /// # Examples /// /// ``` /// # use debian_watch::linebased::WatchFile; /// let wf: WatchFile = r#"version=4 /// opts=oversionmangle=s/$/-1/ https://example.com/ .* /// "#.parse().unwrap(); /// let entry = wf.entries().next().unwrap(); /// assert_eq!(entry.apply_oversionmangle("1.0").unwrap(), "1.0-1"); /// ``` pub fn apply_oversionmangle( &self, version: &str, ) -> Result { if let Some(vm) = self.oversionmangle() { crate::mangle::apply_mangle(&vm, version) } else { Ok(version.to_string()) } } /// Apply dirversionmangle to a directory path string /// /// # Examples /// /// ``` /// # use debian_watch::linebased::WatchFile; /// let wf: WatchFile = r#"version=4 /// opts=dirversionmangle=s/v(\d)/$1/ https://example.com/ .* /// "#.parse().unwrap(); /// let entry = wf.entries().next().unwrap(); /// assert_eq!(entry.apply_dirversionmangle("v1.0").unwrap(), "1.0"); /// ``` pub fn apply_dirversionmangle( &self, version: &str, ) -> Result { if let Some(vm) = self.dirversionmangle() { crate::mangle::apply_mangle(&vm, version) } else { Ok(version.to_string()) } } /// Apply filenamemangle to a URL or filename string /// /// # Examples /// /// ``` /// # use debian_watch::linebased::WatchFile; /// let wf: WatchFile = r#"version=4 /// opts=filenamemangle=s/.+\/v?(\d\S+)\.tar\.gz/mypackage-$1.tar.gz/ https://example.com/ .* /// "#.parse().unwrap(); /// let entry = wf.entries().next().unwrap(); /// assert_eq!( /// entry.apply_filenamemangle("https://example.com/v1.0.tar.gz").unwrap(), /// "mypackage-1.0.tar.gz" /// ); /// ``` pub fn apply_filenamemangle(&self, url: &str) -> Result { if let Some(vm) = self.filenamemangle() { crate::mangle::apply_mangle(&vm, url) } else { Ok(url.to_string()) } } /// Apply pagemangle to page content bytes /// /// # Examples /// /// ``` /// # use debian_watch::linebased::WatchFile; /// let wf: WatchFile = r#"version=4 /// opts=pagemangle=s/&/&/g https://example.com/ .* /// "#.parse().unwrap(); /// let entry = wf.entries().next().unwrap(); /// assert_eq!( /// entry.apply_pagemangle(b"foo & bar").unwrap(), /// b"foo & bar" /// ); /// ``` pub fn apply_pagemangle(&self, page: &[u8]) -> Result, crate::mangle::MangleError> { if let Some(vm) = self.pagemangle() { let page_str = String::from_utf8_lossy(page); let mangled = crate::mangle::apply_mangle(&vm, &page_str)?; Ok(mangled.into_bytes()) } else { Ok(page.to_vec()) } } /// Apply downloadurlmangle to a URL string /// /// # Examples /// /// ``` /// # use debian_watch::linebased::WatchFile; /// let wf: WatchFile = r#"version=4 /// opts=downloadurlmangle=s|/archive/|/download/| https://example.com/ .* /// "#.parse().unwrap(); /// let entry = wf.entries().next().unwrap(); /// assert_eq!( /// entry.apply_downloadurlmangle("https://example.com/archive/file.tar.gz").unwrap(), /// "https://example.com/download/file.tar.gz" /// ); /// ``` pub fn apply_downloadurlmangle(&self, url: &str) -> Result { if let Some(vm) = self.downloadurlmangle() { crate::mangle::apply_mangle(&vm, url) } else { Ok(url.to_string()) } } /// Returns options set pub fn opts(&self) -> std::collections::HashMap { let mut options = std::collections::HashMap::new(); if let Some(ol) = self.option_list() { for opt in ol.options() { let key = opt.key(); let value = opt.value(); if let (Some(key), Some(value)) = (key, value) { options.insert(key.to_string(), value.to_string()); } } } options } fn items(&self) -> impl Iterator + '_ { self.0.children_with_tokens().filter_map(|it| match it { SyntaxElement::Token(token) => { if token.kind() == VALUE || token.kind() == KEY { Some(token.text().to_string()) } else { None } } SyntaxElement::Node(node) => { // Extract values from entry field nodes match node.kind() { URL => Url::cast(node).map(|n| n.url()), MATCHING_PATTERN => MatchingPattern::cast(node).map(|n| n.pattern()), VERSION_POLICY => VersionPolicyNode::cast(node).map(|n| n.policy()), SCRIPT => ScriptNode::cast(node).map(|n| n.script()), _ => None, } } }) } /// Returns the URL AST node of the entry. pub fn url_node(&self) -> Option { self.0.children().find_map(Url::cast) } /// Returns the URL of the entry. pub fn url(&self) -> String { self.url_node() .map(|it| it.url()) .or_else(|| self.items().next()) .unwrap_or_default() } /// Returns the matching pattern AST node of the entry. pub fn matching_pattern_node(&self) -> Option { self.0.children().find_map(MatchingPattern::cast) } /// Returns the matching pattern of the entry. pub fn matching_pattern(&self) -> Option { self.matching_pattern_node() .map(|it| it.pattern()) .or_else(|| { // Fallback for entries without MATCHING_PATTERN node self.items().nth(1) }) } /// Returns the version policy AST node of the entry. pub fn version_node(&self) -> Option { self.0.children().find_map(VersionPolicyNode::cast) } /// Returns the version policy pub fn version(&self) -> Result, String> { self.version_node() .map(|it| it.policy().parse()) .transpose() .map_err(|e: crate::types::ParseError| e.to_string()) .or_else(|_e| { // Fallback for entries without VERSION_POLICY node self.items() .nth(2) .map(|it| it.parse()) .transpose() .map_err(|e: crate::types::ParseError| e.to_string()) }) } /// Returns the script AST node of the entry. pub fn script_node(&self) -> Option { self.0.children().find_map(ScriptNode::cast) } /// Returns the script of the entry. pub fn script(&self) -> Option { self.script_node().map(|it| it.script()).or_else(|| { // Fallback for entries without SCRIPT node self.items().nth(3) }) } /// Replace all substitutions and return the resulting URL. pub fn format_url( &self, package: impl FnOnce() -> String, component: impl FnOnce() -> String, ) -> url::Url { crate::subst::subst(self.url().as_str(), package, component) .parse() .unwrap() } /// Set the URL of the entry. pub fn set_url(&mut self, new_url: &str) { // Build the new URL node let mut builder = GreenNodeBuilder::new(); builder.start_node(URL.into()); builder.token(VALUE.into(), new_url); builder.finish_node(); let new_url_green = builder.finish(); // Create a syntax node (splice_children will detach and reattach it) let new_url_node = SyntaxNode::new_root_mut(new_url_green); // Find existing URL node position (need to use children_with_tokens for correct indexing) let url_pos = self .0 .children_with_tokens() .position(|child| matches!(child, SyntaxElement::Node(node) if node.kind() == URL)); if let Some(pos) = url_pos { // Replace existing URL node self.0 .splice_children(pos..pos + 1, vec![new_url_node.into()]); } } /// Set the matching pattern of the entry. /// /// TODO: This currently only replaces an existing matching pattern. /// If the entry doesn't have a matching pattern, this method does nothing. /// Future implementation should insert the node at the correct position. pub fn set_matching_pattern(&mut self, new_pattern: &str) { // Build the new MATCHING_PATTERN node let mut builder = GreenNodeBuilder::new(); builder.start_node(MATCHING_PATTERN.into()); builder.token(VALUE.into(), new_pattern); builder.finish_node(); let new_pattern_green = builder.finish(); // Create a syntax node (splice_children will detach and reattach it) let new_pattern_node = SyntaxNode::new_root_mut(new_pattern_green); // Find existing MATCHING_PATTERN node position let pattern_pos = self.0.children_with_tokens().position( |child| matches!(child, SyntaxElement::Node(node) if node.kind() == MATCHING_PATTERN), ); if let Some(pos) = pattern_pos { // Replace existing MATCHING_PATTERN node self.0 .splice_children(pos..pos + 1, vec![new_pattern_node.into()]); } // TODO: else insert new node after URL } /// Set the version policy of the entry. /// /// TODO: This currently only replaces an existing version policy. /// If the entry doesn't have a version policy, this method does nothing. /// Future implementation should insert the node at the correct position. pub fn set_version_policy(&mut self, new_policy: &str) { // Build the new VERSION_POLICY node let mut builder = GreenNodeBuilder::new(); builder.start_node(VERSION_POLICY.into()); // Version policy can be KEY (e.g., "debian") or VALUE builder.token(VALUE.into(), new_policy); builder.finish_node(); let new_policy_green = builder.finish(); // Create a syntax node (splice_children will detach and reattach it) let new_policy_node = SyntaxNode::new_root_mut(new_policy_green); // Find existing VERSION_POLICY node position let policy_pos = self.0.children_with_tokens().position( |child| matches!(child, SyntaxElement::Node(node) if node.kind() == VERSION_POLICY), ); if let Some(pos) = policy_pos { // Replace existing VERSION_POLICY node self.0 .splice_children(pos..pos + 1, vec![new_policy_node.into()]); } // TODO: else insert new node after MATCHING_PATTERN (or URL if no pattern) } /// Set the script of the entry. /// /// TODO: This currently only replaces an existing script. /// If the entry doesn't have a script, this method does nothing. /// Future implementation should insert the node at the correct position. pub fn set_script(&mut self, new_script: &str) { // Build the new SCRIPT node let mut builder = GreenNodeBuilder::new(); builder.start_node(SCRIPT.into()); // Script can be KEY (e.g., "uupdate") or VALUE builder.token(VALUE.into(), new_script); builder.finish_node(); let new_script_green = builder.finish(); // Create a syntax node (splice_children will detach and reattach it) let new_script_node = SyntaxNode::new_root_mut(new_script_green); // Find existing SCRIPT node position let script_pos = self .0 .children_with_tokens() .position(|child| matches!(child, SyntaxElement::Node(node) if node.kind() == SCRIPT)); if let Some(pos) = script_pos { // Replace existing SCRIPT node self.0 .splice_children(pos..pos + 1, vec![new_script_node.into()]); } // TODO: else insert new node after VERSION_POLICY (or MATCHING_PATTERN/URL if no policy) } /// Set or update an option value using a WatchOption enum. /// /// If the option already exists, it will be updated with the new value. /// If the option doesn't exist, it will be added to the options list. /// If there's no options list, one will be created. pub fn set_option(&mut self, option: crate::types::WatchOption) { let key = watch_option_to_key(&option); let value = watch_option_to_value(&option); self.set_opt(key, &value); } /// Set or update an option value using string key and value (for backward compatibility). /// /// If the option already exists, it will be updated with the new value. /// If the option doesn't exist, it will be added to the options list. /// If there's no options list, one will be created. pub fn set_opt(&mut self, key: &str, value: &str) { // Find the OPTS_LIST position in Entry let opts_pos = self.0.children_with_tokens().position( |child| matches!(child, SyntaxElement::Node(node) if node.kind() == OPTS_LIST), ); if let Some(_opts_idx) = opts_pos { if let Some(mut ol) = self.option_list() { // Find if the option already exists if let Some(mut opt) = ol.find_option(key) { // Update the existing option's value opt.set_value(value); // Mutations should propagate automatically - no need to replace } else { // Add new option ol.add_option(key, value); // Mutations should propagate automatically - no need to replace } } } else { // Create a new options list let mut builder = GreenNodeBuilder::new(); builder.start_node(OPTS_LIST.into()); builder.token(KEY.into(), "opts"); builder.token(EQUALS.into(), "="); builder.start_node(OPTION.into()); builder.token(KEY.into(), key); builder.token(EQUALS.into(), "="); builder.token(VALUE.into(), value); builder.finish_node(); builder.finish_node(); let new_opts_green = builder.finish(); let new_opts_node = SyntaxNode::new_root_mut(new_opts_green); // Find position to insert (before URL if it exists, otherwise at start) let url_pos = self .0 .children_with_tokens() .position(|child| matches!(child, SyntaxElement::Node(node) if node.kind() == URL)); if let Some(url_idx) = url_pos { // Insert options list and a space before the URL // Build a parent node containing both space and whitespace to extract from let mut combined_builder = GreenNodeBuilder::new(); combined_builder.start_node(ROOT.into()); // Temporary parent combined_builder.token(WHITESPACE.into(), " "); combined_builder.finish_node(); let temp_green = combined_builder.finish(); let temp_root = SyntaxNode::new_root_mut(temp_green); let space_element = temp_root.children_with_tokens().next().unwrap(); self.0 .splice_children(url_idx..url_idx, vec![new_opts_node.into(), space_element]); } else { self.0.splice_children(0..0, vec![new_opts_node.into()]); } } } /// Delete an option using a WatchOption enum. /// /// Removes the option from the options list. /// If the option doesn't exist, this method does nothing. /// If deleting the option results in an empty options list, the entire /// opts= declaration is removed. pub fn del_opt(&mut self, option: crate::types::WatchOption) { let key = watch_option_to_key(&option); if let Some(mut ol) = self.option_list() { let option_count = ol.0.children().filter(|n| n.kind() == OPTION).count(); if option_count == 1 && ol.has_option(key) { // This is the last option, remove the entire OPTS_LIST from Entry let opts_pos = self.0.children().position(|node| node.kind() == OPTS_LIST); if let Some(opts_idx) = opts_pos { // Remove the OPTS_LIST self.0.splice_children(opts_idx..opts_idx + 1, vec![]); // Remove any leading whitespace/continuation that was after the OPTS_LIST while self.0.children_with_tokens().next().is_some_and(|e| { matches!( e, SyntaxElement::Token(t) if t.kind() == WHITESPACE || t.kind() == CONTINUATION ) }) { self.0.splice_children(0..1, vec![]); } } } else { // Defer to OptionList to remove the option ol.remove_option(key); } } } /// Delete an option using a string key (for backward compatibility). /// /// Removes the option with the specified key from the options list. /// If the option doesn't exist, this method does nothing. /// If deleting the option results in an empty options list, the entire /// opts= declaration is removed. pub fn del_opt_str(&mut self, key: &str) { if let Some(mut ol) = self.option_list() { let option_count = ol.0.children().filter(|n| n.kind() == OPTION).count(); if option_count == 1 && ol.has_option(key) { // This is the last option, remove the entire OPTS_LIST from Entry let opts_pos = self.0.children().position(|node| node.kind() == OPTS_LIST); if let Some(opts_idx) = opts_pos { // Remove the OPTS_LIST self.0.splice_children(opts_idx..opts_idx + 1, vec![]); // Remove any leading whitespace/continuation that was after the OPTS_LIST while self.0.children_with_tokens().next().is_some_and(|e| { matches!( e, SyntaxElement::Token(t) if t.kind() == WHITESPACE || t.kind() == CONTINUATION ) }) { self.0.splice_children(0..1, vec![]); } } } else { // Defer to OptionList to remove the option ol.remove_option(key); } } } } impl std::fmt::Debug for OptionList { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("OptionList") .field("text", &self.0.text().to_string()) .finish() } } impl OptionList { /// Returns an iterator over all option nodes in the options list. pub fn options(&self) -> impl Iterator + '_ { self.0.children().filter_map(_Option::cast) } /// Find an option node by key. pub fn find_option(&self, key: &str) -> Option<_Option> { self.options().find(|opt| opt.key().as_deref() == Some(key)) } /// Check if an option with the given key exists pub fn has_option(&self, key: &str) -> bool { self.options().any(|it| it.key().as_deref() == Some(key)) } /// Returns an iterator over all options as (key, value) pairs. /// This is a convenience method for code that needs key-value tuples (used for conversion to deb822 format). #[cfg(feature = "deb822")] pub(crate) fn iter_key_values(&self) -> impl Iterator + '_ { self.options().filter_map(|opt| { if let (Some(key), Some(value)) = (opt.key(), opt.value()) { Some((key, value)) } else { None } }) } /// Get the value of an option by key pub fn get_option(&self, key: &str) -> Option { for child in self.options() { if child.key().as_deref() == Some(key) { return child.value(); } } None } /// Add a new option to the end of the options list. fn add_option(&mut self, key: &str, value: &str) { let option_count = self.0.children().filter(|n| n.kind() == OPTION).count(); // Build a structure containing separator (if needed) + option wrapped in a temporary parent let mut builder = GreenNodeBuilder::new(); builder.start_node(ROOT.into()); // Temporary parent if option_count > 0 { builder.start_node(OPTION_SEPARATOR.into()); builder.token(COMMA.into(), ","); builder.finish_node(); } builder.start_node(OPTION.into()); builder.token(KEY.into(), key); builder.token(EQUALS.into(), "="); builder.token(VALUE.into(), value); builder.finish_node(); builder.finish_node(); // Close temporary parent let combined_green = builder.finish(); // Create a temporary root to extract children from let temp_root = SyntaxNode::new_root_mut(combined_green); let new_children: Vec<_> = temp_root.children_with_tokens().collect(); let insert_pos = self.0.children_with_tokens().count(); self.0.splice_children(insert_pos..insert_pos, new_children); } /// Remove an option by key. Returns true if an option was removed. fn remove_option(&mut self, key: &str) -> bool { if let Some(mut opt) = self.find_option(key) { opt.remove(); true } else { false } } } impl _Option { /// Returns the key of the option. pub fn key(&self) -> Option { self.0.children_with_tokens().find_map(|it| match it { SyntaxElement::Token(token) => { if token.kind() == KEY { Some(token.text().to_string()) } else { None } } _ => None, }) } /// Returns the value of the option. pub fn value(&self) -> Option { self.0 .children_with_tokens() .filter_map(|it| match it { SyntaxElement::Token(token) => { if token.kind() == VALUE || token.kind() == KEY { Some(token.text().to_string()) } else { None } } _ => None, }) .nth(1) } /// Set the value of the option. pub fn set_value(&mut self, new_value: &str) { let key = self.key().expect("Option must have a key"); // Build a new OPTION node with the updated value let mut builder = GreenNodeBuilder::new(); builder.start_node(OPTION.into()); builder.token(KEY.into(), &key); builder.token(EQUALS.into(), "="); builder.token(VALUE.into(), new_value); builder.finish_node(); let new_option_green = builder.finish(); let new_option_node = SyntaxNode::new_root_mut(new_option_green); // Replace this option in the parent OptionList if let Some(parent) = self.0.parent() { let idx = self.0.index(); parent.splice_children(idx..idx + 1, vec![new_option_node.into()]); } } /// Remove this option and its associated separator from the parent OptionList. pub fn remove(&mut self) { // Find adjacent separator to remove before detaching this node let next_sep = self .0 .next_sibling() .filter(|n| n.kind() == OPTION_SEPARATOR); let prev_sep = self .0 .prev_sibling() .filter(|n| n.kind() == OPTION_SEPARATOR); // Detach separator first if it exists if let Some(sep) = next_sep { sep.detach(); } else if let Some(sep) = prev_sep { sep.detach(); } // Now detach the option itself self.0.detach(); } } /// Concatenate every direct token child of `node` whose kind passes `keep`, /// preserving the original input order. Used by entry-field accessors so that /// values made up of several lexer tokens (e.g. a URL split around `=`) are /// returned as a single string. fn join_tokens(node: &SyntaxNode, keep: impl Fn(SyntaxKind) -> bool) -> String { let mut out = String::new(); for it in node.children_with_tokens() { if let SyntaxElement::Token(token) = it { if keep(token.kind()) { out.push_str(token.text()); } } } out } impl Url { /// Returns the URL string. pub fn url(&self) -> String { join_tokens(&self.0, |k| { matches!(k, KEY | VALUE | EQUALS | COMMA | QUOTE) }) } } impl MatchingPattern { /// Returns the matching pattern string. pub fn pattern(&self) -> String { join_tokens(&self.0, |k| { matches!(k, KEY | VALUE | EQUALS | COMMA | QUOTE) }) } } impl VersionPolicyNode { /// Returns the version policy string. pub fn policy(&self) -> String { join_tokens(&self.0, |k| { matches!(k, KEY | VALUE | EQUALS | COMMA | QUOTE) }) } } impl ScriptNode { /// Returns the script string. pub fn script(&self) -> String { join_tokens(&self.0, |k| { matches!(k, KEY | VALUE | EQUALS | COMMA | QUOTE) }) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_entry_node_structure() { // Test that entries properly use the new node types let wf: super::WatchFile = r#"version=4 opts=compression=xz https://example.com/releases (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate "# .parse() .unwrap(); let entry = wf.entries().next().unwrap(); // Verify URL node exists and works assert_eq!(entry.0.children().find(|n| n.kind() == URL).is_some(), true); assert_eq!(entry.url(), "https://example.com/releases"); // Verify MATCHING_PATTERN node exists and works assert_eq!( entry .0 .children() .find(|n| n.kind() == MATCHING_PATTERN) .is_some(), true ); assert_eq!( entry.matching_pattern(), Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into()) ); // Verify VERSION_POLICY node exists and works assert_eq!( entry .0 .children() .find(|n| n.kind() == VERSION_POLICY) .is_some(), true ); assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Debian))); // Verify SCRIPT node exists and works assert_eq!( entry.0.children().find(|n| n.kind() == SCRIPT).is_some(), true ); assert_eq!(entry.script(), Some("uupdate".into())); } #[test] fn test_entry_node_structure_partial() { // Test entry with only URL and pattern (no version or script) let wf: super::WatchFile = r#"version=4 https://github.com/example/tags .*/v?(\d\S+)\.tar\.gz "# .parse() .unwrap(); let entry = wf.entries().next().unwrap(); // Should have URL and MATCHING_PATTERN nodes assert_eq!(entry.0.children().find(|n| n.kind() == URL).is_some(), true); assert_eq!( entry .0 .children() .find(|n| n.kind() == MATCHING_PATTERN) .is_some(), true ); // Should NOT have VERSION_POLICY or SCRIPT nodes assert_eq!( entry .0 .children() .find(|n| n.kind() == VERSION_POLICY) .is_some(), false ); assert_eq!( entry.0.children().find(|n| n.kind() == SCRIPT).is_some(), false ); // Verify accessors work correctly assert_eq!(entry.url(), "https://github.com/example/tags"); assert_eq!( entry.matching_pattern(), Some(".*/v?(\\d\\S+)\\.tar\\.gz".into()) ); assert_eq!(entry.version(), Ok(None)); assert_eq!(entry.script(), None); } #[test] fn test_parse_v1() { const WATCHV1: &str = r#"version=4 opts=bare,filenamemangle=s/.+\/v?(\d\S+)\.tar\.gz/syncthing-gtk-$1\.tar\.gz/ \ https://github.com/syncthing/syncthing-gtk/tags .*/v?(\d\S+)\.tar\.gz "#; let parsed = parse(WATCHV1); //assert_eq!(parsed.errors, Vec::::new()); let node = parsed.syntax(); assert_eq!( format!("{:#?}", node), r#"ROOT@0..161 VERSION@0..10 KEY@0..7 "version" EQUALS@7..8 "=" VALUE@8..9 "4" NEWLINE@9..10 "\n" ENTRY@10..161 OPTS_LIST@10..86 KEY@10..14 "opts" EQUALS@14..15 "=" OPTION@15..19 KEY@15..19 "bare" OPTION_SEPARATOR@19..20 COMMA@19..20 "," OPTION@20..86 KEY@20..34 "filenamemangle" EQUALS@34..35 "=" VALUE@35..86 "s/.+\\/v?(\\d\\S+)\\.tar\\ ..." WHITESPACE@86..87 " " CONTINUATION@87..89 "\\\n" WHITESPACE@89..91 " " URL@91..138 VALUE@91..138 "https://github.com/sy ..." WHITESPACE@138..139 " " MATCHING_PATTERN@139..160 VALUE@139..160 ".*/v?(\\d\\S+)\\.tar\\.gz" NEWLINE@160..161 "\n" "# ); let root = parsed.root(); assert_eq!(root.version(), 4); let entries = root.entries().collect::>(); assert_eq!(entries.len(), 1); let entry = &entries[0]; assert_eq!( entry.url(), "https://github.com/syncthing/syncthing-gtk/tags" ); assert_eq!( entry.matching_pattern(), Some(".*/v?(\\d\\S+)\\.tar\\.gz".into()) ); assert_eq!(entry.version(), Ok(None)); assert_eq!(entry.script(), None); assert_eq!(node.text(), WATCHV1); } #[test] fn test_parse_v2() { let parsed = parse( r#"version=4 https://github.com/syncthing/syncthing-gtk/tags .*/v?(\d\S+)\.tar\.gz # comment "#, ); assert_eq!(parsed.errors, Vec::::new()); let node = parsed.syntax(); assert_eq!( format!("{:#?}", node), r###"ROOT@0..90 VERSION@0..10 KEY@0..7 "version" EQUALS@7..8 "=" VALUE@8..9 "4" NEWLINE@9..10 "\n" ENTRY@10..80 URL@10..57 VALUE@10..57 "https://github.com/sy ..." WHITESPACE@57..58 " " MATCHING_PATTERN@58..79 VALUE@58..79 ".*/v?(\\d\\S+)\\.tar\\.gz" NEWLINE@79..80 "\n" COMMENT@80..89 "# comment" NEWLINE@89..90 "\n" "### ); let root = parsed.root(); assert_eq!(root.version(), 4); let entries = root.entries().collect::>(); assert_eq!(entries.len(), 1); let entry = &entries[0]; assert_eq!( entry.url(), "https://github.com/syncthing/syncthing-gtk/tags" ); assert_eq!( entry.format_url(|| "syncthing-gtk".to_string(), || String::new()), "https://github.com/syncthing/syncthing-gtk/tags" .parse() .unwrap() ); } #[test] fn test_parse_v3() { let parsed = parse( r#"version=4 https://github.com/syncthing/@PACKAGE@/tags .*/v?(\d\S+)\.tar\.gz # comment "#, ); assert_eq!(parsed.errors, Vec::::new()); let root = parsed.root(); assert_eq!(root.version(), 4); let entries = root.entries().collect::>(); assert_eq!(entries.len(), 1); let entry = &entries[0]; assert_eq!(entry.url(), "https://github.com/syncthing/@PACKAGE@/tags"); assert_eq!( entry.format_url(|| "syncthing-gtk".to_string(), || String::new()), "https://github.com/syncthing/syncthing-gtk/tags" .parse() .unwrap() ); } #[test] fn test_thread_safe_parsing() { let text = r#"version=4 https://github.com/example/example/tags example-(.*)\.tar\.gz "#; let parsed = parse_watch_file(text); assert!(parsed.is_ok()); assert_eq!(parsed.errors().len(), 0); // Test that we can get the AST from the parse result let watchfile = parsed.tree(); assert_eq!(watchfile.version(), 4); let entries: Vec<_> = watchfile.entries().collect(); assert_eq!(entries.len(), 1); } #[test] fn test_parse_clone_and_eq() { let text = r#"version=4 https://github.com/example/example/tags example-(.*)\.tar\.gz "#; let parsed1 = parse_watch_file(text); let parsed2 = parsed1.clone(); // Test that cloned parse results are equal assert_eq!(parsed1, parsed2); // Test that the AST nodes are also cloneable let watchfile1 = parsed1.tree(); let watchfile2 = watchfile1.clone(); assert_eq!(watchfile1, watchfile2); } #[test] fn test_parse_v4() { let cl: super::WatchFile = r#"version=4 opts=repack,compression=xz,dversionmangle=s/\+ds//,repacksuffix=+ds \ https://github.com/example/example-cat/tags \ (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate "# .parse() .unwrap(); assert_eq!(cl.version(), 4); let entries = cl.entries().collect::>(); assert_eq!(entries.len(), 1); let entry = &entries[0]; assert_eq!(entry.url(), "https://github.com/example/example-cat/tags"); assert_eq!( entry.matching_pattern(), Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into()) ); assert!(entry.repack()); assert_eq!(entry.compression(), Ok(Some(Compression::Xz))); assert_eq!(entry.dversionmangle(), Some("s/\\+ds//".into())); assert_eq!(entry.repacksuffix(), Some("+ds".into())); assert_eq!(entry.script(), Some("uupdate".into())); assert_eq!( entry.format_url(|| "example-cat".to_string(), || String::new()), "https://github.com/example/example-cat/tags" .parse() .unwrap() ); assert_eq!(entry.version(), Ok(Some(VersionPolicy::Debian))); } #[test] fn test_git_mode() { let text = r#"version=3 opts="mode=git, gitmode=shallow, pgpmode=gittag" \ https://git.kernel.org/pub/scm/linux/kernel/git/firmware/linux-firmware.git \ refs/tags/(.*) debian "#; let parsed = parse(text); assert_eq!(parsed.errors, Vec::::new()); let cl = parsed.root(); assert_eq!(cl.version(), 3); let entries = cl.entries().collect::>(); assert_eq!(entries.len(), 1); let entry = &entries[0]; assert_eq!( entry.url(), "https://git.kernel.org/pub/scm/linux/kernel/git/firmware/linux-firmware.git" ); assert_eq!(entry.matching_pattern(), Some("refs/tags/(.*)".into())); assert_eq!(entry.version(), Ok(Some(VersionPolicy::Debian))); assert_eq!(entry.script(), None); assert_eq!(entry.gitmode(), Ok(GitMode::Shallow)); assert_eq!(entry.pgpmode(), Ok(PgpMode::GitTag)); assert_eq!(entry.mode(), Ok(Mode::Git)); } #[test] fn test_parse_quoted() { const WATCHV1: &str = r#"version=4 opts="bare, filenamemangle=blah" \ https://github.com/syncthing/syncthing-gtk/tags .*/v?(\d\S+)\.tar\.gz "#; let parsed = parse(WATCHV1); //assert_eq!(parsed.errors, Vec::::new()); let node = parsed.syntax(); let root = parsed.root(); assert_eq!(root.version(), 4); let entries = root.entries().collect::>(); assert_eq!(entries.len(), 1); let entry = &entries[0]; assert_eq!( entry.url(), "https://github.com/syncthing/syncthing-gtk/tags" ); assert_eq!( entry.matching_pattern(), Some(".*/v?(\\d\\S+)\\.tar\\.gz".into()) ); assert_eq!(entry.version(), Ok(None)); assert_eq!(entry.script(), None); assert_eq!(node.text(), WATCHV1); } #[test] fn test_set_url() { // Test setting URL on a simple entry without options let wf: super::WatchFile = r#"version=4 https://github.com/syncthing/syncthing-gtk/tags .*/v?(\d\S+)\.tar\.gz "# .parse() .unwrap(); let mut entry = wf.entries().next().unwrap(); assert_eq!( entry.url(), "https://github.com/syncthing/syncthing-gtk/tags" ); entry.set_url("https://newurl.example.org/path"); assert_eq!(entry.url(), "https://newurl.example.org/path"); assert_eq!( entry.matching_pattern(), Some(".*/v?(\\d\\S+)\\.tar\\.gz".into()) ); // Verify the exact serialized output assert_eq!( entry.to_string(), "https://newurl.example.org/path .*/v?(\\d\\S+)\\.tar\\.gz\n" ); } #[test] fn test_set_url_with_options() { // Test setting URL on an entry with options let wf: super::WatchFile = r#"version=4 opts=foo=blah https://foo.com/bar .*/v?(\d\S+)\.tar\.gz "# .parse() .unwrap(); let mut entry = wf.entries().next().unwrap(); assert_eq!(entry.url(), "https://foo.com/bar"); assert_eq!(entry.get_option("foo"), Some("blah".to_string())); entry.set_url("https://example.com/baz"); assert_eq!(entry.url(), "https://example.com/baz"); // Verify options are preserved assert_eq!(entry.get_option("foo"), Some("blah".to_string())); assert_eq!( entry.matching_pattern(), Some(".*/v?(\\d\\S+)\\.tar\\.gz".into()) ); // Verify the exact serialized output assert_eq!( entry.to_string(), "opts=foo=blah https://example.com/baz .*/v?(\\d\\S+)\\.tar\\.gz\n" ); } #[test] fn test_set_url_complex() { // Test with a complex watch file with multiple options and continuation let wf: super::WatchFile = r#"version=4 opts=bare,filenamemangle=s/.+\/v?(\d\S+)\.tar\.gz/syncthing-gtk-$1\.tar\.gz/ \ https://github.com/syncthing/syncthing-gtk/tags .*/v?(\d\S+)\.tar\.gz "# .parse() .unwrap(); let mut entry = wf.entries().next().unwrap(); assert_eq!( entry.url(), "https://github.com/syncthing/syncthing-gtk/tags" ); entry.set_url("https://gitlab.com/newproject/tags"); assert_eq!(entry.url(), "https://gitlab.com/newproject/tags"); // Verify all options are preserved assert!(entry.bare()); assert_eq!( entry.filenamemangle(), Some("s/.+\\/v?(\\d\\S+)\\.tar\\.gz/syncthing-gtk-$1\\.tar\\.gz/".into()) ); assert_eq!( entry.matching_pattern(), Some(".*/v?(\\d\\S+)\\.tar\\.gz".into()) ); // Verify the exact serialized output preserves structure assert_eq!( entry.to_string(), r#"opts=bare,filenamemangle=s/.+\/v?(\d\S+)\.tar\.gz/syncthing-gtk-$1\.tar\.gz/ \ https://gitlab.com/newproject/tags .*/v?(\d\S+)\.tar\.gz "# ); } #[test] fn test_set_url_with_all_fields() { // Test with all fields: options, URL, matching pattern, version, and script let wf: super::WatchFile = r#"version=4 opts=repack,compression=xz,dversionmangle=s/\+ds//,repacksuffix=+ds \ https://github.com/example/example-cat/tags \ (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate "# .parse() .unwrap(); let mut entry = wf.entries().next().unwrap(); assert_eq!(entry.url(), "https://github.com/example/example-cat/tags"); assert_eq!( entry.matching_pattern(), Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into()) ); assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Debian))); assert_eq!(entry.script(), Some("uupdate".into())); entry.set_url("https://gitlab.example.org/project/releases"); assert_eq!(entry.url(), "https://gitlab.example.org/project/releases"); // Verify all other fields are preserved assert!(entry.repack()); assert_eq!(entry.compression(), Ok(Some(super::Compression::Xz))); assert_eq!(entry.dversionmangle(), Some("s/\\+ds//".into())); assert_eq!(entry.repacksuffix(), Some("+ds".into())); assert_eq!( entry.matching_pattern(), Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into()) ); assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Debian))); assert_eq!(entry.script(), Some("uupdate".into())); // Verify the exact serialized output assert_eq!( entry.to_string(), r#"opts=repack,compression=xz,dversionmangle=s/\+ds//,repacksuffix=+ds \ https://gitlab.example.org/project/releases \ (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate "# ); } #[test] fn test_set_url_quoted_options() { // Test with quoted options let wf: super::WatchFile = r#"version=4 opts="bare, filenamemangle=blah" \ https://github.com/syncthing/syncthing-gtk/tags .*/v?(\d\S+)\.tar\.gz "# .parse() .unwrap(); let mut entry = wf.entries().next().unwrap(); assert_eq!( entry.url(), "https://github.com/syncthing/syncthing-gtk/tags" ); entry.set_url("https://example.org/new/path"); assert_eq!(entry.url(), "https://example.org/new/path"); // Verify the exact serialized output assert_eq!( entry.to_string(), r#"opts="bare, filenamemangle=blah" \ https://example.org/new/path .*/v?(\d\S+)\.tar\.gz "# ); } #[test] fn test_set_opt_update_existing() { // Test updating an existing option let wf: super::WatchFile = r#"version=4 opts=foo=blah,bar=baz https://example.com/releases .*/v?(\d\S+)\.tar\.gz "# .parse() .unwrap(); let mut entry = wf.entries().next().unwrap(); assert_eq!(entry.get_option("foo"), Some("blah".to_string())); assert_eq!(entry.get_option("bar"), Some("baz".to_string())); entry.set_opt("foo", "updated"); assert_eq!(entry.get_option("foo"), Some("updated".to_string())); assert_eq!(entry.get_option("bar"), Some("baz".to_string())); // Verify the exact serialized output assert_eq!( entry.to_string(), "opts=foo=updated,bar=baz https://example.com/releases .*/v?(\\d\\S+)\\.tar\\.gz\n" ); } #[test] fn test_set_opt_add_new() { // Test adding a new option to existing options let wf: super::WatchFile = r#"version=4 opts=foo=blah https://example.com/releases .*/v?(\d\S+)\.tar\.gz "# .parse() .unwrap(); let mut entry = wf.entries().next().unwrap(); assert_eq!(entry.get_option("foo"), Some("blah".to_string())); assert_eq!(entry.get_option("bar"), None); entry.set_opt("bar", "baz"); assert_eq!(entry.get_option("foo"), Some("blah".to_string())); assert_eq!(entry.get_option("bar"), Some("baz".to_string())); // Verify the exact serialized output assert_eq!( entry.to_string(), "opts=foo=blah,bar=baz https://example.com/releases .*/v?(\\d\\S+)\\.tar\\.gz\n" ); } #[test] fn test_set_opt_create_options_list() { // Test creating a new options list when none exists let wf: super::WatchFile = r#"version=4 https://example.com/releases .*/v?(\d\S+)\.tar\.gz "# .parse() .unwrap(); let mut entry = wf.entries().next().unwrap(); assert_eq!(entry.option_list(), None); entry.set_opt("compression", "xz"); assert_eq!(entry.get_option("compression"), Some("xz".to_string())); // Verify the exact serialized output assert_eq!( entry.to_string(), "opts=compression=xz https://example.com/releases .*/v?(\\d\\S+)\\.tar\\.gz\n" ); } #[test] fn test_del_opt_remove_single() { // Test removing a single option from multiple options let wf: super::WatchFile = r#"version=4 opts=foo=blah,bar=baz,qux=quux https://example.com/releases .*/v?(\d\S+)\.tar\.gz "# .parse() .unwrap(); let mut entry = wf.entries().next().unwrap(); assert_eq!(entry.get_option("foo"), Some("blah".to_string())); assert_eq!(entry.get_option("bar"), Some("baz".to_string())); assert_eq!(entry.get_option("qux"), Some("quux".to_string())); entry.del_opt_str("bar"); assert_eq!(entry.get_option("foo"), Some("blah".to_string())); assert_eq!(entry.get_option("bar"), None); assert_eq!(entry.get_option("qux"), Some("quux".to_string())); // Verify the exact serialized output assert_eq!( entry.to_string(), "opts=foo=blah,qux=quux https://example.com/releases .*/v?(\\d\\S+)\\.tar\\.gz\n" ); } #[test] fn test_del_opt_remove_first() { // Test removing the first option let wf: super::WatchFile = r#"version=4 opts=foo=blah,bar=baz https://example.com/releases .*/v?(\d\S+)\.tar\.gz "# .parse() .unwrap(); let mut entry = wf.entries().next().unwrap(); entry.del_opt_str("foo"); assert_eq!(entry.get_option("foo"), None); assert_eq!(entry.get_option("bar"), Some("baz".to_string())); // Verify the exact serialized output assert_eq!( entry.to_string(), "opts=bar=baz https://example.com/releases .*/v?(\\d\\S+)\\.tar\\.gz\n" ); } #[test] fn test_del_opt_remove_last() { // Test removing the last option let wf: super::WatchFile = r#"version=4 opts=foo=blah,bar=baz https://example.com/releases .*/v?(\d\S+)\.tar\.gz "# .parse() .unwrap(); let mut entry = wf.entries().next().unwrap(); entry.del_opt_str("bar"); assert_eq!(entry.get_option("foo"), Some("blah".to_string())); assert_eq!(entry.get_option("bar"), None); // Verify the exact serialized output assert_eq!( entry.to_string(), "opts=foo=blah https://example.com/releases .*/v?(\\d\\S+)\\.tar\\.gz\n" ); } #[test] fn test_del_opt_remove_only_option() { // Test removing the only option (should remove entire opts list) let wf: super::WatchFile = r#"version=4 opts=foo=blah https://example.com/releases .*/v?(\d\S+)\.tar\.gz "# .parse() .unwrap(); let mut entry = wf.entries().next().unwrap(); assert_eq!(entry.get_option("foo"), Some("blah".to_string())); entry.del_opt_str("foo"); assert_eq!(entry.get_option("foo"), None); assert_eq!(entry.option_list(), None); // Verify the exact serialized output (opts should be gone) assert_eq!( entry.to_string(), "https://example.com/releases .*/v?(\\d\\S+)\\.tar\\.gz\n" ); } #[test] fn test_del_opt_nonexistent() { // Test deleting a non-existent option (should do nothing) let wf: super::WatchFile = r#"version=4 opts=foo=blah https://example.com/releases .*/v?(\d\S+)\.tar\.gz "# .parse() .unwrap(); let mut entry = wf.entries().next().unwrap(); let original = entry.to_string(); entry.del_opt_str("nonexistent"); assert_eq!(entry.to_string(), original); } #[test] fn test_set_opt_multiple_operations() { // Test multiple set_opt operations let wf: super::WatchFile = r#"version=4 https://example.com/releases .*/v?(\d\S+)\.tar\.gz "# .parse() .unwrap(); let mut entry = wf.entries().next().unwrap(); entry.set_opt("compression", "xz"); entry.set_opt("repack", ""); entry.set_opt("dversionmangle", "s/\\+ds//"); assert_eq!(entry.get_option("compression"), Some("xz".to_string())); assert_eq!( entry.get_option("dversionmangle"), Some("s/\\+ds//".to_string()) ); } #[test] fn test_set_matching_pattern() { // Test setting matching pattern on a simple entry let wf: super::WatchFile = r#"version=4 https://github.com/example/tags .*/v?(\d\S+)\.tar\.gz "# .parse() .unwrap(); let mut entry = wf.entries().next().unwrap(); assert_eq!( entry.matching_pattern(), Some(".*/v?(\\d\\S+)\\.tar\\.gz".into()) ); entry.set_matching_pattern("(?:.*?/)?v?([\\d.]+)\\.tar\\.gz"); assert_eq!( entry.matching_pattern(), Some("(?:.*?/)?v?([\\d.]+)\\.tar\\.gz".into()) ); // Verify URL is preserved assert_eq!(entry.url(), "https://github.com/example/tags"); // Verify the exact serialized output assert_eq!( entry.to_string(), "https://github.com/example/tags (?:.*?/)?v?([\\d.]+)\\.tar\\.gz\n" ); } #[test] fn test_set_matching_pattern_with_all_fields() { // Test with all fields present let wf: super::WatchFile = r#"version=4 opts=compression=xz https://example.com/releases (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate "# .parse() .unwrap(); let mut entry = wf.entries().next().unwrap(); assert_eq!( entry.matching_pattern(), Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into()) ); entry.set_matching_pattern(".*/version-([\\d.]+)\\.tar\\.xz"); assert_eq!( entry.matching_pattern(), Some(".*/version-([\\d.]+)\\.tar\\.xz".into()) ); // Verify all other fields are preserved assert_eq!(entry.url(), "https://example.com/releases"); assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Debian))); assert_eq!(entry.script(), Some("uupdate".into())); assert_eq!(entry.compression(), Ok(Some(super::Compression::Xz))); // Verify the exact serialized output assert_eq!( entry.to_string(), "opts=compression=xz https://example.com/releases .*/version-([\\d.]+)\\.tar\\.xz debian uupdate\n" ); } #[test] fn test_set_version_policy() { // Test setting version policy let wf: super::WatchFile = r#"version=4 https://example.com/releases (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate "# .parse() .unwrap(); let mut entry = wf.entries().next().unwrap(); assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Debian))); entry.set_version_policy("previous"); assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Previous))); // Verify all other fields are preserved assert_eq!(entry.url(), "https://example.com/releases"); assert_eq!( entry.matching_pattern(), Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into()) ); assert_eq!(entry.script(), Some("uupdate".into())); // Verify the exact serialized output assert_eq!( entry.to_string(), "https://example.com/releases (?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz previous uupdate\n" ); } #[test] fn test_set_version_policy_with_options() { // Test with options and continuation let wf: super::WatchFile = r#"version=4 opts=repack,compression=xz \ https://github.com/example/example-cat/tags \ (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate "# .parse() .unwrap(); let mut entry = wf.entries().next().unwrap(); assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Debian))); entry.set_version_policy("ignore"); assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Ignore))); // Verify all other fields are preserved assert_eq!(entry.url(), "https://github.com/example/example-cat/tags"); assert_eq!( entry.matching_pattern(), Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into()) ); assert_eq!(entry.script(), Some("uupdate".into())); assert!(entry.repack()); // Verify the exact serialized output assert_eq!( entry.to_string(), r#"opts=repack,compression=xz \ https://github.com/example/example-cat/tags \ (?:.*?/)?v?(\d[\d.]*)\.tar\.gz ignore uupdate "# ); } #[test] fn test_set_script() { // Test setting script let wf: super::WatchFile = r#"version=4 https://example.com/releases (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate "# .parse() .unwrap(); let mut entry = wf.entries().next().unwrap(); assert_eq!(entry.script(), Some("uupdate".into())); entry.set_script("uscan"); assert_eq!(entry.script(), Some("uscan".into())); // Verify all other fields are preserved assert_eq!(entry.url(), "https://example.com/releases"); assert_eq!( entry.matching_pattern(), Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into()) ); assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Debian))); // Verify the exact serialized output assert_eq!( entry.to_string(), "https://example.com/releases (?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz debian uscan\n" ); } #[test] fn test_set_script_with_options() { // Test with options let wf: super::WatchFile = r#"version=4 opts=compression=xz https://example.com/releases (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate "# .parse() .unwrap(); let mut entry = wf.entries().next().unwrap(); assert_eq!(entry.script(), Some("uupdate".into())); entry.set_script("custom-script.sh"); assert_eq!(entry.script(), Some("custom-script.sh".into())); // Verify all other fields are preserved assert_eq!(entry.url(), "https://example.com/releases"); assert_eq!( entry.matching_pattern(), Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into()) ); assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Debian))); assert_eq!(entry.compression(), Ok(Some(super::Compression::Xz))); // Verify the exact serialized output assert_eq!( entry.to_string(), "opts=compression=xz https://example.com/releases (?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz debian custom-script.sh\n" ); } #[test] fn test_apply_dversionmangle() { // Test basic dversionmangle let wf: super::WatchFile = r#"version=4 opts=dversionmangle=s/\+dfsg$// https://example.com/ .* "# .parse() .unwrap(); let entry = wf.entries().next().unwrap(); assert_eq!(entry.apply_dversionmangle("1.0+dfsg").unwrap(), "1.0"); assert_eq!(entry.apply_dversionmangle("1.0").unwrap(), "1.0"); // Test with versionmangle (fallback) let wf: super::WatchFile = r#"version=4 opts=versionmangle=s/^v// https://example.com/ .* "# .parse() .unwrap(); let entry = wf.entries().next().unwrap(); assert_eq!(entry.apply_dversionmangle("v1.0").unwrap(), "1.0"); // Test with both dversionmangle and versionmangle (dversionmangle takes precedence) let wf: super::WatchFile = r#"version=4 opts=dversionmangle=s/\+ds//,versionmangle=s/^v// https://example.com/ .* "# .parse() .unwrap(); let entry = wf.entries().next().unwrap(); assert_eq!(entry.apply_dversionmangle("1.0+ds").unwrap(), "1.0"); // Test without any mangle options let wf: super::WatchFile = r#"version=4 https://example.com/ .* "# .parse() .unwrap(); let entry = wf.entries().next().unwrap(); assert_eq!(entry.apply_dversionmangle("1.0+dfsg").unwrap(), "1.0+dfsg"); } #[test] fn test_apply_oversionmangle() { // Test basic oversionmangle - adding suffix let wf: super::WatchFile = r#"version=4 opts=oversionmangle=s/$/-1/ https://example.com/ .* "# .parse() .unwrap(); let entry = wf.entries().next().unwrap(); assert_eq!(entry.apply_oversionmangle("1.0").unwrap(), "1.0-1"); assert_eq!(entry.apply_oversionmangle("2.5.3").unwrap(), "2.5.3-1"); // Test oversionmangle for adding +dfsg suffix let wf: super::WatchFile = r#"version=4 opts=oversionmangle=s/$/.dfsg/ https://example.com/ .* "# .parse() .unwrap(); let entry = wf.entries().next().unwrap(); assert_eq!(entry.apply_oversionmangle("1.0").unwrap(), "1.0.dfsg"); // Test without any mangle options let wf: super::WatchFile = r#"version=4 https://example.com/ .* "# .parse() .unwrap(); let entry = wf.entries().next().unwrap(); assert_eq!(entry.apply_oversionmangle("1.0").unwrap(), "1.0"); } #[test] fn test_apply_dirversionmangle() { // Test basic dirversionmangle - removing 'v' prefix let wf: super::WatchFile = r#"version=4 opts=dirversionmangle=s/^v// https://example.com/ .* "# .parse() .unwrap(); let entry = wf.entries().next().unwrap(); assert_eq!(entry.apply_dirversionmangle("v1.0").unwrap(), "1.0"); assert_eq!(entry.apply_dirversionmangle("v2.5.3").unwrap(), "2.5.3"); // Test dirversionmangle with capture groups let wf: super::WatchFile = r#"version=4 opts=dirversionmangle=s/v(\d)/$1/ https://example.com/ .* "# .parse() .unwrap(); let entry = wf.entries().next().unwrap(); assert_eq!(entry.apply_dirversionmangle("v1.0").unwrap(), "1.0"); // Test without any mangle options let wf: super::WatchFile = r#"version=4 https://example.com/ .* "# .parse() .unwrap(); let entry = wf.entries().next().unwrap(); assert_eq!(entry.apply_dirversionmangle("v1.0").unwrap(), "v1.0"); } #[test] fn test_apply_filenamemangle() { // Test filenamemangle to generate tarball filename let wf: super::WatchFile = r#"version=4 opts=filenamemangle=s/.+\/v?(\d\S+)\.tar\.gz/mypackage-$1.tar.gz/ https://example.com/ .* "# .parse() .unwrap(); let entry = wf.entries().next().unwrap(); assert_eq!( entry .apply_filenamemangle("https://example.com/v1.0.tar.gz") .unwrap(), "mypackage-1.0.tar.gz" ); assert_eq!( entry .apply_filenamemangle("https://example.com/2.5.3.tar.gz") .unwrap(), "mypackage-2.5.3.tar.gz" ); // Test filenamemangle with different pattern let wf: super::WatchFile = r#"version=4 opts=filenamemangle=s/.*\/(.*)/$1/ https://example.com/ .* "# .parse() .unwrap(); let entry = wf.entries().next().unwrap(); assert_eq!( entry .apply_filenamemangle("https://example.com/path/to/file.tar.gz") .unwrap(), "file.tar.gz" ); // Test without any mangle options let wf: super::WatchFile = r#"version=4 https://example.com/ .* "# .parse() .unwrap(); let entry = wf.entries().next().unwrap(); assert_eq!( entry .apply_filenamemangle("https://example.com/file.tar.gz") .unwrap(), "https://example.com/file.tar.gz" ); } #[test] fn test_apply_pagemangle() { // Test pagemangle to decode HTML entities let wf: super::WatchFile = r#"version=4 opts=pagemangle=s/&/&/g https://example.com/ .* "# .parse() .unwrap(); let entry = wf.entries().next().unwrap(); assert_eq!( entry.apply_pagemangle(b"foo & bar").unwrap(), b"foo & bar" ); assert_eq!( entry .apply_pagemangle(b"& foo & bar &") .unwrap(), b"& foo & bar &" ); // Test pagemangle with different pattern let wf: super::WatchFile = r#"version=4 opts=pagemangle=s/<[^>]+>//g https://example.com/ .* "# .parse() .unwrap(); let entry = wf.entries().next().unwrap(); assert_eq!(entry.apply_pagemangle(b"
text
").unwrap(), b"text"); // Test without any mangle options let wf: super::WatchFile = r#"version=4 https://example.com/ .* "# .parse() .unwrap(); let entry = wf.entries().next().unwrap(); assert_eq!( entry.apply_pagemangle(b"foo & bar").unwrap(), b"foo & bar" ); } #[test] fn test_apply_downloadurlmangle() { // Test downloadurlmangle to change URL path let wf: super::WatchFile = r#"version=4 opts=downloadurlmangle=s|/archive/|/download/| https://example.com/ .* "# .parse() .unwrap(); let entry = wf.entries().next().unwrap(); assert_eq!( entry .apply_downloadurlmangle("https://example.com/archive/file.tar.gz") .unwrap(), "https://example.com/download/file.tar.gz" ); // Test downloadurlmangle with different pattern let wf: super::WatchFile = r#"version=4 opts=downloadurlmangle=s/github\.com/raw.githubusercontent.com/ https://example.com/ .* "# .parse() .unwrap(); let entry = wf.entries().next().unwrap(); assert_eq!( entry .apply_downloadurlmangle("https://github.com/user/repo/file.tar.gz") .unwrap(), "https://raw.githubusercontent.com/user/repo/file.tar.gz" ); // Test without any mangle options let wf: super::WatchFile = r#"version=4 https://example.com/ .* "# .parse() .unwrap(); let entry = wf.entries().next().unwrap(); assert_eq!( entry .apply_downloadurlmangle("https://example.com/archive/file.tar.gz") .unwrap(), "https://example.com/archive/file.tar.gz" ); } #[test] fn test_entry_builder_minimal() { // Test creating a minimal entry with just URL and pattern let entry = super::EntryBuilder::new("https://github.com/example/tags") .matching_pattern(".*/v?(\\d\\S+)\\.tar\\.gz") .build(); assert_eq!(entry.url(), "https://github.com/example/tags"); assert_eq!( entry.matching_pattern().as_deref(), Some(".*/v?(\\d\\S+)\\.tar\\.gz") ); assert_eq!(entry.version(), Ok(None)); assert_eq!(entry.script(), None); assert!(entry.opts().is_empty()); } #[test] fn test_entry_builder_url_only() { // Test creating an entry with just URL let entry = super::EntryBuilder::new("https://example.com/releases").build(); assert_eq!(entry.url(), "https://example.com/releases"); assert_eq!(entry.matching_pattern(), None); assert_eq!(entry.version(), Ok(None)); assert_eq!(entry.script(), None); assert!(entry.opts().is_empty()); } #[test] fn test_entry_builder_with_all_fields() { // Test creating an entry with all fields let entry = super::EntryBuilder::new("https://github.com/example/tags") .matching_pattern(".*/v?(\\d[\\d.]*)\\.tar\\.gz") .version_policy("debian") .script("uupdate") .opt("compression", "xz") .flag("repack") .build(); assert_eq!(entry.url(), "https://github.com/example/tags"); assert_eq!( entry.matching_pattern().as_deref(), Some(".*/v?(\\d[\\d.]*)\\.tar\\.gz") ); assert_eq!(entry.version(), Ok(Some(VersionPolicy::Debian))); assert_eq!(entry.script(), Some("uupdate".into())); assert_eq!(entry.get_option("compression"), Some("xz".to_string())); assert!(entry.has_option("repack")); assert!(entry.repack()); } #[test] fn test_entry_builder_multiple_options() { // Test creating an entry with multiple options let entry = super::EntryBuilder::new("https://example.com/tags") .matching_pattern(".*/v?(\\d+\\.\\d+)\\.tar\\.gz") .opt("compression", "xz") .opt("dversionmangle", "s/\\+ds//") .opt("repacksuffix", "+ds") .build(); assert_eq!(entry.get_option("compression"), Some("xz".to_string())); assert_eq!( entry.get_option("dversionmangle"), Some("s/\\+ds//".to_string()) ); assert_eq!(entry.get_option("repacksuffix"), Some("+ds".to_string())); } #[test] fn test_entry_builder_via_entry() { // Test using Entry::builder() convenience method let entry = super::Entry::builder("https://github.com/example/tags") .matching_pattern(".*/v?(\\d\\S+)\\.tar\\.gz") .version_policy("debian") .build(); assert_eq!(entry.url(), "https://github.com/example/tags"); assert_eq!( entry.matching_pattern().as_deref(), Some(".*/v?(\\d\\S+)\\.tar\\.gz") ); assert_eq!(entry.version(), Ok(Some(VersionPolicy::Debian))); } #[test] fn test_watchfile_add_entry_to_empty() { // Test adding an entry to an empty watchfile let mut wf = super::WatchFile::new(Some(4)); let entry = super::EntryBuilder::new("https://github.com/example/tags") .matching_pattern(".*/v?(\\d\\S+)\\.tar\\.gz") .build(); wf.add_entry(entry); assert_eq!(wf.version(), 4); assert_eq!(wf.entries().count(), 1); let added_entry = wf.entries().next().unwrap(); assert_eq!(added_entry.url(), "https://github.com/example/tags"); assert_eq!( added_entry.matching_pattern().as_deref(), Some(".*/v?(\\d\\S+)\\.tar\\.gz") ); } #[test] fn test_watchfile_add_multiple_entries() { // Test adding multiple entries to a watchfile let mut wf = super::WatchFile::new(Some(4)); wf.add_entry( super::EntryBuilder::new("https://github.com/example1/tags") .matching_pattern(".*/v?(\\d\\S+)\\.tar\\.gz") .build(), ); wf.add_entry( super::EntryBuilder::new("https://github.com/example2/releases") .matching_pattern(".*/(\\d+\\.\\d+)\\.tar\\.gz") .opt("compression", "xz") .build(), ); assert_eq!(wf.entries().count(), 2); let entries: Vec<_> = wf.entries().collect(); assert_eq!(entries[0].url(), "https://github.com/example1/tags"); assert_eq!(entries[1].url(), "https://github.com/example2/releases"); assert_eq!(entries[1].get_option("compression"), Some("xz".to_string())); } #[test] fn test_watchfile_add_entry_to_existing() { // Test adding an entry to a watchfile that already has entries let mut wf: super::WatchFile = r#"version=4 https://example.com/old .*/v?(\\d\\S+)\\.tar\\.gz "# .parse() .unwrap(); assert_eq!(wf.entries().count(), 1); wf.add_entry( super::EntryBuilder::new("https://github.com/example/new") .matching_pattern(".*/v?(\\d+\\.\\d+)\\.tar\\.gz") .opt("compression", "xz") .version_policy("debian") .build(), ); assert_eq!(wf.entries().count(), 2); let entries: Vec<_> = wf.entries().collect(); assert_eq!(entries[0].url(), "https://example.com/old"); assert_eq!(entries[1].url(), "https://github.com/example/new"); assert_eq!(entries[1].version(), Ok(Some(VersionPolicy::Debian))); } #[test] fn test_entry_builder_formatting() { // Test that the builder produces correctly formatted entries let entry = super::EntryBuilder::new("https://github.com/example/tags") .matching_pattern(".*/v?(\\d\\S+)\\.tar\\.gz") .opt("compression", "xz") .flag("repack") .version_policy("debian") .script("uupdate") .build(); let entry_str = entry.to_string(); // Should start with opts= assert!(entry_str.starts_with("opts=")); // Should contain the URL assert!(entry_str.contains("https://github.com/example/tags")); // Should contain the pattern assert!(entry_str.contains(".*/v?(\\d\\S+)\\.tar\\.gz")); // Should contain version policy assert!(entry_str.contains("debian")); // Should contain script assert!(entry_str.contains("uupdate")); // Should end with newline assert!(entry_str.ends_with('\n')); } #[test] fn test_watchfile_add_entry_preserves_format() { // Test that adding entries preserves the watchfile format let mut wf = super::WatchFile::new(Some(4)); wf.add_entry( super::EntryBuilder::new("https://github.com/example/tags") .matching_pattern(".*/v?(\\d\\S+)\\.tar\\.gz") .build(), ); let wf_str = wf.to_string(); // Should have version line assert!(wf_str.starts_with("version=4\n")); // Should have the entry assert!(wf_str.contains("https://github.com/example/tags")); // Parse it back and ensure it's still valid let reparsed: super::WatchFile = wf_str.parse().unwrap(); assert_eq!(reparsed.version(), 4); assert_eq!(reparsed.entries().count(), 1); } #[test] fn test_line_col() { let text = r#"version=4 opts=compression=xz https://example.com/releases (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate "#; let wf = text.parse::().unwrap(); // Test version line position let version_node = wf.version_node().unwrap(); assert_eq!(version_node.line(), 0); assert_eq!(version_node.column(), 0); assert_eq!(version_node.line_col(), (0, 0)); // Test entry line numbers let entries: Vec<_> = wf.entries().collect(); assert_eq!(entries.len(), 1); // Entry starts at line 1 assert_eq!(entries[0].line(), 1); assert_eq!(entries[0].column(), 0); assert_eq!(entries[0].line_col(), (1, 0)); // Test node accessors let option_list = entries[0].option_list().unwrap(); assert_eq!(option_list.line(), 1); // Option list is on line 1 let url_node = entries[0].url_node().unwrap(); assert_eq!(url_node.line(), 1); // URL is on line 1 let pattern_node = entries[0].matching_pattern_node().unwrap(); assert_eq!(pattern_node.line(), 1); // Pattern is on line 1 let version_policy_node = entries[0].version_node().unwrap(); assert_eq!(version_policy_node.line(), 1); // Version policy is on line 1 let script_node = entries[0].script_node().unwrap(); assert_eq!(script_node.line(), 1); // Script is on line 1 // Test individual option nodes let options: Vec<_> = option_list.options().collect(); assert_eq!(options.len(), 1); assert_eq!(options[0].key(), Some("compression".to_string())); assert_eq!(options[0].value(), Some("xz".to_string())); assert_eq!(options[0].line(), 1); // Option is on line 1 // Test find_option let compression_opt = option_list.find_option("compression").unwrap(); assert_eq!(compression_opt.line(), 1); assert_eq!(compression_opt.column(), 5); // After "opts=" assert_eq!(compression_opt.line_col(), (1, 5)); } #[test] fn test_parse_str_relaxed() { let wf: super::WatchFile = super::WatchFile::from_str_relaxed( r#"version=4 ERRORS IN THIS LINE opts=compression=xz https://example.com/releases (?:.*?/)?v?(\d "#, ); assert_eq!(wf.version(), 4); assert_eq!(wf.entries().count(), 2); let entries = wf.entries().collect::>(); let entry = &entries[0]; assert_eq!(entry.url(), "ERRORS"); let entry = &entries[1]; assert_eq!(entry.url(), "https://example.com/releases"); assert_eq!(entry.matching_pattern().as_deref(), Some("(?:.*?/)?v?(\\d")); assert_eq!(entry.get_option("compression"), Some("xz".to_string())); } #[test] fn test_parse_entry_with_comment_before() { // Regression test for https://bugs.debian.org/1128319: // A comment line before an entry with a continuation line was not parsed correctly // - the entry was silently dropped. let input = concat!( "version=4\n", "# try also https://pypi.debian.net/tomoscan/watch\n", "opts=uversionmangle=s/(rc|a|b|c)/~$1/;s/\\.dev/~dev/ \\\n", "https://pypi.debian.net/tomoscan/tomoscan-(.+)\\.(?:zip|tgz|tbz|txz|(?:tar\\.(?:gz|bz2|xz)))\n" ); let wf: super::WatchFile = input.parse().unwrap(); // The CST must cover the full input (round-trip invariant) assert_eq!(wf.to_string(), input); assert_eq!(wf.entries().count(), 1); let entry = wf.entries().next().unwrap(); assert_eq!( entry.url(), "https://pypi.debian.net/tomoscan/tomoscan-(.+)\\.(?:zip|tgz|tbz|txz|(?:tar\\.(?:gz|bz2|xz)))" ); assert_eq!( entry.get_option("uversionmangle"), Some("s/(rc|a|b|c)/~$1/;s/\\.dev/~dev/".to_string()) ); } #[test] fn test_parse_multiple_comments_before_entry() { // Multiple consecutive comment lines before an entry should all be preserved // and the entry should still be parsed correctly. let input = concat!( "version=4\n", "# first comment\n", "# second comment\n", "# third comment\n", "https://example.com/foo foo-(.*).tar.gz\n", ); let wf: super::WatchFile = input.parse().unwrap(); assert_eq!(wf.to_string(), input); assert_eq!(wf.entries().count(), 1); assert_eq!( wf.entries().next().unwrap().url(), "https://example.com/foo" ); } #[test] fn test_parse_blank_lines_between_entries() { // Blank lines between entries should be preserved and all entries parsed. let input = concat!( "version=4\n", "https://example.com/foo .*/foo-(\\d+)\\.tar\\.gz\n", "\n", "https://example.com/bar .*/bar-(\\d+)\\.tar\\.gz\n", ); let wf: super::WatchFile = input.parse().unwrap(); assert_eq!(wf.to_string(), input); assert_eq!(wf.entries().count(), 2); } #[test] fn test_parse_trailing_unparseable_tokens_produce_error() { // Any tokens that remain after all entries are parsed should be captured // in an ERROR node so the CST covers the full input, and an error is reported. let input = "version=4\nhttps://example.com/foo foo-(.*).tar.gz\n=garbage\n"; let result = input.parse::(); assert!(result.is_err(), "expected parse error for trailing garbage"); // Verify the round-trip via from_str_relaxed: the CST must cover all input. let wf = super::WatchFile::from_str_relaxed(input); assert_eq!(wf.to_string(), input); } #[test] fn test_parse_roundtrip_full_file() { // The CST must always cover the full input, so to_string() == original input. let inputs = [ "version=4\nhttps://example.com/foo foo-(.*).tar.gz\n", "version=4\n# a comment\nhttps://example.com/foo foo-(.*).tar.gz\n", concat!( "version=4\n", "opts=uversionmangle=s/rc/~rc/ \\\n", " https://example.com/foo foo-(.*).tar.gz\n", ), concat!( "version=4\n", "# comment before entry\n", "opts=uversionmangle=s/rc/~rc/ \\\n", "https://example.com/foo foo-(.*).tar.gz\n", "# comment between entries\n", "https://example.com/bar bar-(.*).tar.gz\n", ), ]; for input in &inputs { let wf: super::WatchFile = input.parse().unwrap(); assert_eq!( wf.to_string(), *input, "round-trip failed for input: {:?}", input ); } } #[test] fn test_parse_url_with_equals_in_query_string() { // Regression: URLs with query strings like `?per_page=100` were lexed // as VALUE EQUALS VALUE and tripped up the entry-field parser. let input = concat!( "version=4\n", "https://api.github.com/repos/x/releases?per_page=100 \\\n", " https://github.com/x/v[^/]+/x.tar.gz\n", ); let wf: super::WatchFile = input.parse().unwrap(); let entries: Vec<_> = wf.entries().collect(); assert_eq!(entries.len(), 1); assert_eq!( entries[0].url(), "https://api.github.com/repos/x/releases?per_page=100" ); assert_eq!( entries[0].matching_pattern().as_deref(), Some("https://github.com/x/v[^/]+/x.tar.gz"), ); assert_eq!(wf.to_string(), input); } #[test] fn test_entry_url_does_not_panic_when_empty() { // Pathological entries that come out of the parser without a URL // node must not panic on `Entry::url()` — return an empty string. let input = "version=4\n=garbage\n"; let wf = super::WatchFile::from_str_relaxed(input); for entry in wf.entries() { let _ = entry.url(); } } #[test] fn test_parse_url_node_with_equals_join_tokens() { // Even if the lexer split the URL across tokens, the URL accessor // should reassemble them. let input = "version=4\nhttps://example.com/x?y=1&z=2 .*tar.gz\n"; let wf: super::WatchFile = input.parse().unwrap(); let entry = wf.entries().next().unwrap(); assert_eq!(entry.url(), "https://example.com/x?y=1&z=2"); } #[test] fn test_parse_quoted_opts_with_trailing_comma_continuation() { // Regression (golang-github-varlink-go style): each option line ends // with `,\` and the closing quote sits on its own line. The parser // must skip the whitespace/continuation before checking for the // closing quote so the trailing comma doesn't kick off another // (empty) option. let input = concat!( "version=4\n\n", "opts=\"\\\n", "pgpmode=none,\\\n", "repack,compression=xz,repacksuffix=+dfsg,\\\n", "dversionmangle=s{[+~]dfsg\\d*}{},\\\n", "\" https://github.com/varlink/go/releases \\\n", " .*/archive/v?(\\d[\\d\\.]+)\\.tar\\.gz\n", ); let wf: super::WatchFile = input.parse().unwrap(); let entries: Vec<_> = wf.entries().collect(); assert_eq!(entries.len(), 1); assert_eq!(entries[0].url(), "https://github.com/varlink/go/releases"); assert_eq!( entries[0].matching_pattern().as_deref(), Some(".*/archive/v?(\\d[\\d\\.]+)\\.tar\\.gz"), ); assert_eq!(wf.to_string(), input); } #[test] fn test_parse_quoted_opts_with_spaces_around_comma() { // Regression (libiio style): `opts="a=1 , b=2"` with whitespace // around the comma inside quotes. let input = concat!( "version=4\n", "opts=\"filenamemangle=s/.+\\/v?(\\d\\S*)\\.tar\\.gz/v$1.tar.gz/ , uversionmangle=tr%-rc%~rc%\" \\\n", " https://github.com/analogdevicesinc/libiio/tags .*/v(\\d\\S*)\\.tar\\.gz\n", ); let wf: super::WatchFile = input.parse().unwrap(); let entries: Vec<_> = wf.entries().collect(); assert_eq!(entries.len(), 1); assert_eq!( entries[0].url(), "https://github.com/analogdevicesinc/libiio/tags", ); assert_eq!(wf.to_string(), input); } #[test] fn test_parse_unquoted_opts_trailing_comma_then_url() { // Regression (rally-openstack style): opts ends with `,\` and the URL // begins on the next physical line. The trailing comma should not // make the parser eat the URL as a malformed option. let input = concat!( "version=3\n", "opts=uversionmangle=s/(rc|a|b|c)/~$1/,\\\n", "https://github.com/openstack/rally/tags .*/(\\d\\S*)\\.tar\\.gz\n", ); let wf: super::WatchFile = input.parse().unwrap(); let entries: Vec<_> = wf.entries().collect(); assert_eq!(entries.len(), 1); assert_eq!(entries[0].url(), "https://github.com/openstack/rally/tags"); assert_eq!( entries[0].matching_pattern().as_deref(), Some(".*/(\\d\\S*)\\.tar\\.gz"), ); assert_eq!(wf.to_string(), input); } #[test] fn test_parse_unquoted_opts_value_with_equals() { // Regression: `s/.*ref=//` in an option value contains `=` which the // lexer treats as a separator. The option-value loop must keep // gobbling until it hits a real option boundary. let input = concat!( "version=4\n", "opts=dversionmangle=s/\\~dfsg//,downloadurlmangle=s/.*ref=//,pgpsigurlmangle=s/$/.asc/ \\\n", "\thttps://downloads.asterisk.org/pub/telephony/libpri/releases/ libpri-([0-9.]*)\\.tar\\.gz debian uupdate\n", ); let wf: super::WatchFile = input.parse().unwrap(); let entries: Vec<_> = wf.entries().collect(); assert_eq!(entries.len(), 1); assert_eq!( entries[0].url(), "https://downloads.asterisk.org/pub/telephony/libpri/releases/" ); assert_eq!( entries[0].matching_pattern().as_deref(), Some("libpri-([0-9.]*)\\.tar\\.gz"), ); assert_eq!(wf.to_string(), input); } } debian-watch-0.4.12/src/mangle.rs000064400000000000000000000336371046102023000146520ustar 00000000000000//! Functions for parsing and applying version and URL mangling expressions. //! //! Debian watch files use sed-style expressions for transforming versions and URLs. use regex::Regex; /// Error type for mangling expression parsing #[derive(Debug, Clone, PartialEq, Eq)] pub enum MangleError { /// Not a substitution or translation expression NotMangleExpr(String), /// Invalid substitution expression InvalidSubstExpr(String), /// Invalid translation expression InvalidTranslExpr(String), /// Regex compilation error RegexError(String), } impl std::fmt::Display for MangleError { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { MangleError::NotMangleExpr(s) => { write!(f, "not a substitution or translation expression: {}", s) } MangleError::InvalidSubstExpr(s) => write!(f, "invalid substitution expression: {}", s), MangleError::InvalidTranslExpr(s) => write!(f, "invalid translation expression: {}", s), MangleError::RegexError(s) => write!(f, "regex error: {}", s), } } } impl std::error::Error for MangleError {} /// Type of mangling expression #[derive(Debug, Clone, PartialEq, Eq)] pub enum MangleExprKind { /// Substitution (s/pattern/replacement/flags) Subst, /// Translation (tr/pattern/replacement/flags or y/pattern/replacement/flags) Transl, } /// A parsed mangling expression #[derive(Debug, Clone, PartialEq, Eq)] pub struct MangleExpr { /// The kind of expression pub kind: MangleExprKind, /// The pattern to match pub pattern: String, /// The replacement string pub replacement: String, /// Optional flags pub flags: Option, } /// Parse a mangling expression /// /// # Examples /// /// ``` /// use debian_watch::mangle::parse_mangle_expr; /// /// let expr = parse_mangle_expr("s/foo/bar/g").unwrap(); /// assert_eq!(expr.pattern, "foo"); /// assert_eq!(expr.replacement, "bar"); /// assert_eq!(expr.flags.as_deref(), Some("g")); /// ``` pub fn parse_mangle_expr(vm: &str) -> Result { if vm.starts_with('s') { parse_subst_expr(vm) } else if vm.starts_with("tr") { parse_transl_expr(vm) } else if vm.starts_with('y') { parse_transl_expr(vm) } else { Err(MangleError::NotMangleExpr(vm.to_string())) } } /// Parse a substitution expression (s/pattern/replacement/flags) /// /// # Examples /// /// ``` /// use debian_watch::mangle::parse_subst_expr; /// /// let expr = parse_subst_expr("s/foo/bar/g").unwrap(); /// assert_eq!(expr.pattern, "foo"); /// assert_eq!(expr.replacement, "bar"); /// assert_eq!(expr.flags.as_deref(), Some("g")); /// /// let expr = parse_subst_expr("s|foo|bar|").unwrap(); /// assert_eq!(expr.pattern, "foo"); /// assert_eq!(expr.replacement, "bar"); /// ``` pub fn parse_subst_expr(vm: &str) -> Result { if !vm.starts_with('s') { return Err(MangleError::InvalidSubstExpr( "not a substitution expression".to_string(), )); } if vm.len() < 2 { return Err(MangleError::InvalidSubstExpr( "expression too short".to_string(), )); } let delimiter = vm.chars().nth(1).unwrap(); let rest = &vm[2..]; // Split by unescaped delimiter let parts = split_by_unescaped_delimiter(rest, delimiter); if parts.len() < 2 { return Err(MangleError::InvalidSubstExpr( "not enough parts".to_string(), )); } let pattern = parts[0].clone(); let replacement = parts[1].clone(); let flags = if parts.len() > 2 && !parts[2].is_empty() { Some(parts[2].clone()) } else { None }; Ok(MangleExpr { kind: MangleExprKind::Subst, pattern, replacement, flags, }) } /// Parse a translation expression (tr/pattern/replacement/flags or y/pattern/replacement/flags) /// /// # Examples /// /// ``` /// use debian_watch::mangle::parse_transl_expr; /// /// let expr = parse_transl_expr("tr/a-z/A-Z/").unwrap(); /// assert_eq!(expr.pattern, "a-z"); /// assert_eq!(expr.replacement, "A-Z"); /// ``` pub fn parse_transl_expr(vm: &str) -> Result { let rest = if vm.starts_with("tr") { &vm[2..] } else if vm.starts_with('y') { &vm[1..] } else { return Err(MangleError::InvalidTranslExpr( "not a translation expression".to_string(), )); }; if rest.is_empty() { return Err(MangleError::InvalidTranslExpr( "expression too short".to_string(), )); } let delimiter = rest.chars().next().unwrap(); let rest = &rest[1..]; // Split by unescaped delimiter let parts = split_by_unescaped_delimiter(rest, delimiter); if parts.len() < 2 { return Err(MangleError::InvalidTranslExpr( "not enough parts".to_string(), )); } let pattern = parts[0].clone(); let replacement = parts[1].clone(); let flags = if parts.len() > 2 && !parts[2].is_empty() { Some(parts[2].clone()) } else { None }; Ok(MangleExpr { kind: MangleExprKind::Transl, pattern, replacement, flags, }) } /// Split a string by an unescaped delimiter fn split_by_unescaped_delimiter(s: &str, delimiter: char) -> Vec { let mut parts = Vec::new(); let mut current = String::new(); let mut escaped = false; for c in s.chars() { if escaped { current.push(c); escaped = false; } else if c == '\\' { current.push(c); escaped = true; } else if c == delimiter { parts.push(current.clone()); current.clear(); } else { current.push(c); } } // Don't forget the last part parts.push(current); parts } /// Apply a mangling expression to a string /// /// # Examples /// /// ``` /// use debian_watch::mangle::apply_mangle; /// /// let result = apply_mangle("s/foo/bar/", "foo baz foo").unwrap(); /// assert_eq!(result, "bar baz foo"); /// /// let result = apply_mangle("s/foo/bar/g", "foo baz foo").unwrap(); /// assert_eq!(result, "bar baz bar"); /// ``` pub fn apply_mangle(vm: &str, orig: &str) -> Result { let expr = parse_mangle_expr(vm)?; match expr.kind { MangleExprKind::Subst => { let re = Regex::new(&expr.pattern).map_err(|e| MangleError::RegexError(e.to_string()))?; // Check if 'g' flag is present for global replacement let global = expr.flags.as_ref().is_some_and(|f| f.contains('g')); if global { Ok(re.replace_all(orig, expr.replacement.as_str()).to_string()) } else { Ok(re.replace(orig, expr.replacement.as_str()).to_string()) } } MangleExprKind::Transl => { // Translation: character-by-character replacement apply_translation(&expr.pattern, &expr.replacement, orig) } } } /// Apply a mangling expression with template variable substitution /// /// This first substitutes template variables like @PACKAGE@ and @COMPONENT@ in the /// mangle expression itself, then applies the mangle to the input string. /// /// # Examples /// /// ``` /// use debian_watch::mangle::apply_mangle_with_subst; /// /// let result = apply_mangle_with_subst( /// "s/@PACKAGE@/bar/", /// "foo baz foo", /// || "foo".to_string(), /// || String::new() /// ).unwrap(); /// assert_eq!(result, "bar baz foo"); /// ``` pub fn apply_mangle_with_subst( vm: &str, orig: &str, package: impl FnOnce() -> String, component: impl FnOnce() -> String, ) -> Result { // Apply template substitution to the mangle expression let substituted_vm = crate::subst::subst(vm, package, component); // Apply the mangle expression apply_mangle(&substituted_vm, orig) } /// Apply character-by-character translation fn apply_translation(pattern: &str, replacement: &str, orig: &str) -> Result { // Expand ranges like a-z let from_chars = expand_char_range(pattern); let to_chars = expand_char_range(replacement); if from_chars.len() != to_chars.len() { return Err(MangleError::InvalidTranslExpr( "pattern and replacement must have same length".to_string(), )); } let mut result = String::new(); for c in orig.chars() { if let Some(pos) = from_chars.iter().position(|&fc| fc == c) { result.push(to_chars[pos]); } else { result.push(c); } } Ok(result) } /// Expand character ranges like a-z to actual characters fn expand_char_range(s: &str) -> Vec { let mut result = Vec::new(); let chars: Vec = s.chars().collect(); let mut i = 0; while i < chars.len() { if i + 2 < chars.len() && chars[i + 1] == '-' { // Range found let start = chars[i]; let end = chars[i + 2]; for c in (start as u32)..=(end as u32) { if let Some(ch) = char::from_u32(c) { result.push(ch); } } i += 3; } else { result.push(chars[i]); i += 1; } } result } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_subst_expr() { let expr = parse_subst_expr("s/foo/bar/g").unwrap(); assert_eq!(expr.pattern, "foo"); assert_eq!(expr.replacement, "bar"); assert_eq!(expr.flags.as_deref(), Some("g")); let expr = parse_subst_expr("s|foo|bar|").unwrap(); assert_eq!(expr.pattern, "foo"); assert_eq!(expr.replacement, "bar"); assert_eq!(expr.flags, None); let expr = parse_subst_expr("s#a/b#c/d#").unwrap(); assert_eq!(expr.pattern, "a/b"); assert_eq!(expr.replacement, "c/d"); } #[test] fn test_parse_transl_expr() { let expr = parse_transl_expr("tr/a-z/A-Z/").unwrap(); assert_eq!(expr.pattern, "a-z"); assert_eq!(expr.replacement, "A-Z"); let expr = parse_transl_expr("y/abc/xyz/").unwrap(); assert_eq!(expr.pattern, "abc"); assert_eq!(expr.replacement, "xyz"); } #[test] fn test_apply_mangle_subst() { let result = apply_mangle("s/foo/bar/", "foo baz foo").unwrap(); assert_eq!(result, "bar baz foo"); let result = apply_mangle("s/foo/bar/g", "foo baz foo").unwrap(); assert_eq!(result, "bar baz bar"); // Test with regex let result = apply_mangle("s/[0-9]+/X/g", "a1b2c3").unwrap(); assert_eq!(result, "aXbXcX"); } #[test] fn test_apply_mangle_transl() { let result = apply_mangle("tr/a-z/A-Z/", "hello").unwrap(); assert_eq!(result, "HELLO"); let result = apply_mangle("y/abc/xyz/", "aabbcc").unwrap(); assert_eq!(result, "xxyyzz"); } #[test] fn test_expand_char_range() { let result = expand_char_range("a-z"); assert_eq!(result.len(), 26); assert_eq!(result[0], 'a'); assert_eq!(result[25], 'z'); let result = expand_char_range("a-c"); assert_eq!(result, vec!['a', 'b', 'c']); let result = expand_char_range("abc"); assert_eq!(result, vec!['a', 'b', 'c']); } #[test] fn test_split_by_unescaped_delimiter() { let result = split_by_unescaped_delimiter("foo/bar/baz", '/'); assert_eq!(result, vec!["foo", "bar", "baz"]); let result = split_by_unescaped_delimiter("foo\\/bar/baz", '/'); assert_eq!(result, vec!["foo\\/bar", "baz"]); } #[test] fn test_real_world_examples() { // Example from Python code: dversionmangle=s/\+ds// let result = apply_mangle(r"s/\+ds//", "1.0+ds").unwrap(); assert_eq!(result, "1.0"); // Example: filenamemangle let result = apply_mangle( r"s/.+\/v?(\d\S+)\.tar\.gz/syncthing-gtk-$1.tar.gz/", "https://github.com/syncthing/syncthing-gtk/archive/v0.9.4.tar.gz", ) .unwrap(); assert_eq!(result, "syncthing-gtk-0.9.4.tar.gz"); } #[test] fn test_apply_mangle_with_subst_package() { // Template substitution happens in the mangle expression, so @PACKAGE@ // becomes "mypackage" in the pattern, then it matches against the input let result = apply_mangle_with_subst( "s/@PACKAGE@/replaced/", "foo mypackage bar", || "mypackage".to_string(), || String::new(), ) .unwrap(); assert_eq!(result, "foo replaced bar"); } #[test] fn test_apply_mangle_with_subst_component() { // Template substitution happens in the mangle expression, so @COMPONENT@ // becomes "upstream" in the pattern, then it matches against the input let result = apply_mangle_with_subst( "s/@COMPONENT@/replaced/g", "upstream foo upstream", || unreachable!(), || "upstream".to_string(), ) .unwrap(); assert_eq!(result, "replaced foo replaced"); } #[test] fn test_apply_mangle_with_subst_filenamemangle() { // Example: filenamemangle with @PACKAGE@ template let result = apply_mangle_with_subst( r"s/.+\/v?(\d\S+)\.tar\.gz/@PACKAGE@-$1.tar.gz/", "https://github.com/example/repo/archive/v0.9.4.tar.gz", || "myapp".to_string(), || String::new(), ) .unwrap(); assert_eq!(result, "myapp-0.9.4.tar.gz"); } #[test] fn test_apply_mangle_with_subst_no_templates() { // Ensure it still works when no templates are present let result = apply_mangle_with_subst( "s/foo/bar/g", "foo baz foo", || unreachable!(), || unreachable!(), ) .unwrap(); assert_eq!(result, "bar baz bar"); } } debian-watch-0.4.12/src/parse.rs000064400000000000000000001303451046102023000145130ustar 00000000000000#![cfg(any(feature = "linebased", feature = "deb822"))] //! Format detection and parsing for watch files //! //! This module is only available when at least one of the `linebased` or `deb822` features is enabled. /// Error type for parsing watch files #[derive(Debug)] pub enum ParseError { /// Error parsing line-based format (v1-4) #[cfg(feature = "linebased")] LineBased(crate::linebased::ParseError), /// Error parsing deb822 format (v5) #[cfg(feature = "deb822")] Deb822(crate::deb822::ParseError), /// Could not detect version UnknownVersion, /// Feature not enabled FeatureNotEnabled(String), } impl std::fmt::Display for ParseError { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { #[cfg(feature = "linebased")] ParseError::LineBased(e) => write!(f, "{}", e), #[cfg(feature = "deb822")] ParseError::Deb822(e) => write!(f, "{}", e), ParseError::UnknownVersion => write!(f, "Could not detect watch file version"), ParseError::FeatureNotEnabled(msg) => write!(f, "{}", msg), } } } impl std::error::Error for ParseError {} /// Detected watch file format #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WatchFileVersion { /// Line-based format (versions 1-4) LineBased(u32), /// Deb822 format (version 5) Deb822, } /// Detect the version/format of a watch file from its content /// /// This function examines the content to determine if it's a line-based /// format (v1-4) or deb822 format (v5). /// /// After detecting the version, you can either: /// - Use the `parse()` function to automatically parse and return a `ParsedWatchFile` /// - Parse directly: `content.parse::()` /// /// # Examples /// /// ``` /// use debian_watch::parse::{detect_version, WatchFileVersion}; /// /// let v4_content = "version=4\nhttps://example.com/ .*.tar.gz"; /// assert_eq!(detect_version(v4_content), Some(WatchFileVersion::LineBased(4))); /// /// let v5_content = "Version: 5\n\nSource: https://example.com/"; /// assert_eq!(detect_version(v5_content), Some(WatchFileVersion::Deb822)); /// ``` pub fn detect_version(content: &str) -> Option { let trimmed = content.trim_start(); // Check if it starts with RFC822-style "Version: 5" if trimmed.starts_with("Version:") || trimmed.starts_with("version:") { // Try to extract the version number if let Some(first_line) = trimmed.lines().next() { if let Some(colon_pos) = first_line.find(':') { let version_str = first_line[colon_pos + 1..].trim(); if version_str == "5" { return Some(WatchFileVersion::Deb822); } } } } // Otherwise, it's line-based format // Try to detect the version from "version=N" line for line in trimmed.lines() { let line = line.trim(); // Skip comments and blank lines if line.starts_with('#') || line.is_empty() { continue; } // Check for version=N if line.starts_with("version=") || line.starts_with("version =") { let version_part = if line.starts_with("version=") { &line[8..] } else { &line[9..] }; if let Ok(version) = version_part.trim().parse::() { return Some(WatchFileVersion::LineBased(version)); } } // If we hit a non-comment, non-version line, assume default version break; } // Default to version 1 for line-based format Some(WatchFileVersion::LineBased(crate::DEFAULT_VERSION)) } /// Parsed watch file that can be either line-based or deb822 format #[derive(Debug, Clone)] pub enum ParsedWatchFile { /// Line-based watch file (v1-4) #[cfg(feature = "linebased")] LineBased(crate::linebased::WatchFile), /// Deb822 watch file (v5) #[cfg(feature = "deb822")] Deb822(crate::deb822::WatchFile), } /// Parsed watch entry that can be either line-based or deb822 format #[derive(Debug, Clone)] pub enum ParsedEntry { /// Line-based entry (v1-4) #[cfg(feature = "linebased")] LineBased(crate::linebased::Entry), /// Deb822 entry (v5) #[cfg(feature = "deb822")] Deb822(crate::deb822::Entry), } impl ParsedWatchFile { /// Capture an independent snapshot of this watch file. /// /// The returned value shares the underlying immutable green-node data /// with `self` at the time of the call, but lives in its own mutable /// tree: subsequent mutations to `self` do not propagate to the snapshot. /// Pair with [`Self::tree_eq`] to detect later mutations. pub fn snapshot(&self) -> Self { match self { #[cfg(feature = "linebased")] ParsedWatchFile::LineBased(wf) => ParsedWatchFile::LineBased(wf.snapshot()), #[cfg(feature = "deb822")] ParsedWatchFile::Deb822(wf) => ParsedWatchFile::Deb822(wf.snapshot()), } } /// Returns true iff the syntax trees of `self` and `other` are /// value-equal. An O(1) pointer-identity fast path makes this free for /// trees that still share state with a recent [`Self::snapshot`]. /// Mismatched variants are never considered equal. pub fn tree_eq(&self, other: &Self) -> bool { match (self, other) { #[cfg(feature = "linebased")] (ParsedWatchFile::LineBased(a), ParsedWatchFile::LineBased(b)) => a.tree_eq(b), #[cfg(feature = "deb822")] (ParsedWatchFile::Deb822(a), ParsedWatchFile::Deb822(b)) => a.tree_eq(b), #[allow(unreachable_patterns)] _ => false, } } /// Create a new empty watch file with the specified version. /// /// - For version 5, creates a deb822-format watch file (requires `deb822` feature) /// - For versions 1-4, creates a line-based watch file (requires `linebased` feature) /// /// # Examples /// /// ``` /// # #[cfg(feature = "deb822")] /// # { /// use debian_watch::parse::ParsedWatchFile; /// /// let wf = ParsedWatchFile::new(5).unwrap(); /// assert_eq!(wf.version(), 5); /// # } /// ``` pub fn new(version: u32) -> Result { match version { #[cfg(feature = "deb822")] 5 => Ok(ParsedWatchFile::Deb822(crate::deb822::WatchFile::new())), #[cfg(not(feature = "deb822"))] 5 => Err(ParseError::FeatureNotEnabled( "deb822 feature required for v5 format".to_string(), )), #[cfg(feature = "linebased")] v @ 1..=4 => Ok(ParsedWatchFile::LineBased( crate::linebased::WatchFile::new(Some(v)), )), #[cfg(not(feature = "linebased"))] v @ 1..=4 => Err(ParseError::FeatureNotEnabled(format!( "linebased feature required for v{} format", v ))), v => Err(ParseError::FeatureNotEnabled(format!( "unsupported watch file version: {}", v ))), } } /// Get the version of the watch file pub fn version(&self) -> u32 { match self { #[cfg(feature = "linebased")] ParsedWatchFile::LineBased(wf) => wf.version(), #[cfg(feature = "deb822")] ParsedWatchFile::Deb822(wf) => wf.version(), } } /// Get an iterator over entries as ParsedEntry enum pub fn entries(&self) -> impl Iterator + '_ { // We need to collect because we can't return different iterator types from match arms let entries: Vec<_> = match self { #[cfg(feature = "linebased")] ParsedWatchFile::LineBased(wf) => wf.entries().map(ParsedEntry::LineBased).collect(), #[cfg(feature = "deb822")] ParsedWatchFile::Deb822(wf) => wf.entries().map(ParsedEntry::Deb822).collect(), }; entries.into_iter() } /// Add a new entry to the watch file and return it. /// /// For v5 (deb822) watch files, this adds a new paragraph with Source and Matching-Pattern fields. /// For v1-4 (line-based) watch files, this adds a new entry line. /// /// Returns a `ParsedEntry` that can be used to query or modify the entry. /// /// # Examples /// /// ``` /// # #[cfg(feature = "deb822")] /// # { /// use debian_watch::parse::ParsedWatchFile; /// use debian_watch::WatchOption; /// /// let mut wf = ParsedWatchFile::new(5).unwrap(); /// let mut entry = wf.add_entry("https://github.com/foo/bar/tags", ".*/v?([\\d.]+)\\.tar\\.gz"); /// entry.set_option(WatchOption::Component("upstream".to_string())); /// # } /// ``` pub fn add_entry(&mut self, source: &str, matching_pattern: &str) -> ParsedEntry { match self { #[cfg(feature = "linebased")] ParsedWatchFile::LineBased(wf) => { let entry = crate::linebased::EntryBuilder::new(source) .matching_pattern(matching_pattern) .build(); let added_entry = wf.add_entry(entry); ParsedEntry::LineBased(added_entry) } #[cfg(feature = "deb822")] ParsedWatchFile::Deb822(wf) => { let added_entry = wf.add_entry(source, matching_pattern); ParsedEntry::Deb822(added_entry) } } } /// Byte range of the version declaration. /// /// In line-based files this is the `version=N` directive on the /// first line; in deb822 files it's the `Version:` entry on the /// header paragraph. Returns `None` when the file has no version /// declaration (legal for v1 line-based files; unusual for v5). pub fn version_range(&self) -> Option { match self { #[cfg(feature = "linebased")] ParsedWatchFile::LineBased(wf) => wf.version_node().map(|v| v.text_range()), #[cfg(feature = "deb822")] ParsedWatchFile::Deb822(wf) => { // The header paragraph in v5 carries `Version:`; it's // the first paragraph in the deb822 document. let first = wf.as_deb822().paragraphs().next()?; first.get_entry("Version").map(|e| e.text_range()) } } } } impl ParsedEntry { /// Get the URL/Source of the entry pub fn url(&self) -> String { match self { #[cfg(feature = "linebased")] ParsedEntry::LineBased(e) => e.url(), #[cfg(feature = "deb822")] ParsedEntry::Deb822(e) => e.source().unwrap_or(None).unwrap_or_default(), } } /// Get the matching pattern pub fn matching_pattern(&self) -> Option { match self { #[cfg(feature = "linebased")] ParsedEntry::LineBased(e) => e.matching_pattern(), #[cfg(feature = "deb822")] ParsedEntry::Deb822(e) => e.matching_pattern().unwrap_or(None), } } /// Get a generic option/field value by key (case-insensitive) /// /// This handles the difference between line-based format (lowercase keys) /// and deb822 format (capitalized keys). It tries the key as-is first, /// then tries with the first letter capitalized. pub fn get_option(&self, key: &str) -> Option { match self { #[cfg(feature = "linebased")] ParsedEntry::LineBased(e) => e.get_option(key), #[cfg(feature = "deb822")] ParsedEntry::Deb822(e) => { // Try exact match first, then try capitalized e.get_field(key).or_else(|| { let mut chars = key.chars(); if let Some(first) = chars.next() { let capitalized = first.to_uppercase().chain(chars).collect::(); e.get_field(&capitalized) } else { None } }) } } } /// Check if an option/field is set (case-insensitive) pub fn has_option(&self, key: &str) -> bool { self.get_option(key).is_some() } /// Byte range of the source URL within the buffer. /// /// In line-based format this covers the URL token; in deb822 format /// it covers the `Source:` (or `URL:`) entry as a whole — key, /// separator, and value. Returns `None` when the entry has no /// recognisable source. pub fn url_range(&self) -> Option { match self { #[cfg(feature = "linebased")] ParsedEntry::LineBased(e) => e.url_node().map(|n| n.text_range()), #[cfg(feature = "deb822")] ParsedEntry::Deb822(e) => deb822_field_range(e.as_deb822(), &["Source", "URL"]), } } /// Byte range of the matching-pattern within the buffer. /// /// Returns `None` when the entry has no matching pattern (either /// not yet set, or the entry is a template). pub fn matching_pattern_range(&self) -> Option { match self { #[cfg(feature = "linebased")] ParsedEntry::LineBased(e) => e.matching_pattern_node().map(|n| n.text_range()), #[cfg(feature = "deb822")] ParsedEntry::Deb822(e) => deb822_field_range(e.as_deb822(), &["Matching-Pattern"]), } } /// Byte range of the named option's `key=value` pair (line-based) /// or `Key: value` entry (deb822). /// /// `key` is matched case-insensitively, mirroring `get_option`. /// Returns `None` if the option is unset. pub fn option_range(&self, key: &str) -> Option { match self { #[cfg(feature = "linebased")] ParsedEntry::LineBased(e) => { let list = e.option_list()?; let opt = list.find_option(key)?; Some(opt.text_range()) } #[cfg(feature = "deb822")] ParsedEntry::Deb822(e) => { // Try the key as-is, then capitalised — same shape as // `get_option`, since deb822 uses `Component` / // `Mode` / `Pgpsigurlmangle` while line-based uses // lowercase. if let Some(r) = deb822_field_range(e.as_deb822(), &[key]) { return Some(r); } let mut chars = key.chars(); if let Some(first) = chars.next() { let capitalized = first.to_uppercase().chain(chars).collect::(); deb822_field_range(e.as_deb822(), &[capitalized.as_str()]) } else { None } } } } /// Byte range of the version-policy / `version=...` part of the /// entry, in line-based files. Returns `None` when not set, or when /// this is a deb822 entry (per-file `Version:` lives on the header /// paragraph, not on individual entries — use /// [`ParsedWatchFile::version_range`] for that). pub fn version_policy_range(&self) -> Option { match self { #[cfg(feature = "linebased")] ParsedEntry::LineBased(e) => e.version_node().map(|n| n.text_range()), #[cfg(feature = "deb822")] ParsedEntry::Deb822(_) => None, } } /// Byte range of the `Template:` field in this entry, when the /// entry uses one. Templates are a v5 (deb822) feature only; /// line-based entries always return `None`. pub fn template_range(&self) -> Option { match self { #[cfg(feature = "linebased")] ParsedEntry::LineBased(_) => None, #[cfg(feature = "deb822")] ParsedEntry::Deb822(e) => deb822_field_range(e.as_deb822(), &["Template"]), } } /// Template kind for this entry (e.g. `"GitHub"`, `"PyPI"`, /// `"CRAN"`), if the entry uses one. Line-based entries always /// return `None`. pub fn template_kind(&self) -> Option { match self { #[cfg(feature = "linebased")] ParsedEntry::LineBased(_) => None, #[cfg(feature = "deb822")] ParsedEntry::Deb822(e) => e.as_deb822().get("Template"), } } /// Get the script pub fn script(&self) -> Option { self.get_option("script") } /// Get the component name (empty for main paragraph) pub fn component(&self) -> Option { self.get_option("component") } /// Format the URL with package and component substitution pub fn format_url( &self, package: impl FnOnce() -> String, component: impl FnOnce() -> String, ) -> Result { crate::subst::subst(&self.url(), package, component).parse() } /// Get the user agent pub fn user_agent(&self) -> Option { self.get_option("user-agent") } /// Get the pagemangle option pub fn pagemangle(&self) -> Option { self.get_option("pagemangle") } /// Get the uversionmangle option pub fn uversionmangle(&self) -> Option { self.get_option("uversionmangle") } /// Get the downloadurlmangle option pub fn downloadurlmangle(&self) -> Option { self.get_option("downloadurlmangle") } /// Get the pgpsigurlmangle option pub fn pgpsigurlmangle(&self) -> Option { self.get_option("pgpsigurlmangle") } /// Get the filenamemangle option pub fn filenamemangle(&self) -> Option { self.get_option("filenamemangle") } /// Get the oversionmangle option pub fn oversionmangle(&self) -> Option { self.get_option("oversionmangle") } /// Get the searchmode, with default fallback pub fn searchmode(&self) -> crate::types::SearchMode { self.get_option("searchmode") .and_then(|s| s.parse().ok()) .unwrap_or_default() } /// Set an option/field value using a WatchOption enum. /// /// For v5 (deb822) entries, this sets a field in the paragraph. /// For v1-4 (line-based) entries, this sets an option in the opts= list. /// /// # Examples /// /// ``` /// # #[cfg(feature = "linebased")] /// # { /// use debian_watch::parse::ParsedWatchFile; /// use debian_watch::{WatchOption, Compression}; /// /// let mut wf = ParsedWatchFile::new(4).unwrap(); /// let mut entry = wf.add_entry("https://github.com/foo/bar/tags", ".*/v?([\\d.]+)\\.tar\\.gz"); /// entry.set_option(WatchOption::Component("upstream".to_string())); /// entry.set_option(WatchOption::Compression(Compression::Xz)); /// assert_eq!(entry.get_option("component"), Some("upstream".to_string())); /// assert_eq!(entry.get_option("compression"), Some("xz".to_string())); /// # } /// ``` pub fn set_option(&mut self, option: crate::types::WatchOption) { match self { #[cfg(feature = "linebased")] ParsedEntry::LineBased(e) => { e.set_option(option); } #[cfg(feature = "deb822")] ParsedEntry::Deb822(e) => { e.set_option(option); } } } /// Set the URL/Source of the entry /// /// # Examples /// /// ``` /// # #[cfg(feature = "linebased")] /// # { /// use debian_watch::parse::ParsedWatchFile; /// /// let mut wf = ParsedWatchFile::new(4).unwrap(); /// let mut entry = wf.add_entry("https://github.com/foo/bar/tags", ".*/v?([\\d.]+)\\.tar\\.gz"); /// entry.set_url("https://github.com/foo/bar/releases"); /// assert_eq!(entry.url(), "https://github.com/foo/bar/releases"); /// # } /// ``` pub fn set_url(&mut self, url: &str) { match self { #[cfg(feature = "linebased")] ParsedEntry::LineBased(e) => e.set_url(url), #[cfg(feature = "deb822")] ParsedEntry::Deb822(e) => e.set_source(url), } } /// Set the matching pattern of the entry /// /// # Examples /// /// ``` /// # #[cfg(feature = "linebased")] /// # { /// use debian_watch::parse::ParsedWatchFile; /// /// let mut wf = ParsedWatchFile::new(4).unwrap(); /// let mut entry = wf.add_entry("https://github.com/foo/bar/tags", ".*/v?([\\d.]+)\\.tar\\.gz"); /// entry.set_matching_pattern(".*/release-([\\d.]+)\\.tar\\.gz"); /// assert_eq!(entry.matching_pattern(), Some(".*/release-([\\d.]+)\\.tar\\.gz".to_string())); /// # } /// ``` pub fn set_matching_pattern(&mut self, pattern: &str) { match self { #[cfg(feature = "linebased")] ParsedEntry::LineBased(e) => e.set_matching_pattern(pattern), #[cfg(feature = "deb822")] ParsedEntry::Deb822(e) => e.set_matching_pattern(pattern), } } /// Get the line number (0-indexed) where this entry starts /// /// For line-based formats (v1-4), this returns the actual line number in the file. /// For deb822 format (v5), this returns the line where the paragraph starts. /// /// # Examples /// /// ``` /// # #[cfg(feature = "linebased")] /// # { /// use debian_watch::parse::parse; /// /// let content = "version=4\nhttps://example.com/ .*.tar.gz\nhttps://example2.com/ .*.tar.gz"; /// let wf = parse(content).unwrap(); /// let entries: Vec<_> = wf.entries().collect(); /// assert_eq!(entries[0].line(), 1); // Second line (0-indexed) /// assert_eq!(entries[1].line(), 2); // Third line (0-indexed) /// # } /// ``` pub fn line(&self) -> usize { match self { #[cfg(feature = "linebased")] ParsedEntry::LineBased(e) => e.line(), #[cfg(feature = "deb822")] ParsedEntry::Deb822(e) => e.line(), } } /// Remove/delete an option from the entry /// /// For v5 (deb822) entries, this removes a field from the paragraph. /// For v1-4 (line-based) entries, this removes an option from the opts= list. /// If this is the last option in a line-based entry, the entire opts= declaration is removed. /// /// # Examples /// /// ``` /// # #[cfg(feature = "linebased")] /// # { /// use debian_watch::parse::ParsedWatchFile; /// use debian_watch::WatchOption; /// /// let mut wf = ParsedWatchFile::new(4).unwrap(); /// let mut entry = wf.add_entry("https://github.com/foo/bar/tags", ".*/v?([\\d.]+)\\.tar\\.gz"); /// entry.set_option(WatchOption::Compression(debian_watch::Compression::Xz)); /// assert!(entry.has_option("compression")); /// entry.remove_option(WatchOption::Compression(debian_watch::Compression::Xz)); /// assert!(!entry.has_option("compression")); /// # } /// ``` pub fn remove_option(&mut self, option: crate::types::WatchOption) { match self { #[cfg(feature = "linebased")] ParsedEntry::LineBased(e) => e.del_opt(option), #[cfg(feature = "deb822")] ParsedEntry::Deb822(e) => e.delete_option(option), } } /// Retrieve the mode of the watch file entry. /// /// Returns the mode with default fallback to `Mode::LWP` if not specified. /// Returns an error if the mode value is invalid. /// /// # Examples /// /// ``` /// # #[cfg(feature = "linebased")] /// # { /// use debian_watch::parse::ParsedWatchFile; /// use debian_watch::{WatchOption, Mode}; /// /// let mut wf = ParsedWatchFile::new(4).unwrap(); /// let mut entry = wf.add_entry("https://github.com/foo/bar/tags", ".*/v?([\\d.]+)\\.tar\\.gz"); /// /// // Default mode is LWP /// assert_eq!(entry.mode().unwrap(), Mode::LWP); /// /// // Set git mode /// entry.set_option(WatchOption::Mode(Mode::Git)); /// assert_eq!(entry.mode().unwrap(), Mode::Git); /// # } /// ``` pub fn mode(&self) -> Result { match self { #[cfg(feature = "linebased")] ParsedEntry::LineBased(e) => e.try_mode(), #[cfg(feature = "deb822")] ParsedEntry::Deb822(e) => e.mode(), } } } /// Look up the byte range of a deb822 entry by trying each name in /// `names` in order. Returns the first match's range. Used by the /// watch-file range helpers to handle aliased fields (`Source` vs /// `URL`) without spelling out two lookups at every call site. #[cfg(feature = "deb822")] fn deb822_field_range( paragraph: &deb822_lossless::Paragraph, names: &[&str], ) -> Option { for name in names { if let Some(entry) = paragraph.get_entry(name) { return Some(entry.text_range()); } } None } impl std::fmt::Display for ParsedWatchFile { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { #[cfg(feature = "linebased")] ParsedWatchFile::LineBased(wf) => write!(f, "{}", wf), #[cfg(feature = "deb822")] ParsedWatchFile::Deb822(wf) => write!(f, "{}", wf), } } } /// Parse a watch file with automatic format detection /// /// This function detects whether the input is line-based (v1-4) or /// deb822 format (v5) and parses it accordingly, returning a unified /// ParsedWatchFile enum. /// /// # Examples /// /// ``` /// # #[cfg(feature = "linebased")] /// # { /// use debian_watch::parse::parse; /// /// let content = "version=4\nhttps://example.com/ .*.tar.gz"; /// let parsed = parse(content).unwrap(); /// assert_eq!(parsed.version(), 4); /// # } /// ``` pub fn parse(content: &str) -> Result { let version = detect_version(content).ok_or(ParseError::UnknownVersion)?; match version { #[cfg(feature = "linebased")] WatchFileVersion::LineBased(_v) => { let wf: crate::linebased::WatchFile = content.parse().map_err(ParseError::LineBased)?; Ok(ParsedWatchFile::LineBased(wf)) } #[cfg(not(feature = "linebased"))] WatchFileVersion::LineBased(_v) => Err(ParseError::FeatureNotEnabled( "linebased feature required for v1-4 formats".to_string(), )), #[cfg(feature = "deb822")] WatchFileVersion::Deb822 => { let wf: crate::deb822::WatchFile = content.parse().map_err(ParseError::Deb822)?; Ok(ParsedWatchFile::Deb822(wf)) } #[cfg(not(feature = "deb822"))] WatchFileVersion::Deb822 => Err(ParseError::FeatureNotEnabled( "deb822 feature required for v5 format".to_string(), )), } } #[cfg(test)] mod tests { use super::*; #[test] fn test_detect_version_v1_default() { let content = "https://example.com/ .*.tar.gz"; assert_eq!( detect_version(content), Some(WatchFileVersion::LineBased(1)) ); } #[test] fn test_detect_version_v4() { let content = "version=4\nhttps://example.com/ .*.tar.gz"; assert_eq!( detect_version(content), Some(WatchFileVersion::LineBased(4)) ); } #[test] fn test_detect_version_v4_with_spaces() { let content = "version = 4\nhttps://example.com/ .*.tar.gz"; assert_eq!( detect_version(content), Some(WatchFileVersion::LineBased(4)) ); } #[test] fn test_detect_version_v5() { let content = "Version: 5\n\nSource: https://example.com/"; assert_eq!(detect_version(content), Some(WatchFileVersion::Deb822)); } #[test] fn test_detect_version_v5_lowercase() { let content = "version: 5\n\nSource: https://example.com/"; assert_eq!(detect_version(content), Some(WatchFileVersion::Deb822)); } #[test] fn test_detect_version_with_leading_comments() { let content = "# This is a comment\nversion=4\nhttps://example.com/ .*.tar.gz"; assert_eq!( detect_version(content), Some(WatchFileVersion::LineBased(4)) ); } #[test] fn test_detect_version_with_leading_whitespace() { let content = " \n version=3\nhttps://example.com/ .*.tar.gz"; assert_eq!( detect_version(content), Some(WatchFileVersion::LineBased(3)) ); } #[test] fn test_detect_version_v2() { let content = "version=2\nhttps://example.com/ .*.tar.gz"; assert_eq!( detect_version(content), Some(WatchFileVersion::LineBased(2)) ); } #[cfg(feature = "linebased")] #[test] fn test_parse_linebased() { let content = "version=4\nhttps://example.com/ .*.tar.gz"; let parsed = parse(content).unwrap(); assert_eq!(parsed.version(), 4); } #[cfg(feature = "deb822")] #[test] fn test_parse_deb822() { let content = "Version: 5\n\nSource: https://example.com/\nMatching-Pattern: .*.tar.gz"; let parsed = parse(content).unwrap(); assert_eq!(parsed.version(), 5); } #[cfg(all(feature = "linebased", feature = "deb822"))] #[test] fn test_parse_both_formats() { // Test v4 let v4_content = "version=4\nhttps://example.com/ .*.tar.gz"; let v4_parsed = parse(v4_content).unwrap(); assert_eq!(v4_parsed.version(), 4); // Test v5 let v5_content = "Version: 5\n\nSource: https://example.com/\nMatching-Pattern: .*.tar.gz"; let v5_parsed = parse(v5_content).unwrap(); assert_eq!(v5_parsed.version(), 5); } #[cfg(feature = "linebased")] #[test] fn test_parse_roundtrip() { let content = "version=4\n# Comment\nhttps://example.com/ .*.tar.gz"; let parsed = parse(content).unwrap(); let output = parsed.to_string(); // Parse again let reparsed = parse(&output).unwrap(); assert_eq!(reparsed.version(), 4); } #[cfg(feature = "deb822")] #[test] fn test_parsed_watch_file_new_v5() { let wf = ParsedWatchFile::new(5).unwrap(); assert_eq!(wf.version(), 5); assert_eq!(wf.entries().count(), 0); } #[cfg(feature = "linebased")] #[test] fn test_parsed_watch_file_new_v4() { let wf = ParsedWatchFile::new(4).unwrap(); assert_eq!(wf.version(), 4); assert_eq!(wf.entries().count(), 0); } #[cfg(feature = "deb822")] #[test] fn test_parsed_watch_file_add_entry_v5() { let mut wf = ParsedWatchFile::new(5).unwrap(); let mut entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz"); assert_eq!(wf.entries().count(), 1); assert_eq!(entry.url(), "https://github.com/foo/bar/tags"); assert_eq!( entry.matching_pattern(), Some(r".*/v?([\d.]+)\.tar\.gz".to_string()) ); // Test setting options with enum entry.set_option(crate::types::WatchOption::Component("upstream".to_string())); entry.set_option(crate::types::WatchOption::Compression( crate::types::Compression::Xz, )); assert_eq!(entry.get_option("Component"), Some("upstream".to_string())); assert_eq!(entry.get_option("Compression"), Some("xz".to_string())); } #[cfg(feature = "linebased")] #[test] fn test_parsed_watch_file_add_entry_v4() { let mut wf = ParsedWatchFile::new(4).unwrap(); let entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz"); assert_eq!(wf.entries().count(), 1); assert_eq!(entry.url(), "https://github.com/foo/bar/tags"); assert_eq!( entry.matching_pattern(), Some(r".*/v?([\d.]+)\.tar\.gz".to_string()) ); } #[cfg(feature = "deb822")] #[test] fn test_parsed_watch_file_roundtrip_with_add_entry() { let mut wf = ParsedWatchFile::new(5).unwrap(); let mut entry = wf.add_entry( "https://github.com/owner/repo/tags", r".*/v?([\d.]+)\.tar\.gz", ); entry.set_option(crate::types::WatchOption::Compression( crate::types::Compression::Xz, )); let output = wf.to_string(); // Parse again let reparsed = parse(&output).unwrap(); assert_eq!(reparsed.version(), 5); let entries: Vec<_> = reparsed.entries().collect(); assert_eq!(entries.len(), 1); assert_eq!(entries[0].url(), "https://github.com/owner/repo/tags"); assert_eq!(entries[0].get_option("Compression"), Some("xz".to_string())); } #[cfg(feature = "linebased")] #[test] fn test_parsed_entry_set_url_v4() { let mut wf = ParsedWatchFile::new(4).unwrap(); let mut entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz"); assert_eq!(entry.url(), "https://github.com/foo/bar/tags"); entry.set_url("https://github.com/foo/bar/releases"); assert_eq!(entry.url(), "https://github.com/foo/bar/releases"); } #[cfg(feature = "deb822")] #[test] fn test_parsed_entry_set_url_v5() { let mut wf = ParsedWatchFile::new(5).unwrap(); let mut entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz"); assert_eq!(entry.url(), "https://github.com/foo/bar/tags"); entry.set_url("https://github.com/foo/bar/releases"); assert_eq!(entry.url(), "https://github.com/foo/bar/releases"); } #[cfg(feature = "linebased")] #[test] fn test_parsed_entry_set_matching_pattern_v4() { let mut wf = ParsedWatchFile::new(4).unwrap(); let mut entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz"); assert_eq!( entry.matching_pattern(), Some(r".*/v?([\d.]+)\.tar\.gz".to_string()) ); entry.set_matching_pattern(r".*/release-([\d.]+)\.tar\.gz"); assert_eq!( entry.matching_pattern(), Some(r".*/release-([\d.]+)\.tar\.gz".to_string()) ); } #[cfg(feature = "deb822")] #[test] fn test_parsed_entry_set_matching_pattern_v5() { let mut wf = ParsedWatchFile::new(5).unwrap(); let mut entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz"); assert_eq!( entry.matching_pattern(), Some(r".*/v?([\d.]+)\.tar\.gz".to_string()) ); entry.set_matching_pattern(r".*/release-([\d.]+)\.tar\.gz"); assert_eq!( entry.matching_pattern(), Some(r".*/release-([\d.]+)\.tar\.gz".to_string()) ); } #[cfg(feature = "linebased")] #[test] fn test_parsed_entry_line_v4() { let content = "version=4\nhttps://example.com/ .*.tar.gz\nhttps://example2.com/ .*.tar.gz"; let wf = parse(content).unwrap(); let entries: Vec<_> = wf.entries().collect(); assert_eq!(entries[0].line(), 1); // Second line (0-indexed) assert_eq!(entries[1].line(), 2); // Third line (0-indexed) } #[cfg(feature = "deb822")] #[test] fn test_parsed_entry_line_v5() { let content = r#"Version: 5 Source: https://example.com/repo1 Matching-Pattern: .*\.tar\.gz Source: https://example.com/repo2 Matching-Pattern: .*\.tar\.xz "#; let wf = parse(content).unwrap(); let entries: Vec<_> = wf.entries().collect(); assert_eq!(entries[0].line(), 2); // Third line (0-indexed) assert_eq!(entries[1].line(), 5); // Sixth line (0-indexed) } #[cfg(feature = "linebased")] #[test] fn test_url_range_linebased() { let content = "version=4\nhttps://example.com/ .*-([\\d.]+)\\.tar\\.gz\n"; let wf = parse(content).unwrap(); let entry = wf.entries().next().unwrap(); let range = entry.url_range().expect("entry has url"); let start: usize = range.start().into(); let end: usize = range.end().into(); assert_eq!(&content[start..end], "https://example.com/"); } #[cfg(feature = "linebased")] #[test] fn test_matching_pattern_range_linebased() { let content = "version=4\nhttps://example.com/ .*-([\\d.]+)\\.tar\\.gz\n"; let wf = parse(content).unwrap(); let entry = wf.entries().next().unwrap(); let range = entry.matching_pattern_range().expect("has pattern"); let start: usize = range.start().into(); let end: usize = range.end().into(); assert_eq!(&content[start..end], ".*-([\\d.]+)\\.tar\\.gz"); } #[cfg(feature = "linebased")] #[test] fn test_option_range_linebased() { let content = "version=4\nopts=mode=git,pretty=raw https://example.com/ .*\n"; let wf = parse(content).unwrap(); let entry = wf.entries().next().unwrap(); let mode = entry.option_range("mode").expect("mode option"); let start: usize = mode.start().into(); let end: usize = mode.end().into(); assert_eq!(&content[start..end], "mode=git"); let pretty = entry.option_range("pretty").expect("pretty option"); let start: usize = pretty.start().into(); let end: usize = pretty.end().into(); assert_eq!(&content[start..end], "pretty=raw"); assert!(entry.option_range("not-a-real-option").is_none()); } #[cfg(feature = "linebased")] #[test] fn test_version_range_linebased() { let content = "version=4\nhttps://example.com/ .*\n"; let wf = parse(content).unwrap(); let range = wf.version_range().expect("has version"); let start: usize = range.start().into(); let end: usize = range.end().into(); assert_eq!(&content[start..end], "version=4\n"); } #[cfg(feature = "deb822")] #[test] fn test_url_range_deb822() { let content = "Version: 5\n\nSource: https://example.com/foo\nMatching-Pattern: .*\\.tar\\.gz\n"; let wf = parse(content).unwrap(); let entry = wf.entries().next().unwrap(); let range = entry.url_range().expect("has source"); let start: usize = range.start().into(); let end: usize = range.end().into(); // The range covers the whole `Source: ...` entry, ending after // the trailing newline. assert_eq!(&content[start..end], "Source: https://example.com/foo\n"); } #[cfg(feature = "deb822")] #[test] fn test_matching_pattern_range_deb822() { let content = "Version: 5\n\nSource: https://example.com/foo\nMatching-Pattern: v(.+)\\.tar\\.gz\n"; let wf = parse(content).unwrap(); let entry = wf.entries().next().unwrap(); let range = entry.matching_pattern_range().expect("has pattern"); let start: usize = range.start().into(); let end: usize = range.end().into(); assert_eq!(&content[start..end], "Matching-Pattern: v(.+)\\.tar\\.gz\n"); } #[cfg(feature = "deb822")] #[test] fn test_option_range_deb822_lookup_capitalises_key() { // The line-based format uses `mode=git`; deb822 v5 spells the // same option as `Mode: git`. option_range looks up either // case, so callers using the line-based naming convention // still work against v5 files. let content = "Version: 5\n\nSource: https://example.com/foo\nMatching-Pattern: x\nMode: git\n"; let wf = parse(content).unwrap(); let entry = wf.entries().next().unwrap(); let range = entry.option_range("mode").expect("mode field"); let start: usize = range.start().into(); let end: usize = range.end().into(); assert_eq!(&content[start..end], "Mode: git\n"); } #[cfg(feature = "deb822")] #[test] fn test_version_range_deb822() { let content = "Version: 5\n\nSource: https://example.com/foo\nMatching-Pattern: x\n"; let wf = parse(content).unwrap(); let range = wf.version_range().expect("has version"); let start: usize = range.start().into(); let end: usize = range.end().into(); assert_eq!(&content[start..end], "Version: 5\n"); } #[cfg(feature = "deb822")] #[test] fn test_template_range_deb822() { let content = "Version: 5\n\nSource: https://github.com/foo/bar\nTemplate: GitHub\n"; let wf = parse(content).unwrap(); let entry = wf.entries().next().unwrap(); let range = entry.template_range().expect("has template"); let start: usize = range.start().into(); let end: usize = range.end().into(); assert_eq!(&content[start..end], "Template: GitHub\n"); assert_eq!(entry.template_kind(), Some("GitHub".to_string())); } } /// Thread-safe parse result for watch files, suitable for use in Salsa databases. /// /// This wrapper provides a thread-safe interface around the parsed watch file, /// storing either a line-based parse tree or the raw text for deb822 format. /// The underlying lossless parse trees (based on rowan's GreenNode) are thread-safe. #[derive(Clone, PartialEq, Eq)] pub struct Parse { inner: ParseInner, } #[derive(Clone, PartialEq, Eq)] enum ParseInner { #[cfg(feature = "linebased")] LineBased(crate::linebased::Parse), #[cfg(feature = "deb822")] Deb822(deb822_lossless::Parse), } impl Parse { /// Parse a watch file with automatic format detection pub fn parse(text: &str) -> Self { let version = detect_version(text); let inner = match version { #[cfg(feature = "linebased")] Some(WatchFileVersion::LineBased(_)) => { ParseInner::LineBased(crate::linebased::parse_watch_file(text)) } #[cfg(feature = "deb822")] Some(WatchFileVersion::Deb822) => { ParseInner::Deb822(deb822_lossless::Deb822::parse(text)) } #[cfg(not(feature = "linebased"))] Some(WatchFileVersion::LineBased(_)) => { // Fallback to storing text if linebased feature is not enabled #[cfg(feature = "deb822")] { ParseInner::Deb822(deb822_lossless::Deb822::parse(text)) } #[cfg(not(feature = "deb822"))] { panic!("No watch file parsing features enabled") } } #[cfg(not(feature = "deb822"))] Some(WatchFileVersion::Deb822) => { // Fallback to linebased if deb822 feature is not enabled #[cfg(feature = "linebased")] { ParseInner::LineBased(crate::linebased::parse_watch_file(text)) } #[cfg(not(feature = "linebased"))] { panic!("No watch file parsing features enabled") } } None => { // Default to linebased v1 if we can't detect #[cfg(feature = "linebased")] { ParseInner::LineBased(crate::linebased::parse_watch_file(text)) } #[cfg(not(feature = "linebased"))] #[cfg(feature = "deb822")] { ParseInner::Deb822(deb822_lossless::Deb822::parse(text)) } #[cfg(not(any(feature = "linebased", feature = "deb822")))] { panic!("No watch file parsing features enabled") } } }; Parse { inner } } /// Get the parsed watch file pub fn to_watch_file(&self) -> ParsedWatchFile { match &self.inner { #[cfg(feature = "linebased")] ParseInner::LineBased(parse) => ParsedWatchFile::LineBased(parse.tree()), #[cfg(feature = "deb822")] ParseInner::Deb822(parse) => { let deb822 = parse.tree(); ParsedWatchFile::Deb822(crate::deb822::WatchFile::from_deb822(deb822)) } } } /// Get the version of the watch file pub fn version(&self) -> u32 { match &self.inner { #[cfg(feature = "linebased")] ParseInner::LineBased(parse) => parse.tree().version(), #[cfg(feature = "deb822")] ParseInner::Deb822(_) => 5, } } } // Implement Send + Sync since the underlying types are thread-safe // Both variants store GreenNode (thread-safe) via their Parse types unsafe impl Send for Parse {} unsafe impl Sync for Parse {} debian-watch-0.4.12/src/pgp.rs000064400000000000000000000213441046102023000141650ustar 00000000000000//! PGP signature verification support. use sequoia_openpgp as openpgp; use std::io::Read; /// Error type for PGP operations #[derive(Debug)] pub enum PgpError { /// Failed to parse signature SignatureParseError(String), /// Failed to verify signature VerificationError(String), /// IO error IoError(std::io::Error), /// Sequoia error SequoiaError(String), } impl std::fmt::Display for PgpError { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { PgpError::SignatureParseError(s) => write!(f, "signature parse error: {}", s), PgpError::VerificationError(s) => write!(f, "verification error: {}", s), PgpError::IoError(e) => write!(f, "IO error: {}", e), PgpError::SequoiaError(e) => write!(f, "sequoia error: {}", e), } } } impl std::error::Error for PgpError {} impl From for PgpError { fn from(e: std::io::Error) -> Self { PgpError::IoError(e) } } impl From for PgpError { fn from(e: openpgp::Error) -> Self { PgpError::SequoiaError(e.to_string()) } } impl From for PgpError { fn from(e: anyhow::Error) -> Self { PgpError::SequoiaError(e.to_string()) } } /// Common signature file extensions to probe pub const SIGNATURE_EXTENSIONS: &[&str] = &[".asc", ".sig", ".sign", ".gpg"]; /// Result of signature verification #[derive(Debug, Clone, PartialEq, Eq)] pub struct SignatureVerification { /// Whether the signature is cryptographically valid pub valid: bool, /// The fingerprint of the signing key pub fingerprint: Option, /// Error message if verification failed pub error: Option, } impl SignatureVerification { /// Create a successful verification result pub fn valid(fingerprint: String) -> Self { Self { valid: true, fingerprint: Some(fingerprint), error: None, } } /// Create a failed verification result pub fn invalid(error: String) -> Self { Self { valid: false, fingerprint: None, error: Some(error), } } } /// Generate potential signature URLs from a tarball URL /// /// Returns a list of URLs that might contain the detached signature, /// based on common naming conventions. /// /// # Examples /// /// ``` /// use debian_watch::pgp::probe_signature_urls; /// /// let tarball_url = "https://example.com/project-1.0.tar.gz"; /// let sig_urls = probe_signature_urls(tarball_url); /// assert_eq!(sig_urls, vec![ /// "https://example.com/project-1.0.tar.gz.asc", /// "https://example.com/project-1.0.tar.gz.sig", /// "https://example.com/project-1.0.tar.gz.sign", /// "https://example.com/project-1.0.tar.gz.gpg", /// ]); /// ``` pub fn probe_signature_urls(url: &str) -> Vec { SIGNATURE_EXTENSIONS .iter() .map(|ext| format!("{}{}", url, ext)) .collect() } /// Verify a detached PGP signature and extract the key fingerprint /// /// Verifies that the signature correctly signs the data using the provided certificate. /// This performs cryptographic verification but does NOT verify certificate trust or validity. /// The caller is responsible for trust decisions. /// /// # Arguments /// /// * `signature` - The detached signature data (e.g., .asc file contents) /// * `data` - The data that was signed /// * `cert` - The PGP certificate containing the public key /// /// # Returns /// /// * `Ok(fingerprint)` with the signing key's fingerprint if the signature is cryptographically valid /// * `Err(PgpError)` if verification fails or parsing errors occur /// /// # Examples /// /// ```ignore /// use debian_watch::pgp::verify_detached; /// /// let data = b"Hello, world!"; /// let signature = std::fs::read("data.sig")?; /// let cert = std::fs::read("pubkey.asc")?; /// /// match verify_detached(&signature[..], &data[..], &cert[..]) { /// Ok(fingerprint) => println!("Signature valid, key fingerprint: {}", fingerprint), /// Err(e) => eprintln!("Signature verification failed: {}", e), /// } /// ``` pub fn verify_detached(signature: S, data: D, cert: C) -> Result where S: Read + Send + Sync, D: Read + Send + Sync, C: Read + Send + Sync, { use openpgp::parse::stream::*; use openpgp::parse::Parse; use openpgp::policy::StandardPolicy; let p = &StandardPolicy::new(); // Parse the certificate let cert = openpgp::Cert::from_reader(cert) .map_err(|e| PgpError::SignatureParseError(e.to_string()))?; // Create a helper that provides public keys for verification struct Helper<'a> { cert: &'a openpgp::Cert, fingerprint: Option, } impl<'a> VerificationHelper for Helper<'a> { fn get_certs( &mut self, _ids: &[openpgp::KeyHandle], ) -> openpgp::Result> { Ok(vec![self.cert.clone()]) } fn check(&mut self, structure: MessageStructure) -> openpgp::Result<()> { // Check that we have at least one valid signature let mut valid_signature = false; for layer in structure.iter() { match layer { MessageLayer::SignatureGroup { results } => { for result in results { match result { Ok(GoodChecksum { ka, .. }) => { valid_signature = true; // Extract the fingerprint from the key amalgamation self.fingerprint = Some(ka.key().fingerprint().to_hex()); } Err(e) => { eprintln!("Signature verification failed: {}", e); } } } } MessageLayer::Compression { .. } => {} MessageLayer::Encryption { .. } => {} } } if valid_signature { Ok(()) } else { Err(anyhow::anyhow!("No valid signature found")) } } } let helper = Helper { cert: &cert, fingerprint: None, }; // Create a verifier and verify the data let mut verifier = DetachedVerifierBuilder::from_reader(signature)?.with_policy(p, None, helper)?; // In sequoia v2, we verify by calling verify_reader with the data verifier.verify_reader(data)?; // Extract the fingerprint from the helper let fingerprint = verifier .into_helper() .fingerprint .ok_or_else(|| PgpError::VerificationError("No fingerprint found".to_string()))?; Ok(fingerprint) } /// Verify a detached signature from byte slices and extract the key fingerprint /// /// Convenience wrapper around `verify_detached` for in-memory data. /// /// # Examples /// /// ```ignore /// use debian_watch::pgp::verify_detached_bytes; /// /// let data = b"Hello, world!"; /// let signature = include_bytes!("test.sig"); /// let cert = include_bytes!("test_key.asc"); /// /// let fingerprint = verify_detached_bytes(signature, data, cert)?; /// println!("Signature valid, key fingerprint: {}", fingerprint); /// ``` pub fn verify_detached_bytes( signature: &[u8], data: &[u8], cert: &[u8], ) -> Result { verify_detached( std::io::Cursor::new(signature), std::io::Cursor::new(data), std::io::Cursor::new(cert), ) } #[cfg(test)] mod tests { use super::*; #[test] fn test_probe_signature_urls() { let url = "https://example.com/project-1.0.tar.gz"; let sig_urls = probe_signature_urls(url); assert_eq!( sig_urls, vec![ "https://example.com/project-1.0.tar.gz.asc", "https://example.com/project-1.0.tar.gz.sig", "https://example.com/project-1.0.tar.gz.sign", "https://example.com/project-1.0.tar.gz.gpg", ] ); } #[test] fn test_probe_signature_urls_tar_xz() { let url = "https://example.com/release.tar.xz"; let sig_urls = probe_signature_urls(url); assert_eq!( sig_urls, vec![ "https://example.com/release.tar.xz.asc", "https://example.com/release.tar.xz.sig", "https://example.com/release.tar.xz.sign", "https://example.com/release.tar.xz.gpg", ] ); } #[test] fn test_signature_extensions_constant() { assert_eq!(SIGNATURE_EXTENSIONS, &[".asc", ".sig", ".sign", ".gpg"]); } } debian-watch-0.4.12/src/release.rs000064400000000000000000000147771046102023000150330ustar 00000000000000//! Types for representing discovered releases. use debversion::Version; use std::cmp::Ordering; /// A discovered release from an upstream source #[derive(Debug, Clone, PartialEq, Eq)] pub struct Release { /// The version string of the release (after uversionmangle) pub version: String, /// The URL to download the release tarball (after downloadurlmangle) pub url: String, /// Optional URL to the PGP signature file pub pgpsigurl: Option, /// Optional target filename for the downloaded tarball (from filenamemangle) pub target_filename: Option, /// Optional Debian package version (from oversionmangle, e.g., "1.0+dfsg") pub package_version: Option, } impl Release { /// Create a new Release /// /// # Examples /// /// ``` /// use debian_watch::Release; /// /// let release = Release::new("1.0.0", "https://example.com/project-1.0.0.tar.gz", None); /// assert_eq!(release.version, "1.0.0"); /// assert_eq!(release.url, "https://example.com/project-1.0.0.tar.gz"); /// ``` pub fn new( version: impl Into, url: impl Into, pgpsigurl: Option, ) -> Self { Self { version: version.into(), url: url.into(), pgpsigurl, target_filename: None, package_version: None, } } /// Create a new Release with all fields /// /// # Examples /// /// ``` /// use debian_watch::Release; /// /// let release = Release::new_full( /// "1.0.0", /// "https://example.com/project-1.0.0.tar.gz", /// Some("https://example.com/project-1.0.0.tar.gz.asc".to_string()), /// Some("myproject_1.0.0.orig.tar.gz".to_string()), /// Some("1.0.0+dfsg".to_string()), /// ); /// assert_eq!(release.version, "1.0.0"); /// assert_eq!(release.target_filename, Some("myproject_1.0.0.orig.tar.gz".to_string())); /// ``` pub fn new_full( version: impl Into, url: impl Into, pgpsigurl: Option, target_filename: Option, package_version: Option, ) -> Self { Self { version: version.into(), url: url.into(), pgpsigurl, target_filename, package_version, } } /// Download the release tarball (async version) /// /// Downloads the tarball from the release URL. /// Requires the 'discover' feature. /// /// # Examples /// /// ```ignore /// use debian_watch::Release; /// /// # async fn example() -> Result<(), Box> { /// let release = Release::new("1.0.0", "https://example.com/project-1.0.tar.gz", None); /// let data = release.download().await?; /// println!("Downloaded {} bytes", data.len()); /// # Ok(()) /// # } /// ``` #[cfg(feature = "discover")] pub async fn download(&self) -> Result, Box> { let client = reqwest::Client::new(); let response = client.get(&self.url).send().await?; let bytes = response.bytes().await?; Ok(bytes.to_vec()) } /// Download the release tarball (blocking version) /// /// Downloads the tarball from the release URL. /// Requires both 'discover' and 'blocking' features. /// /// # Examples /// /// ```ignore /// use debian_watch::Release; /// /// let release = Release::new("1.0.0", "https://example.com/project-1.0.tar.gz", None); /// let data = release.download_blocking()?; /// println!("Downloaded {} bytes", data.len()); /// ``` #[cfg(all(feature = "discover", feature = "blocking"))] pub fn download_blocking(&self) -> Result, Box> { let client = reqwest::blocking::Client::new(); let response = client.get(&self.url).send()?; let bytes = response.bytes()?; Ok(bytes.to_vec()) } } impl PartialOrd for Release { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } impl Ord for Release { fn cmp(&self, other: &Self) -> Ordering { // Parse versions and compare them match ( self.version.parse::(), other.version.parse::(), ) { (Ok(v1), Ok(v2)) => v1.cmp(&v2), // If parsing fails, fall back to string comparison _ => self.version.cmp(&other.version), } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_release_new() { let release = Release::new("1.0.0", "https://example.com/foo.tar.gz", None); assert_eq!(release.version, "1.0.0"); assert_eq!(release.url, "https://example.com/foo.tar.gz"); assert_eq!(release.pgpsigurl, None); let release = Release::new( "2.0.0", "https://example.com/foo-2.0.0.tar.gz", Some("https://example.com/foo-2.0.0.tar.gz.asc".to_string()), ); assert_eq!(release.version, "2.0.0"); assert_eq!( release.pgpsigurl, Some("https://example.com/foo-2.0.0.tar.gz.asc".to_string()) ); } #[test] fn test_release_ordering() { let r1 = Release::new("1.0.0", "https://example.com/foo-1.0.0.tar.gz", None); let r2 = Release::new("2.0.0", "https://example.com/foo-2.0.0.tar.gz", None); let r3 = Release::new("1.5.0", "https://example.com/foo-1.5.0.tar.gz", None); assert!(r1 < r2); assert!(r2 > r1); assert!(r1 < r3); assert!(r3 < r2); } #[test] fn test_release_ordering_debian_versions() { // Test with Debian version strings let r1 = Release::new("1.0", "https://example.com/foo-1.0.tar.gz", None); let r2 = Release::new("1.0+dfsg", "https://example.com/foo-1.0+dfsg.tar.gz", None); let r3 = Release::new("1.0~rc1", "https://example.com/foo-1.0~rc1.tar.gz", None); // 1.0~rc1 < 1.0 < 1.0+dfsg in Debian version ordering assert!(r3 < r1); assert!(r1 < r2); } #[test] fn test_release_max() { let releases = vec![ Release::new("1.0.0", "https://example.com/foo-1.0.0.tar.gz", None), Release::new("2.0.0", "https://example.com/foo-2.0.0.tar.gz", None), Release::new("1.5.0", "https://example.com/foo-1.5.0.tar.gz", None), ]; let max = releases.iter().max().unwrap(); assert_eq!(max.version, "2.0.0"); } } debian-watch-0.4.12/src/search.rs000064400000000000000000000231561046102023000146470ustar 00000000000000//! Functions for searching web pages for upstream releases. use regex::Regex; use std::io::Read; /// Search for version matches in HTML content /// /// Parses the HTML and searches for links matching the given pattern. /// Returns an iterator of (version, url) tuples. /// /// # Arguments /// /// * `body` - The HTML content to search /// * `matching_pattern` - Regex pattern to match against URLs /// * `base_url` - Base URL for resolving relative links /// /// # Examples /// /// ```ignore /// use debian_watch::search::html_search; /// /// let html = b"Download"; /// let results: Vec<_> = html_search(html, r"project-(\d+\.\d+)\.tar\.gz", "https://example.com/") /// .collect(); /// assert_eq!(results.len(), 1); /// assert_eq!(results[0].0, "1.0"); /// ``` #[cfg(feature = "discover")] pub fn html_search( body: &[u8], matching_pattern: &str, base_url: &str, ) -> Box> { let html = String::from_utf8_lossy(body); let doc = scraper::Html::parse_document(&html); // Check for tag to use as base URL for resolving relative hrefs let base_selector = scraper::Selector::parse("base").unwrap(); let effective_base_url = doc .select(&base_selector) .filter_map(|element| element.value().attr("href")) .next() .unwrap_or(base_url); let base_url_parsed = match url::Url::parse(effective_base_url) { Ok(u) => u, Err(_) => return Box::new(std::iter::empty()), }; let selector = scraper::Selector::parse("a").unwrap(); let re = match Regex::new(matching_pattern) { Ok(r) => r, Err(_) => return Box::new(std::iter::empty()), }; let results: Vec<(String, String)> = doc .select(&selector) .filter_map(move |element| { let href = element.value().attr("href")?; // Match the pattern against the raw href value (as per uscan behavior) if let Some(captures) = re.captures(href) { // Extract the first capture group as the version if let Some(version_match) = captures.get(1) { let version = version_match.as_str().to_string(); // Convert href to absolute URL using proper URL joining // Use base tag href if present, otherwise use page URL let full_url = match base_url_parsed.join(href) { Ok(url) => url.to_string(), Err(_) => return None, }; Some((version, full_url)) } else { None } } else { None } }) .collect(); Box::new(results.into_iter()) } /// Search for version matches in plain text content /// /// Searches the plain text for matches of the given pattern. /// Returns an iterator of (version, url) tuples. /// /// # Arguments /// /// * `body` - The plain text content to search /// * `matching_pattern` - Regex pattern to match /// * `base_url` - Base URL for resolving relative links /// /// # Examples /// /// ``` /// use debian_watch::search::plain_search; /// /// let text = b"project-1.0.tar.gz\nproject-2.0.tar.gz"; /// let results: Vec<_> = plain_search(text, r"project-(\d+\.\d+)\.tar\.gz", "https://example.com/") /// .collect(); /// assert!(results.len() >= 1); /// ``` pub fn plain_search( body: &[u8], matching_pattern: &str, base_url: &str, ) -> Box> { let re = match Regex::new(matching_pattern) { Ok(r) => r, Err(_) => return Box::new(std::iter::empty()), }; let text = String::from_utf8_lossy(body); let base_url_parsed = match url::Url::parse(base_url) { Ok(u) => u, Err(_) => return Box::new(std::iter::empty()), }; let results: Vec<(String, String)> = re .captures_iter(&text) .filter_map(|captures| { // Extract the first capture group as the version if let Some(version_match) = captures.get(1) { let version = version_match.as_str().to_string(); // Use capture group 0 (full match) for constructing the URL let matched = captures.get(0).unwrap().as_str(); // Convert matched text to absolute URL using proper URL joining let full_url = if matched.starts_with("http://") || matched.starts_with("https://") { // Already absolute matched.to_string() } else { // Relative - use proper URL joining match base_url_parsed.join(matched) { Ok(url) => url.to_string(), Err(_) => return None, } }; Some((version, full_url)) } else { None } }) .collect(); Box::new(results.into_iter()) } /// Search for version matches in content /// /// Dispatches to either html_search or plain_search based on search mode. pub fn search( searchmode: &str, mut resp: R, matching_pattern: &str, _package: &str, url: &str, ) -> Result>, std::io::Error> { let mut body = Vec::new(); resp.read_to_end(&mut body)?; let iter: Box> = match searchmode { #[cfg(feature = "discover")] "html" => html_search(&body, matching_pattern, url), "plain" => plain_search(&body, matching_pattern, url), _ => Box::new(std::iter::empty()), }; Ok(iter) } #[cfg(test)] mod tests { use super::*; #[test] #[cfg(feature = "discover")] fn test_html_search() { let html = b"v1.0v2.0"; let results: Vec<_> = html_search(html, r"project-(\d+\.\d+)\.tar\.gz", "https://example.com/").collect(); assert_eq!(results.len(), 2); assert!(results.iter().any(|(v, _)| v == "1.0")); assert!(results.iter().any(|(v, _)| v == "2.0")); } #[test] #[cfg(feature = "discover")] fn test_html_search_absolute_urls() { // Test curl case with tag let html = b"curl"; let results: Vec<_> = html_search( html, r"download/curl-([\d.]+)\.tar\.gz", "https://curl.se/download/", ) .collect(); assert_eq!(results.len(), 1); assert_eq!(results[0].0, "8.14.0"); // With , the href resolves to https://curl.se/download/curl-8.14.0.tar.gz assert_eq!(results[0].1, "https://curl.se/download/curl-8.14.0.tar.gz"); } #[test] #[cfg(feature = "discover")] fn test_html_search_absolute_urls_with_slash_prefix() { // Test that returned URLs are absolute when href starts with '/' let html = b"curl"; let results: Vec<_> = html_search( html, r"download/curl-([\d.]+)\.tar\.gz", "https://curl.se/download/", ) .collect(); assert_eq!(results.len(), 1); assert_eq!(results[0].0, "8.14.0"); assert_eq!(results[0].1, "https://curl.se/download/curl-8.14.0.tar.gz"); } #[test] #[cfg(feature = "discover")] fn test_html_search_with_absolute_href() { // Test that absolute URLs in href are preserved correctly let html = b"v3.5.0"; let results: Vec<_> = html_search( html, r"https://example\.org/files/project-([\d.]+)\.tar\.gz", "https://example.com/", ) .collect(); assert_eq!(results.len(), 1); assert_eq!(results[0].0, "3.5.0"); assert_eq!( results[0].1, "https://example.org/files/project-3.5.0.tar.gz" ); } #[test] fn test_plain_search() { let text = b"Available: project-1.0.tar.gz project-2.0.tar.gz"; let results: Vec<_> = plain_search(text, r"project-(\d+\.\d+)\.tar\.gz", "https://example.com/").collect(); assert_eq!(results.len(), 2); assert!(results.iter().any(|(v, _)| v == "1.0")); assert!(results.iter().any(|(v, _)| v == "2.0")); } #[test] fn test_plain_search_absolute_urls() { // Test that returned URLs are absolute, not relative let text = b"Available: curl-8.14.0.tar.gz"; let results: Vec<_> = plain_search(text, r"curl-([\d.]+)\.tar\.gz", "https://curl.se/download/").collect(); assert_eq!(results.len(), 1); assert_eq!(results[0].0, "8.14.0"); assert_eq!(results[0].1, "https://curl.se/download/curl-8.14.0.tar.gz"); } #[test] fn test_plain_search_with_absolute_urls() { // Test that absolute URLs in text are preserved correctly let text = b"Available: https://example.org/files/project-3.5.0.tar.gz"; let results: Vec<_> = plain_search( text, r"https://example\.org/files/project-([\d.]+)\.tar\.gz", "https://example.com/", ) .collect(); assert_eq!(results.len(), 1); assert_eq!(results[0].0, "3.5.0"); assert_eq!( results[0].1, "https://example.org/files/project-3.5.0.tar.gz" ); } } debian-watch-0.4.12/src/subst.rs000064400000000000000000000202041046102023000145310ustar 00000000000000//! Variable substitution for watch file patterns //! //! This module provides template variable substitution for Debian watch files, //! allowing common patterns to be expressed concisely using `@VARIABLE@` syntax. //! //! # Supported Template Variables //! //! - `@PACKAGE@` - Source package name (dynamically provided) //! - `@COMPONENT@` - Component name for multi-component packages (empty for main) //! - `@ANY_VERSION@` - Generic upstream version regex //! - `@SEMANTIC_VERSION@` - Semantic versioning pattern (MAJOR.MINOR.PATCH) //! - `@STABLE_VERSION@` - Stable version pattern (1.2.3 format, no 0.x.x) //! - `@ARCHIVE_EXT@` - Common archive file extensions //! - `@SIGNATURE_EXT@` - Signature file extensions //! - `@DEB_EXT@` - Debian-specific version extensions //! //! # Example //! //! ``` //! use debian_watch::subst::subst; //! //! let url = "https://github.com/@PACKAGE@/releases"; //! let result = subst(url, || "mypackage".to_string(), || String::new()); //! assert_eq!(result, "https://github.com/mypackage/releases"); //! ``` const SUBSTITUTIONS: &[(&str, &str)] = &[ // @PACKAGE@: Substituted with the source package name found in the first line // of the debian/changelog file. This is handled dynamically in the subst() function. // @ANY_VERSION@: Legal upstream version regex (capturing). // Matches versions like: 1.2.3, 1.0-beta, 2.0+git20210101 ("@ANY_VERSION@", r"[-_]?(\d[\-+\.:\~\da-zA-Z]*)"), // @SEMANTIC_VERSION@: Semantic versioning pattern (capturing). // Matches MAJOR.MINOR.PATCH with optional prerelease and build metadata. // Examples: 1.2.3, 0.1.0, 1.0.0-alpha, 2.1.0-beta.1 // See https://semver.org/ for full specification. ( "@SEMANTIC_VERSION@", r"[-_]?[Vv]?((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?)", ), // @STABLE_VERSION@: Stable version pattern (capturing). // Matches pure digit versions with exactly three numbers (MAJOR.MINOR.PATCH). // Examples: 1.2.3, 10.20.30 // Note: Does NOT match 0.x.x versions (requires MAJOR >= 1) ("@STABLE_VERSION@", r"[-_]?[Vv]?((?:[1-9]\d*)(?:\.\d+){2})"), // @ARCHIVE_EXT@: Typical archive file extension regex (non-capturing). // Matches: .tar.xz, .tar.bz2, .tar.gz, .zip, .tgz, .tbz, .txz ( "@ARCHIVE_EXT@", r"(?i)\.(?:tar\.xz|tar\.bz2|tar\.gz|zip|tgz|tbz|txz)", ), // @SIGNATURE_EXT@: Signature file extension regex (non-capturing). // Matches archive extensions followed by signature extensions // Examples: .tar.gz.asc, .tar.xz.sig, .zip.gpg ( "@SIGNATURE_EXT@", r"(?i)\.(?:tar\.xz|tar\.bz2|tar\.gz|zip|tgz|tbz|txz)\.(?:asc|pgp|gpg|sig|sign)", ), // @DEB_EXT@: Debian extension pattern (capturing). // Matches Debian-specific version suffixes like +debian, ~dfsg, +ds1 ("@DEB_EXT@", r"[\+~](debian|dfsg|ds|deb)(\.)?(\d+)?$"), ]; /// Substitute watch file variables like @PACKAGE@, @COMPONENT@, and @ANY_VERSION@ /// /// # Arguments /// * `text` - The text containing template variables to substitute /// * `package` - Closure that returns the package name for @PACKAGE@ substitution /// * `component` - Closure that returns the component name for @COMPONENT@ substitution /// (returns empty string for main paragraph) pub fn subst( text: &str, package: impl FnOnce() -> String, component: impl FnOnce() -> String, ) -> String { // Early return if no substitutions are needed if !text.contains('@') { return text.to_string(); } // Apply all substitutions in a single pass using fold let result = SUBSTITUTIONS .iter() .fold(text.to_string(), |acc, (pattern, replacement)| { acc.replace(pattern, replacement) }); // Handle @PACKAGE@ substitution if needed let result = if result.contains("@PACKAGE@") { let package_name = package(); result.replace("@PACKAGE@", &package_name) } else { result }; // Handle @COMPONENT@ substitution if needed if result.contains("@COMPONENT@") { let component_name = component(); result.replace("@COMPONENT@", &component_name) } else { result } } #[cfg(test)] mod tests { use super::*; #[test] fn test_subst_any_version() { assert_eq!( subst("@ANY_VERSION@", || unreachable!(), || unreachable!()), r"[-_]?(\d[\-+\.:\~\da-zA-Z]*)" ); } #[test] fn test_subst_package() { assert_eq!( subst("@PACKAGE@", || "foo".to_string(), || unreachable!()), "foo" ); // Test in a URL pattern assert_eq!( subst( "https://github.com/@PACKAGE@/releases", || "mypackage".to_string(), || unreachable!() ), "https://github.com/mypackage/releases" ); } #[test] fn test_subst_component() { assert_eq!( subst("@COMPONENT@", || unreachable!(), || "bar".to_string()), "bar" ); // Test with empty component (main paragraph) assert_eq!( subst("@COMPONENT@", || unreachable!(), || String::new()), "" ); // Test in a pattern assert_eq!( subst( "https://example.com/@COMPONENT@/files", || unreachable!(), || "upstream".to_string() ), "https://example.com/upstream/files" ); } #[test] fn test_subst_semantic_version() { let pattern = subst("@SEMANTIC_VERSION@", || unreachable!(), || unreachable!()); assert_eq!( pattern, r"[-_]?[Vv]?((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?)" ); // Verify the pattern works with regex let re = regex::Regex::new(&pattern).unwrap(); assert!(re.is_match("1.2.3")); assert!(re.is_match("v1.2.3")); assert!(re.is_match("0.0.0")); assert!(re.is_match("1.2.3-alpha")); assert!(re.is_match("1.2.3-alpha.1")); } #[test] fn test_subst_stable_version() { let pattern = subst("@STABLE_VERSION@", || unreachable!(), || unreachable!()); assert_eq!(pattern, r"[-_]?[Vv]?((?:[1-9]\d*)(?:\.\d+){2})"); // Verify the pattern works with regex let re = regex::Regex::new(&pattern).unwrap(); assert!(re.is_match("1.2.3")); assert!(re.is_match("v1.2.3")); assert!(re.is_match("10.20.30")); // Stable version shouldn't match 0.x.x assert!(!re.is_match("0.2.3")); } #[test] fn test_subst_archive_ext() { let pattern = subst("@ARCHIVE_EXT@", || unreachable!(), || unreachable!()); assert_eq!( pattern, r"(?i)\.(?:tar\.xz|tar\.bz2|tar\.gz|zip|tgz|tbz|txz)" ); } #[test] fn test_subst_signature_ext() { let pattern = subst("@SIGNATURE_EXT@", || unreachable!(), || unreachable!()); assert_eq!( pattern, r"(?i)\.(?:tar\.xz|tar\.bz2|tar\.gz|zip|tgz|tbz|txz)\.(?:asc|pgp|gpg|sig|sign)" ); } #[test] fn test_subst_deb_ext() { let pattern = subst("@DEB_EXT@", || unreachable!(), || unreachable!()); assert_eq!(pattern, r"[\+~](debian|dfsg|ds|deb)(\.)?(\d+)?$"); } #[test] fn test_subst_multiple_templates() { assert_eq!( subst( "https://github.com/@PACKAGE@/releases/@COMPONENT@/file@ARCHIVE_EXT@", || "myapp".to_string(), || "core".to_string(), ), "https://github.com/myapp/releases/core/file(?i)\\.(?:tar\\.xz|tar\\.bz2|tar\\.gz|zip|tgz|tbz|txz)" ); } #[test] fn test_subst_no_templates() { // Test early return optimization when no @ present assert_eq!( subst( "https://example.com/releases", || unreachable!(), || unreachable!(), ), "https://example.com/releases" ); } } debian-watch-0.4.12/src/templates.rs000064400000000000000000001334761046102023000154070ustar 00000000000000//! Template expansion for v5 watch files //! //! This module provides template expansion for common project hosting platforms, //! simplifying watch file creation by auto-generating Source URLs, matching patterns, //! and other configuration based on template type. //! //! # Supported Templates //! //! - `GitHub` - For GitHub-hosted projects //! - `GitLab` - For GitLab instances //! - `PyPI` - For Python packages on PyPI //! - `Npmregistry` - For npm packages //! - `Metacpan` - For Perl modules on MetaCPAN /// Error type for template expansion #[derive(Debug, Clone, PartialEq, Eq)] pub enum TemplateError { /// Unknown template type UnknownTemplate(String), /// Missing required field MissingField { /// Template type template: String, /// Field name field: String, }, /// Invalid field value InvalidValue { /// Field name field: String, /// Reason for invalidity reason: String, }, } impl std::fmt::Display for TemplateError { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { TemplateError::UnknownTemplate(t) => write!(f, "Unknown template type: {}", t), TemplateError::MissingField { template, field } => { write!(f, "{} template requires '{}' field", template, field) } TemplateError::InvalidValue { field, reason } => { write!(f, "Invalid value for '{}': {}", field, reason) } } } } impl std::error::Error for TemplateError {} /// Template with variant-specific parameters #[derive(Debug, Clone, PartialEq, Eq)] pub enum Template { /// GitHub template GitHub { /// Project owner owner: String, /// Project repository name repository: String, /// Search only releases (not all tags) release_only: bool, /// Version type pattern to use version_type: Option, }, /// GitLab template GitLab { /// Project URL dist: String, /// Search only releases (not all tags) release_only: bool, /// Version type pattern to use version_type: Option, }, /// PyPI template PyPI { /// Package name package: String, /// Version type pattern to use version_type: Option, }, /// npm registry template Npmregistry { /// Package name (may include @scope/) package: String, /// Version type pattern to use version_type: Option, }, /// MetaCPAN template Metacpan { /// Distribution name (using :: or -) dist: String, /// Version type pattern to use version_type: Option, }, /// CRAN template Cran { /// Package name package: String, /// Version type pattern to use version_type: Option, }, /// Bioconductor template Bioconductor { /// Package name package: String, /// Version type pattern to use version_type: Option, }, } /// Expanded template fields #[derive(Debug, Clone, Default)] pub struct ExpandedTemplate { /// Source URL pub source: Option, /// Matching pattern pub matching_pattern: Option, /// Search mode pub searchmode: Option, /// Mode pub mode: Option, /// PGP mode pub pgpmode: Option, /// Download URL mangle pub downloadurlmangle: Option, } /// Expand a template into field values pub fn expand_template(template: Template) -> ExpandedTemplate { match template { Template::GitHub { owner, repository, release_only, version_type, } => expand_github_template(owner, repository, release_only, version_type), Template::GitLab { dist, release_only, version_type, } => expand_gitlab_template(dist, release_only, version_type), Template::PyPI { package, version_type, } => expand_pypi_template(package, version_type), Template::Npmregistry { package, version_type, } => expand_npmregistry_template(package, version_type), Template::Metacpan { dist, version_type } => expand_metacpan_template(dist, version_type), Template::Cran { package, version_type, } => expand_cran_template(package, version_type), Template::Bioconductor { package, version_type, } => expand_bioconductor_template(package, version_type), } } /// Expand GitHub template fn expand_github_template( owner: String, repository: String, release_only: bool, version_type: Option, ) -> ExpandedTemplate { let version_pattern = version_type .as_deref() .map(|v| format!("@{}_VERSION@", v.to_uppercase())) .unwrap_or_else(|| "@ANY_VERSION@".to_string()); let source = if release_only { format!("https://github.com/{}/{}/releases", owner, repository) } else { format!("https://github.com/{}/{}/tags", owner, repository) }; let matching_pattern = format!( r".*/(?:refs/tags/)?v?{}{}", version_pattern, "@ARCHIVE_EXT@" ); ExpandedTemplate { source: Some(source), matching_pattern: Some(matching_pattern), searchmode: Some("html".to_string()), ..Default::default() } } /// Expand GitLab template fn expand_gitlab_template( dist: String, _release_only: bool, version_type: Option, ) -> ExpandedTemplate { let version_pattern = version_type .as_deref() .map(|v| format!("@{}_VERSION@", v.to_uppercase())) .unwrap_or_else(|| "@ANY_VERSION@".to_string()); // GitLab uses mode=gitlab ExpandedTemplate { source: Some(dist), matching_pattern: Some(format!(r".*/v?{}{}", version_pattern, "@ARCHIVE_EXT@")), mode: Some("gitlab".to_string()), ..Default::default() } } /// Expand PyPI template fn expand_pypi_template(package: String, version_type: Option) -> ExpandedTemplate { let version_pattern = version_type .as_deref() .map(|v| format!("@{}_VERSION@", v.to_uppercase())) .unwrap_or_else(|| "@ANY_VERSION@".to_string()); ExpandedTemplate { source: Some(format!("https://pypi.debian.net/{}/", package)), matching_pattern: Some(format!( r"https://pypi\.debian\.net/{}/[^/]+\.tar\.gz#/.*-{}\.tar\.gz", package, version_pattern )), searchmode: Some("plain".to_string()), ..Default::default() } } /// Expand Npmregistry template fn expand_npmregistry_template(package: String, version_type: Option) -> ExpandedTemplate { let version_pattern = version_type .as_deref() .map(|v| format!("@{}_VERSION@", v.to_uppercase())) .unwrap_or_else(|| "@ANY_VERSION@".to_string()); // npm package names might have @ prefix for scoped packages let package_name = package.trim_start_matches('@'); ExpandedTemplate { source: Some(format!("https://registry.npmjs.org/{}", package)), matching_pattern: Some(format!( r"https://registry\.npmjs\.org/{}/-/.*-{}@ARCHIVE_EXT@", package_name.replace('/', r"\/"), version_pattern )), searchmode: Some("plain".to_string()), ..Default::default() } } /// Expand Metacpan template fn expand_metacpan_template(dist: String, version_type: Option) -> ExpandedTemplate { let version_pattern = version_type .as_deref() .map(|v| format!("@{}_VERSION@", v.to_uppercase())) .unwrap_or_else(|| "@ANY_VERSION@".to_string()); // MetaCPAN dist names can use :: or - let dist_name = dist.replace("::", "-"); ExpandedTemplate { source: Some("https://cpan.metacpan.org/authors/id/".to_string()), matching_pattern: Some(format!(r".*/{}{}@ARCHIVE_EXT@", dist_name, version_pattern)), searchmode: Some("plain".to_string()), ..Default::default() } } /// Expand CRAN template fn expand_cran_template(package: String, version_type: Option) -> ExpandedTemplate { let version_pattern = version_type .as_deref() .map(|v| format!("@{}_VERSION@", v.to_uppercase())) .unwrap_or_else(|| "@ANY_VERSION@".to_string()); ExpandedTemplate { source: Some(format!("https://cran.r-project.org/package={}", package)), matching_pattern: Some(format!(".*_{}.tar.gz", version_pattern)), downloadurlmangle: Some( "s%.*/src/contrib/%https://cran.r-project.org/src/contrib/%".to_string(), ), ..Default::default() } } /// Expand Bioconductor template fn expand_bioconductor_template(package: String, version_type: Option) -> ExpandedTemplate { let version_pattern = version_type .as_deref() .map(|v| format!("@{}_VERSION@", v.to_uppercase())) .unwrap_or_else(|| "@ANY_VERSION@".to_string()); ExpandedTemplate { source: Some(format!("https://bioconductor.org/packages/{}", package)), matching_pattern: Some(format!(".*_{}.tar.gz", version_pattern)), downloadurlmangle: Some( "s%.*/src/contrib/%https://bioconductor.org/packages/release/bioc/src/contrib/%" .to_string(), ), ..Default::default() } } /// Try to detect if the given fields match a known template pattern /// and return the corresponding Template if a match is found. /// /// This is the reverse of `expand_template` - it analyzes expanded fields /// and tries to identify which template would produce them. /// /// # Arguments /// /// * `source` - The Source URL /// * `matching_pattern` - The Matching-Pattern /// * `searchmode` - The Searchmode field (if any) /// * `mode` - The Mode field (if any) /// /// # Returns /// /// Returns `Some(Template)` if the fields match a known template pattern, /// `None` otherwise. pub fn detect_template( source: Option<&str>, matching_pattern: Option<&str>, searchmode: Option<&str>, mode: Option<&str>, ) -> Option