debian-watch-0.4.4/.cargo_vcs_info.json0000644000000001361046102023000134110ustar { "git": { "sha1": "526e4450c8bee705268e7262499cf68be3e68976" }, "path_in_vcs": "" }debian-watch-0.4.4/.codespellrc000064400000000000000000000000461046102023000144570ustar 00000000000000[codespell] ignore-words-list = crate debian-watch-0.4.4/.github/CODEOWNERS000064400000000000000000000000121046102023000151030ustar 00000000000000* @jelmer debian-watch-0.4.4/.github/FUNDING.yml000064400000000000000000000000171046102023000153320ustar 00000000000000github: jelmer debian-watch-0.4.4/.github/dependabot.yml000064400000000000000000000006251046102023000163520ustar 00000000000000# Please see the documentation for all configuration options: # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates version: 2 updates: - package-ecosystem: "cargo" directory: "/" schedule: interval: "weekly" rebase-strategy: "disabled" - package-ecosystem: "github-actions" directory: "/" schedule: interval: weekly debian-watch-0.4.4/.github/workflows/disperse.yml000064400000000000000000000002741046102023000201200ustar 00000000000000--- name: Disperse configuration "on": - push jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: jelmer/action-disperse-validate@v2 debian-watch-0.4.4/.github/workflows/rust.yml000064400000000000000000000026351046102023000173020ustar 00000000000000--- name: Rust "on": push: pull_request: env: CARGO_TERM_COLOR: always jobs: build: runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] fail-fast: false steps: - uses: actions/checkout@v6 - name: Install dependencies (Ubuntu) run: sudo apt install clang llvm pkg-config nettle-dev if: matrix.os == 'ubuntu-latest' - name: Install dependencies (macOS) run: brew install nettle if: matrix.os == 'macos-latest' - name: Build run: cargo build --verbose - name: Build with no default features run: cargo build --no-default-features --verbose - name: Run tests run: cargo test --verbose - name: Run tests with all features run: cargo test --all-features --verbose if: matrix.os != 'windows-latest' - name: Run tests with linebased feature only run: cargo test --no-default-features --features linebased --verbose - name: Run tests with deb822 feature only run: cargo test --no-default-features --features deb822 --verbose minimal-versions: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Install cargo-minimal-versions run: cargo install cargo-minimal-versions cargo-hack - name: Test with minimal versions run: cargo minimal-versions test --verbose debian-watch-0.4.4/.gitignore000064400000000000000000000000131046102023000141410ustar 00000000000000target .*~ debian-watch-0.4.4/CODE_OF_CONDUCT.md000064400000000000000000000125451046102023000147650ustar 00000000000000 # Contributor Covenant Code of Conduct ## Our Pledge We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socioeconomic status, nationality, personal appearance, race, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards Examples of behavior that contributes to a positive environment for our community include: * Demonstrating empathy and kindness toward other people * Being respectful of differing opinions, viewpoints, and experiences * Giving and gracefully accepting constructive feedback * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience * Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: * The use of sexualized language or imagery, and sexual attention or advances of any kind * Trolling, insulting or derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or email address, without their explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official email address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [INSERT CONTACT METHOD]. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. ### 2. Warning **Community Impact**: A violation through a single incident or series of actions. **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. ### 3. Temporary Ban **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0]. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. [homepage]: https://www.contributor-covenant.org [v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html [Mozilla CoC]: https://github.com/mozilla/diversity [FAQ]: https://www.contributor-covenant.org/faq [translations]: https://www.contributor-covenant.org/translations debian-watch-0.4.4/Cargo.lock0000644000002420111046102023000113640ustar # 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 = "0.6.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" dependencies = [ "anstyle", "anstyle-parse", "anstyle-query", "anstyle-wincon", "colorchoice", "is_terminal_polyfill", "utf8parse", ] [[package]] name = "anstyle" version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] name = "anstyle-parse" version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ "windows-sys 0.61.2", ] [[package]] name = "anstyle-wincon" version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", "windows-sys 0.61.2", ] [[package]] name = "anyhow" version = "1.0.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 = "aws-lc-rs" version = "1.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf" dependencies = [ "aws-lc-sys", "zeroize", ] [[package]] name = "aws-lc-sys" version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e" dependencies = [ "cc", "cmake", "dunce", "fs_extra", ] [[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.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f" dependencies = [ "bitflags", "cexpr", "clang-sys", "itertools 0.13.0", "proc-macro2", "quote", "regex", "rustc-hash 1.1.0", "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.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[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.56" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ "find-msvc-tools", "jobserver", "libc", "shlex", ] [[package]] name = "cesu8" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" [[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 = "cfg_aliases" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" version = "0.4.44" 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.5.60" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" dependencies = [ "clap_builder", "clap_derive", ] [[package]] name = "clap_builder" version = "4.5.60" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" dependencies = [ "anstream", "anstyle", "clap_lex", "strsim", ] [[package]] name = "clap_derive" version = "4.5.55" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" dependencies = [ "heck", "proc-macro2", "quote", "syn", ] [[package]] name = "clap_lex" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" [[package]] name = "cmake" version = "0.1.57" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" dependencies = [ "cc", ] [[package]] name = "colorchoice" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] name = "combine" version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" dependencies = [ "bytes", "memchr", ] [[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.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84462549961d1b612f244697d68105f6e5c7ed640f7ead092592a31e2ac1af8a" dependencies = [ "regex", "rowan", "serde", ] [[package]] name = "debian-watch" version = "0.4.4" 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 = "dunce" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[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.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2972feb8dffe7bc8c5463b1dacda1b0dfbed3710e50f977d965429692d74cd8" [[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.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[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 = "form_urlencoded" version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] [[package]] name = "fs_extra" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "futf" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" dependencies = [ "mac", "new_debug_unreachable", ] [[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.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaf57c49a95fd1fe24b90b3033bee6dc7e8f1288d51494cb44e627c295e38542" 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.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "js-sys", "libc", "r-efi 5.3.0", "wasip2", "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 6.0.0", "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.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" dependencies = [ "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", "http", "indexmap", "slab", "tokio", "tokio-util", "tracing", ] [[package]] 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.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "html5ever" version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6452c4751a24e1b99c3260d505eaeee76a050573e61f30ac2c924ddc7236f01e" 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.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", "h2", "http", "http-body", "httparse", "itoa", "pin-project-lite", "pin-utils", "smallvec", "tokio", "want", ] [[package]] name = "hyper-rustls" version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ "http", "hyper", "hyper-util", "rustls", "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", ] [[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.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" dependencies = [ "displaydoc", "potential_utf", "yoke", "zerofrom", "zerovec", ] [[package]] name = "icu_locale_core" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" dependencies = [ "displaydoc", "litemap", "tinystr", "writeable", "zerovec", ] [[package]] name = "icu_normalizer" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" dependencies = [ "icu_collections", "icu_normalizer_data", "icu_properties", "icu_provider", "smallvec", "zerovec", ] [[package]] name = "icu_normalizer_data" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ "icu_collections", "icu_locale_core", "icu_properties_data", "icu_provider", "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" dependencies = [ "displaydoc", "icu_locale_core", "writeable", "yoke", "zerofrom", "zerotrie", "zerovec", ] [[package]] name = "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.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" dependencies = [ "icu_normalizer", "icu_properties", ] [[package]] name = "indexmap" version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown 0.16.1", "serde", "serde_core", ] [[package]] name = "ipnet" version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "iri-string" version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" dependencies = [ "memchr", "serde", ] [[package]] name = "is_terminal_polyfill" version = "1.70.2" 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.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jni" version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" dependencies = [ "cesu8", "cfg-if", "combine", "jni-sys", "log", "thiserror 1.0.69", "walkdir", "windows-sys 0.45.0", ] [[package]] name = "jni-sys" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" [[package]] name = "jobserver" version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ "getrandom 0.3.4", "libc", ] [[package]] name = "js-sys" version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" dependencies = [ "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.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[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.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" [[package]] name = "lock_api" version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ "scopeguard", ] [[package]] name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lru-slab" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "m_lexer" version = "0.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7e51ebf91162d585a5bae05e4779efc4a276171cb880d61dd6fab11c98467a7" dependencies = [ "regex", ] [[package]] name = "mac" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "maplit" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" [[package]] name = "markup5ever" version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c3294c4d74d0742910f8c7b466f44dda9eb2d5742c1e430138df290a1e8451c" 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.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "wasi", "windows-sys 0.61.2", ] [[package]] name = "nettle" version = "7.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44e6ff4a94e5d34a1fd5abbd39418074646e2fa51b257198701330f22fcd6936" dependencies = [ "getrandom 0.2.17", "libc", "nettle-sys", "thiserror 1.0.69", "typenum", ] [[package]] name = "nettle-sys" version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61a3f5406064d310d59b1a219d3c5c9a49caf4047b6496032e3f930876488c34" 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.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "once_cell_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[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 0.6.4", "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 = "pin-utils" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "pkg-config" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "potential_utf" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" dependencies = [ "zerovec", ] [[package]] name = "ppv-lite86" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ "zerocopy", ] [[package]] name = "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 = "quinn" version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" dependencies = [ "bytes", "cfg_aliases", "pin-project-lite", "quinn-proto", "quinn-udp", "rustc-hash 2.1.1", "rustls", "socket2", "thiserror 2.0.18", "tokio", "tracing", "web-time", ] [[package]] name = "quinn-proto" version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ "aws-lc-rs", "bytes", "getrandom 0.3.4", "lru-slab", "rand", "ring", "rustc-hash 2.1.1", "rustls", "rustls-pki-types", "slab", "thiserror 2.0.18", "tinyvec", "tracing", "web-time", ] [[package]] name = "quinn-udp" version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", "windows-sys 0.60.2", ] [[package]] name = "quote" version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] [[package]] name = "r-efi" version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha", "rand_core 0.9.5", ] [[package]] name = "rand_chacha" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", "rand_core 0.9.5", ] [[package]] name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" [[package]] name = "rand_core" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ "getrandom 0.3.4", ] [[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.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ "base64", "bytes", "encoding_rs", "futures-channel", "futures-core", "futures-util", "h2", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", "hyper-util", "js-sys", "log", "mime", "percent-encoding", "pin-project-lite", "quinn", "rustls", "rustls-pki-types", "rustls-platform-verifier", "sync_wrapper", "tokio", "tokio-rustls", "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.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" [[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.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ "aws-lc-rs", "once_cell", "rustls-pki-types", "rustls-webpki", "subtle", "zeroize", ] [[package]] name = "rustls-native-certs" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", "security-framework", ] [[package]] name = "rustls-pki-types" version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ "web-time", "zeroize", ] [[package]] name = "rustls-platform-verifier" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", "jni", "log", "once_cell", "rustls", "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", "security-framework", "security-framework-sys", "webpki-root-certs", "windows-sys 0.61.2", ] [[package]] name = "rustls-platform-verifier-android" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ "aws-lc-rs", "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 = "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.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" 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.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93cecd86d6259499c844440546d02f55f3e17bd286e529e48d1f9f67e92315cb" 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.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "feef350c36147532e1b79ea5c1f3791373e61cbd9a6a2615413b3807bb164fb7" dependencies = [ "bitflags", "cssparser", "derive_more", "log", "new_debug_unreachable", "phf", "phf_codegen", "precomputed-hash", "rustc-hash 2.1.1", "servo_arc", "smallvec", ] [[package]] name = "semver" version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" [[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 2.0.18", "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 = "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.3.5", ] [[package]] name = "sha3" version = "0.10.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" 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.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[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.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", ] [[package]] name = "tendril" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" dependencies = [ "futf", "mac", "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 = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ "thiserror-impl 1.0.69", ] [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ "thiserror-impl 2.0.18", ] [[package]] name = "thiserror-impl" version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "thiserror-impl" version = "2.0.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.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" dependencies = [ "displaydoc", "zerovec", ] [[package]] name = "tinyvec" version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" dependencies = [ "tinyvec_macros", ] [[package]] name = "tinyvec_macros" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" version = "1.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ "bytes", "libc", "mio", "pin-project-lite", "socket2", "tokio-macros", "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ "rustls", "tokio", ] [[package]] name = "tokio-util" version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", "futures-sink", "pin-project-lite", "tokio", ] [[package]] name = "tower" version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", "pin-project-lite", "sync_wrapper", "tokio", "tower-layer", "tower-service", ] [[package]] name = "tower-http" version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags", "bytes", "futures-util", "http", "http-body", "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", ] [[package]] name = "tower-layer" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" [[package]] name = "tower-service" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", "tracing-core", ] [[package]] name = "tracing-core" version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", ] [[package]] name = "try-lock" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typenum" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "unicode-ident" version = "1.0.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.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" dependencies = [ "wit-bindgen", ] [[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", ] [[package]] name = "wasm-bindgen" version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" version = "0.4.64" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" dependencies = [ "cfg-if", "futures-util", "js-sys", "once_cell", "wasm-bindgen", "web-sys", ] [[package]] name = "wasm-bindgen-macro" version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" dependencies = [ "quote", "wasm-bindgen-macro-support", ] [[package]] name = "wasm-bindgen-macro-support" version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" dependencies = [ "bumpalo", "proc-macro2", "quote", "syn", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" 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.91" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" dependencies = [ "js-sys", "wasm-bindgen", ] [[package]] name = "web-time" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ "js-sys", "wasm-bindgen", ] [[package]] name = "web_atoms" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576" dependencies = [ "phf", "phf_codegen", "string_cache 0.9.0", "string_cache_codegen", ] [[package]] name = "webpki-root-certs" version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" dependencies = [ "rustls-pki-types", ] [[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.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" dependencies = [ "windows-targets 0.42.2", ] [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ "windows-targets 0.52.6", ] [[package]] name = "windows-sys" version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ "windows-targets 0.53.5", ] [[package]] name = "windows-sys" version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] [[package]] name = "windows-targets" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" dependencies = [ "windows_aarch64_gnullvm 0.42.2", "windows_aarch64_msvc 0.42.2", "windows_i686_gnu 0.42.2", "windows_i686_msvc 0.42.2", "windows_x86_64_gnu 0.42.2", "windows_x86_64_gnullvm 0.42.2", "windows_x86_64_msvc 0.42.2", ] [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] [[package]] name = "windows-targets" version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ "windows-link", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", "windows_i686_gnullvm 0.53.1", "windows_i686_msvc 0.53.1", "windows_x86_64_gnu 0.53.1", "windows_x86_64_gnullvm 0.53.1", "windows_x86_64_msvc 0.53.1", ] [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" [[package]] name = "windows_aarch64_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_aarch64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" [[package]] name = "windows_i686_gnu" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] name = "windows_i686_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" [[package]] name = "windows_i686_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_i686_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" [[package]] name = "windows_x86_64_gnu" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" [[package]] name = "windows_x86_64_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "windows_x86_64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "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-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.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" [[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.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" dependencies = [ "stable_deref_trait", "yoke-derive", "zerofrom", ] [[package]] name = "yoke-derive" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", "syn", "synstructure", ] [[package]] name = "zerocopy" version = "0.8.41" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96e13bc581734df6250836c59a5f44f3c57db9f9acb9dc8e3eaabdaf6170254d" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" version = "0.8.41" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3545ea9e86d12ab9bba9fcd99b54c1556fd3199007def5a03c375623d05fac1c" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "zerofrom" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", "syn", "synstructure", ] [[package]] name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" dependencies = [ "displaydoc", "yoke", "zerofrom", ] [[package]] name = "zerovec" version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" dependencies = [ "yoke", "zerofrom", "zerovec-derive", ] [[package]] name = "zerovec-derive" version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" debian-watch-0.4.4/Cargo.toml0000644000000040761046102023000114160ustar # 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.4" 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-watch-rs" readme = "README.md" license = "Apache-2.0" repository = "https://github.com/jelmer/debian-watch-rs.git" [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.9" 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.26" 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.4/Cargo.toml.orig000064400000000000000000000022241046102023000150460ustar 00000000000000[package] name = "debian-watch" version = "0.4.4" authors = [ "Jelmer Vernooij ",] edition = "2021" license = "Apache-2.0" description = "parser for Debian watch files" repository = "https://github.com/jelmer/debian-watch-rs.git" homepage = "https://github.com/jelmer/debian-watch-rs" [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.9", optional = true } clap = { version = "4.5", features = ["derive"] } regex = "1" scraper = { version = ">=0.22,<0.26", 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.4/README.md000064400000000000000000000021061046102023000134350ustar 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.4/TODO.md000064400000000000000000000004271046102023000132510ustar 00000000000000Options to properly support: * dversionmangle * oversionmangle * dirversionmangle * filenamemangle * pagemangle * downloadurlmangle * repack * repacksuffix * compression * mode * pretty * versionmode * component * ctype Add common trait for legacy and deb822-style watchfiles. debian-watch-0.4.4/disperse.toml000064400000000000000000000000531046102023000146700ustar 00000000000000tag-name = "v$VERSION" release-timeout = 5 debian-watch-0.4.4/src/bin/convert-watch-v5.rs000064400000000000000000000054161046102023000172060ustar 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.4/src/convert.rs000064400000000000000000000370141046102023000150010ustar 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.4/src/deb822.rs000064400000000000000000001206361046102023000143120ustar 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)] pub struct WatchFile(Deb822); /// An entry in a format 5 watch file #[derive(Debug)] pub struct Entry { paragraph: Paragraph, defaults: Option, } impl WatchFile { /// 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 } } _ => 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); } } } 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_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.4/src/discover.rs000064400000000000000000000426551046102023000151460ustar 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.4/src/lex.rs000064400000000000000000000100521046102023000141020ustar 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.4/src/lib.rs000064400000000000000000000115671046102023000140740ustar 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.4/src/linebased.rs000064400000000000000000003652101046102023000152510ustar 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, } 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; } if self.current() != Some(VALUE) && self.current() != Some(KEY) { 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 match i { 0 => { // URL self.builder.start_node(URL.into()); self.bump(); self.builder.finish_node(); } 1 => { // Matching pattern self.builder.start_node(MATCHING_PATTERN.into()); self.bump(); self.builder.finish_node(); } 2 => { // Version policy self.builder.start_node(VERSION_POLICY.into()); self.bump(); self.builder.finish_node(); } 3 => { // Script self.builder.start_node(SCRIPT.into()); self.bump(); self.builder.finish_node(); } _ => { self.bump(); } } } 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 } fn parse_option(&mut self) -> bool { if self.current().is_none() { return false; } while self.current() == Some(CONTINUATION) { self.bump(); } if self.current() == Some(WHITESPACE) { 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(); if self.current() != Some(VALUE) && self.current() != Some(KEY) { self.builder.start_node(ERROR.into()); self.errors .push(format!("expected value, got {:?}", self.current())); self.bump(); self.builder.finish_node(); } else { self.bump(); } } 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 { if self.current() == Some(QUOTE) { self.bump(); break; } self.skip_ws(); } if !self.parse_option() { break; } 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 } } /// 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 } } /// 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 } /// 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 (needed for conversion to deb822 format) #[cfg(feature = "deb822")] pub(crate) 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()).unwrap_or_else(|| { // Fallback for entries without URL node (shouldn't happen with new parser) self.items().next().unwrap() }) } /// 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(); } } impl Url { /// Returns the URL string. pub fn url(&self) -> String { self.0 .children_with_tokens() .find_map(|it| match it { SyntaxElement::Token(token) => { if token.kind() == VALUE { Some(token.text().to_string()) } else { None } } _ => None, }) .unwrap() } } impl MatchingPattern { /// Returns the matching pattern string. pub fn pattern(&self) -> String { self.0 .children_with_tokens() .find_map(|it| match it { SyntaxElement::Token(token) => { if token.kind() == VALUE { Some(token.text().to_string()) } else { None } } _ => None, }) .unwrap() } } impl VersionPolicyNode { /// Returns the version policy string. pub fn policy(&self) -> String { self.0 .children_with_tokens() .find_map(|it| match it { SyntaxElement::Token(token) => { // Can be KEY (e.g., "debian") or VALUE if token.kind() == VALUE || token.kind() == KEY { Some(token.text().to_string()) } else { None } } _ => None, }) .unwrap() } } impl ScriptNode { /// Returns the script string. pub fn script(&self) -> String { self.0 .children_with_tokens() .find_map(|it| match it { SyntaxElement::Token(token) => { // Can be KEY (e.g., "uupdate") or VALUE if token.kind() == VALUE || token.kind() == KEY { Some(token.text().to_string()) } else { None } } _ => None, }) .unwrap() } } #[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 ); } } } debian-watch-0.4.4/src/mangle.rs000064400000000000000000000336371046102023000145730ustar 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.4/src/parse.rs000064400000000000000000001000621046102023000144250ustar 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)] 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)] 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 { /// 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) } } } } 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() } /// 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(), } } } 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) } } /// 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(String), // Store raw text for deb822 to avoid SyntaxNode } 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(text.to_string()) } #[cfg(not(feature = "linebased"))] Some(WatchFileVersion::LineBased(_)) => { // Fallback to storing text if linebased feature is not enabled #[cfg(feature = "deb822")] { ParseInner::Deb822(text.to_string()) } #[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(text.to_string()) } #[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(text) => { let wf: crate::deb822::WatchFile = text.parse().unwrap(); ParsedWatchFile::Deb822(wf) } } } /// 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 // LineBased parse uses GreenNode (thread-safe) // Deb822 variant stores String (thread-safe) unsafe impl Send for Parse {} unsafe impl Sync for Parse {} debian-watch-0.4.4/src/pgp.rs000064400000000000000000000213441046102023000141060ustar 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.4/src/release.rs000064400000000000000000000147771046102023000147540ustar 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.4/src/search.rs000064400000000000000000000231561046102023000145700ustar 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.4/src/subst.rs000064400000000000000000000202041046102023000144520ustar 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.4/src/templates.rs000064400000000000000000001026761046102023000153260ustar 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, }, } /// 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), } } /// 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() } } /// 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