diff --git a/Cargo.lock b/Cargo.lock index 5e76806..a73442d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -16,7 +16,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -80,9 +80,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" [[package]] name = "assert-json-diff" @@ -211,6 +211,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.0", +] + [[package]] name = "cipher" version = "0.4.4" @@ -223,9 +234,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.54" +version = "4.5.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806" dependencies = [ "clap_builder", "clap_derive", @@ -233,9 +244,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.54" +version = "4.5.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2" dependencies = [ "anstream", "anstyle", @@ -245,9 +256,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.49" +version = "4.5.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" dependencies = [ "heck", "proc-macro2", @@ -257,9 +268,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.6" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" [[package]] name = "cmake" @@ -321,9 +332,9 @@ dependencies = [ [[package]] name = "constant_time_eq" -version = "0.3.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" [[package]] name = "core-foundation" @@ -360,6 +371,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc" version = "3.3.0" @@ -498,9 +518,9 @@ dependencies = [ [[package]] name = "env_filter" -version = "0.1.4" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" dependencies = [ "log", "regex", @@ -508,9 +528,9 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.8" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" dependencies = [ "anstream", "anstyle", @@ -564,6 +584,12 @@ 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" @@ -706,10 +732,26 @@ dependencies = [ ] [[package]] -name = "git2" -version = "0.20.3" +name = "getrandom" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e2b37e2f62729cdada11f0e6b3b6fe383c69c29fc619e391223e12856af308c" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core 0.10.0", + "wasip2", + "wasip3", + "wasm-bindgen", +] + +[[package]] +name = "git2" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" dependencies = [ "bitflags 2.10.0", "libc", @@ -720,6 +762,12 @@ dependencies = [ "url", ] +[[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" @@ -739,6 +787,15 @@ dependencies = [ "tracing", ] +[[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" @@ -951,6 +1008,12 @@ dependencies = [ "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" @@ -979,7 +1042,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -1108,6 +1173,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libbz2-rs-sys" version = "0.2.2" @@ -1116,9 +1187,9 @@ checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" [[package]] name = "libc" -version = "0.2.180" +version = "0.2.181" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5" [[package]] name = "libgit2-sys" @@ -1263,9 +1334,9 @@ dependencies = [ [[package]] name = "mockito" -version = "1.7.1" +version = "1.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e0603425789b4a70fcc4ac4f5a46a566c116ee3e2a6b768dc623f7719c611de" +checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0" dependencies = [ "assert-json-diff", "bytes", @@ -1278,7 +1349,7 @@ dependencies = [ "hyper-util", "log", "pin-project-lite", - "rand", + "rand 0.9.2", "regex", "serde_json", "serde_urlencoded", @@ -1288,9 +1359,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" [[package]] name = "once_cell" @@ -1340,16 +1411,17 @@ dependencies = [ "env_logger", "futures", "git2", + "glob", "indicatif", "keyring", "libc", "log", "md-5", "mockito", - "once_cell", - "rand", + "rand 0.10.0", "regex", "reqwest", + "semver", "serde", "serde_json", "sha1", @@ -1357,7 +1429,7 @@ dependencies = [ "strsim", "tempfile", "textwrap", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "walkdir", "yansi", @@ -1453,9 +1525,9 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "ppmd-rust" -version = "1.3.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d558c559f0450f16f2a27a1f017ef38468c1090c9ce63c8e51366232d53717b4" +checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24" [[package]] name = "ppv-lite86" @@ -1466,6 +1538,16 @@ dependencies = [ "zerocopy", ] +[[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.105" @@ -1489,7 +1571,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -1505,13 +1587,13 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand", + "rand 0.9.2", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.17", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -1553,7 +1635,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha", - "rand_core", + "rand_core 0.9.3", +] + +[[package]] +name = "rand" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +dependencies = [ + "chacha20", + "getrandom 0.4.1", + "rand_core 0.10.0", ] [[package]] @@ -1563,7 +1656,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.3", ] [[package]] @@ -1575,6 +1668,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1586,9 +1685,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -1615,9 +1714,9 @@ checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "reqwest" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ "base64", "bytes", @@ -1820,6 +1919,12 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" version = "1.0.228" @@ -1882,7 +1987,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -1893,7 +1998,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -2031,12 +2136,12 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.24.0" +version = "3.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.1", "once_cell", "rustix", "windows-sys 0.61.2", @@ -2064,11 +2169,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl 2.0.18", ] [[package]] @@ -2084,9 +2189,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", @@ -2095,22 +2200,23 @@ dependencies = [ [[package]] name = "time" -version = "0.3.44" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", + "js-sys", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "tinystr" @@ -2258,6 +2364,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typenum" version = "1.19.0" @@ -2288,6 +2400,12 @@ 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 = "unit-prefix" version = "0.5.2" @@ -2367,7 +2485,16 @@ version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.46.0", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", ] [[package]] @@ -2428,6 +2555,40 @@ 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 2.10.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-sys" version = "0.3.83" @@ -2760,6 +2921,94 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +[[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 2.10.0", + "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" @@ -2891,9 +3140,9 @@ dependencies = [ [[package]] name = "zip" -version = "7.1.0" +version = "7.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9013f1222db8a6d680f13a7ccdc60a781199cd09c2fa4eff58e728bb181757fc" +checksum = "cc12baa6db2b15a140161ce53d72209dacea594230798c24774139b54ecaa980" dependencies = [ "aes", "bzip2", @@ -2901,8 +3150,7 @@ dependencies = [ "crc32fast", "deflate64", "flate2", - "generic-array", - "getrandom 0.3.4", + "getrandom 0.4.1", "hmac", "indexmap", "lzma-rust2", @@ -2911,6 +3159,7 @@ dependencies = [ "ppmd-rust", "sha1", "time", + "typed-path", "zeroize", "zopfli", "zstd", diff --git a/Cargo.toml b/Cargo.toml index 71acc35..411bb0e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,47 +1,47 @@ [package] -name = "pakker" -version = "0.1.0" -edition = "2024" -authors = [ "NotAShelf " ] +rust-version = "1.91.0" +readme = true + [dependencies] -anyhow = "1.0.100" +anyhow = "1.0.101" async-trait = "0.1.89" -clap = { version = "4.5.54", features = [ "derive" ] } -comfy-table = "7.1" +clap = { version = "4.5.58", features = [ "derive" ] } +comfy-table = "7.2.2" dialoguer = "0.12.0" -env_logger = "0.11.8" +env_logger = "0.11.9" futures = "0.3.31" -git2 = "0.20.3" +git2 = "0.20.4" +glob = "0.3.3" indicatif = "0.18.3" keyring = "3.6.3" -libc = "0.2.180" +libc = "0.2.181" log = "0.4.29" md-5 = "0.10.6" -once_cell = "1.20" -rand = "0.9.2" -regex = "1.12" -reqwest = { version = "0.13.1", features = [ "json" ] } +rand = "0.10.0" +regex = "1.12.3" +reqwest = { version = "0.13.2", features = [ "json" ] } +semver = "1.0.27" serde = { version = "1.0.228", features = [ "derive" ] } serde_json = "1.0.149" sha1 = "0.10.6" -sha2 = "0.10.0" +sha2 = "0.10.9" strsim = "0.11.1" -tempfile = "3.24.0" -textwrap = "0.16" -thiserror = "2.0.17" +tempfile = "3.25.0" +textwrap = "0.16.2" +thiserror = "2.0.18" tokio = { version = "1.49.0", features = [ "full" ] } walkdir = "2.5.0" yansi = "1.0.1" -zip = "7.1.0" +zip = "7.4.0" [dev-dependencies] -mockito = "1.7.1" -tempfile = "3.24.0" - -[[bin]] -name = "pakker" -path = "src/main.rs" +mockito = "1.7.2" +tempfile = "3.25.0" # Optimize crypto stuff. Building them with optimizations makes that build script # run ~5x faster, more than offsetting the additional build time added to the diff --git a/src/cli.rs b/src/cli.rs index a4b4b25..fea5e72 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -97,20 +97,20 @@ pub struct InitArgs { pub version: Option, /// Target platform - #[clap(short, long, default_value = "multiplatform")] - pub target: String, + #[clap(short, long)] + pub target: Option, - /// Minecraft version - #[clap(short, long, default_value = "1.20.1")] - pub mc_version: String, + /// Minecraft versions (space-separated) + #[clap(short, long = "mc-versions", value_delimiter = ' ', num_args = 1..)] + pub mc_versions: Option>, - /// Mod loader - #[clap(short, long, default_value = "fabric")] - pub loader: String, + /// Mod loaders (format: name=version, can be specified multiple times) + #[clap(short, long = "loaders", value_delimiter = ',')] + pub loaders: Option>, - /// Mod loader version - #[clap(short = 'v', long, default_value = "latest")] - pub loader_version: String, + /// Skip interactive prompts (use defaults) + #[clap(short, long)] + pub yes: bool, } #[derive(Args)] @@ -214,6 +214,10 @@ pub struct RmArgs { /// Skip confirmation prompt #[clap(short, long)] pub yes: bool, + + /// Skip removing dependent projects + #[clap(short = 'D', long = "no-deps")] + pub no_deps: bool, } #[derive(Args)] @@ -222,6 +226,10 @@ pub struct UpdateArgs { #[arg(value_name = "PROJECT")] pub inputs: Vec, + /// Update all projects + #[arg(short, long)] + pub all: bool, + /// Skip confirmation prompts #[arg(short, long)] pub yes: bool, @@ -344,7 +352,7 @@ pub struct SyncArgs { #[clap(short = 'R', long)] pub removals: bool, - /// Sync updates only + /// Sync updates only (apply pending updates) #[clap(short = 'U', long)] pub updates: bool, } @@ -371,7 +379,7 @@ pub struct ExportArgs { /// Export modpack without server content /// Modrinth: exclude server-overrides and SERVER mods - /// ServerPack: skip export + /// `ServerPack`: skip export #[clap(long = "no-server")] pub no_server: bool, } diff --git a/src/cli/commands/export.rs b/src/cli/commands/export.rs index 8528a45..6861553 100644 --- a/src/cli/commands/export.rs +++ b/src/cli/commands/export.rs @@ -35,7 +35,7 @@ pub async fn execute( let config_dir = config_path.parent().unwrap_or(Path::new(".")); // IPC coordination - prevent concurrent operations on the same modpack - let ipc = IpcCoordinator::new(&config_dir.to_path_buf())?; + let ipc = IpcCoordinator::new(config_dir)?; let ipc_timeout = std::time::Duration::from_secs(60); // Check for conflicting export operations diff --git a/src/cli/commands/fetch.rs b/src/cli/commands/fetch.rs index bdea8f7..9b88b1d 100644 --- a/src/cli/commands/fetch.rs +++ b/src/cli/commands/fetch.rs @@ -37,8 +37,8 @@ pub async fn execute( let operation_id = coordinator.register_operation(OperationType::Fetch)?; let _guard = OperationGuard::new(coordinator, operation_id); - // Create fetcher - let fetcher = Fetcher::new("."); + // Create fetcher with shelve option + let fetcher = Fetcher::new(".").with_shelve(args.shelve); // Fetch all projects (progress indicators handled in fetch.rs) fetcher.fetch_all(&lockfile, &config).await?; diff --git a/src/cli/commands/fork.rs b/src/cli/commands/fork.rs index 1627dc3..1d18966 100644 --- a/src/cli/commands/fork.rs +++ b/src/cli/commands/fork.rs @@ -211,13 +211,12 @@ fn execute_init( .args(["log", "--limit", "1", "--template", ""]) .current_dir(path) .output() + && !output.stdout.is_empty() { - if !output.stdout.is_empty() { - println!( - "Note: Jujutsu repository detected. Make sure to run 'jj git \ - push' to sync changes with remote if needed." - ); - } + println!( + "Note: Jujutsu repository detected. Make sure to run 'jj git \ + push' to sync changes with remote if needed." + ); } }, VcsType::None => { diff --git a/src/cli/commands/import.rs b/src/cli/commands/import.rs index cb6649c..c9a20da 100644 --- a/src/cli/commands/import.rs +++ b/src/cli/commands/import.rs @@ -134,16 +134,19 @@ async fn import_modrinth( { log::info!("Fetching project: {project_id}"); match platform - .request_project_with_files(project_id, &lockfile.mc_versions, &[ - loader.0.clone(), - ]) + .request_project_with_files( + project_id, + &lockfile.mc_versions, + std::slice::from_ref(&loader.0), + ) .await { Ok(mut project) => { // Select best file - if let Err(e) = - project.select_file(&lockfile.mc_versions, &[loader.0.clone()]) - { + if let Err(e) = project.select_file( + &lockfile.mc_versions, + std::slice::from_ref(&loader.0), + ) { log::warn!( "Failed to select file for {}: {}", project.get_name(), @@ -357,7 +360,7 @@ async fn import_curseforge( description: None, author: manifest["author"] .as_str() - .map(|s| s.to_string()), + .map(std::string::ToString::to_string), overrides: vec!["overrides".to_string()], server_overrides: None, client_overrides: None, diff --git a/src/cli/commands/inspect.rs b/src/cli/commands/inspect.rs index 7ed263d..75378e0 100644 --- a/src/cli/commands/inspect.rs +++ b/src/cli/commands/inspect.rs @@ -176,7 +176,7 @@ fn display_project_inspection( // Display project files println!(); - display_project_files(&project.files)?; + display_project_files(&project.files, project)?; // Display properties println!(); @@ -228,7 +228,10 @@ fn display_project_header(project: &Project) -> Result<()> { Ok(()) } -fn display_project_files(files: &[ProjectFile]) -> Result<()> { +fn display_project_files( + files: &[ProjectFile], + project: &Project, +) -> Result<()> { if files.is_empty() { println!("{}", "No files available".yellow()); return Ok(()); @@ -250,19 +253,31 @@ fn display_project_files(files: &[ProjectFile]) -> Result<()> { format!(" {status}") }; - // File path line + // File path line with optional site URL let file_path = format!("{}={}", file.file_type, file.file_name); - table.add_row(vec![ - Cell::new(format!("{file_path}:{status_text}")).fg(if idx == 0 { - Color::Green - } else { - Color::White - }), - ]); + let file_display = if let Some(site_url) = file.get_site_url(project) { + // Create hyperlink for the file + let hyperlink = crate::ui_utils::hyperlink(&site_url, &file_path); + format!("{hyperlink}:{status_text}") + } else { + format!("{file_path}:{status_text}") + }; + + table.add_row(vec![Cell::new(file_display).fg(if idx == 0 { + Color::Green + } else { + Color::White + })]); // Date published table.add_row(vec![Cell::new(&file.date_published).fg(Color::DarkGrey)]); + // Show site URL if available (for non-hyperlink terminals) + if let Some(site_url) = file.get_site_url(project) { + table + .add_row(vec![Cell::new(format!("URL: {site_url}")).fg(Color::Blue)]); + } + // Empty line table.add_row(vec![Cell::new("")]); diff --git a/src/cli/commands/ls.rs b/src/cli/commands/ls.rs index d225e04..4b9c1c3 100644 --- a/src/cli/commands/ls.rs +++ b/src/cli/commands/ls.rs @@ -2,6 +2,18 @@ use std::path::Path; use crate::{cli::LsArgs, error::Result, model::LockFile}; +/// Truncate a name to fit within `max_len` characters, adding "..." if +/// truncated +fn truncate_name(name: &str, max_len: usize) -> String { + if name.len() <= max_len { + name.to_string() + } else if max_len > 3 { + format!("{}...", &name[..max_len - 3]) + } else { + name[..max_len].to_string() + } +} + pub fn execute(args: LsArgs, lockfile_path: &Path) -> Result<()> { // Load expects directory path, so get parent directory let lockfile_dir = lockfile_path.parent().unwrap_or(Path::new(".")); @@ -15,10 +27,33 @@ pub fn execute(args: LsArgs, lockfile_path: &Path) -> Result<()> { println!("Installed projects ({}):", lockfile.projects.len()); println!(); + // Calculate max name length for alignment + let max_name_len = args.name_max_length.unwrap_or_else(|| { + lockfile + .projects + .iter() + .map(|p| p.get_name().len()) + .max() + .unwrap_or(20) + .min(50) + }); + for project in &lockfile.projects { + // Check for version mismatch across providers + let version_warning = if project.versions_match_across_providers() { + "" + } else { + // Use the detailed check_version_mismatch for logging + if let Some(mismatch_detail) = project.check_version_mismatch() { + log::warn!("{mismatch_detail}"); + } + " [!] versions do not match across providers" + }; + if args.detailed { let id = project.pakku_id.as_deref().unwrap_or("unknown"); - println!(" {} ({})", project.get_name(), id); + let name = truncate_name(&project.get_name(), max_name_len); + println!(" {name} ({id}){version_warning}"); println!(" Type: {:?}", project.r#type); println!(" Side: {:?}", project.side); @@ -30,19 +65,28 @@ pub fn execute(args: LsArgs, lockfile_path: &Path) -> Result<()> { ); } + // Show version details if there's a mismatch + if !version_warning.is_empty() { + println!(" Provider versions:"); + for file in &project.files { + println!(" {}: {}", file.file_type, file.file_name); + } + } + if !project.pakku_links.is_empty() { println!(" Dependencies: {}", project.pakku_links.len()); } println!(); } else { + let name = truncate_name(&project.get_name(), max_name_len); let file_info = project .files .first() .map(|f| format!(" ({})", f.file_name)) .unwrap_or_default(); - println!(" {}{}", project.get_name(), file_info); + println!(" {name}{file_info}{version_warning}"); } } diff --git a/src/cli/commands/remote_update.rs b/src/cli/commands/remote_update.rs index a4ddbba..873dbe9 100644 --- a/src/cli/commands/remote_update.rs +++ b/src/cli/commands/remote_update.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use crate::{cli::RemoteUpdateArgs, error::PakkerError, git, model::Config}; @@ -71,7 +71,7 @@ pub async fn execute(args: RemoteUpdateArgs) -> Result<(), PakkerError> { } /// Sync override files from remote directory to current directory -async fn sync_overrides(remote_dir: &PathBuf) -> Result<(), PakkerError> { +async fn sync_overrides(remote_dir: &Path) -> Result<(), PakkerError> { let remote_config_path = remote_dir.join("pakku.json"); if !remote_config_path.exists() { return Ok(()); diff --git a/src/cli/commands/status.rs b/src/cli/commands/status.rs index 41f84d7..6b50ed6 100644 --- a/src/cli/commands/status.rs +++ b/src/cli/commands/status.rs @@ -6,7 +6,7 @@ use tokio::sync::Semaphore; use yansi::Paint; use crate::{ - error::Result, + error::{ErrorSeverity, Result}, model::{Config, LockFile, Project}, platform::create_platform, }; @@ -36,13 +36,42 @@ pub async fn execute( // Display results display_update_results(&updates); - // Display errors if any + // Display errors if any, categorized by severity if !errors.is_empty() { println!(); - println!("{}", "Errors encountered:".red()); - for (project, error) in &errors { - println!(" - {}: {}", project.yellow(), error.red()); + + // Categorize errors by severity + let (warnings, errors_only): (Vec<_>, Vec<_>) = + errors.iter().partition(|(_, err)| { + // Network errors and "not found" are warnings (non-fatal) + err.contains("Failed to check") || err.contains("not found") + }); + + // Display warnings (ErrorSeverity::Warning) + if !warnings.is_empty() { + let severity = ErrorSeverity::Warning; + println!("{}", format_severity_header(severity, "Warnings")); + for (project, error) in &warnings { + println!(" - {}: {}", project.yellow(), error.dim()); + } } + + // Display errors (ErrorSeverity::Error) + if !errors_only.is_empty() { + let severity = ErrorSeverity::Error; + println!("{}", format_severity_header(severity, "Errors")); + for (project, error) in &errors_only { + println!(" - {}: {}", project.yellow(), error.red()); + } + } + + // Log info level summary + let _info_severity = ErrorSeverity::Info; + log::info!( + "Update check completed with {} warning(s) and {} error(s)", + warnings.len(), + errors_only.len() + ); } // Prompt to update if there are updates available @@ -52,6 +81,7 @@ pub async fn execute( // Call update command programmatically (update all projects) let update_args = crate::cli::UpdateArgs { inputs: vec![], + all: true, yes: true, // Auto-yes for status command }; crate::cli::commands::update::execute( @@ -368,3 +398,12 @@ fn get_api_key(platform: &str) -> Option { _ => None, } } + +/// Format severity header with appropriate color +fn format_severity_header(severity: ErrorSeverity, label: &str) -> String { + match severity { + ErrorSeverity::Error => format!("{label}:").red().to_string(), + ErrorSeverity::Warning => format!("{label}:").yellow().to_string(), + ErrorSeverity::Info => format!("{label}:").cyan().to_string(), + } +} diff --git a/src/cli/commands/update.rs b/src/cli/commands/update.rs index 88f5a43..f33caf2 100644 --- a/src/cli/commands/update.rs +++ b/src/cli/commands/update.rs @@ -4,10 +4,10 @@ use indicatif::{ProgressBar, ProgressStyle}; use crate::{ cli::UpdateArgs, - error::PakkerError, - model::{Config, LockFile}, + error::{MultiError, PakkerError}, + model::{Config, LockFile, UpdateStrategy}, platform::create_platform, - ui_utils::prompt_select, + ui_utils::{prompt_select, prompt_typo_suggestion, prompt_yes_no}, }; pub async fn execute( @@ -33,6 +33,22 @@ pub async fn execute( platforms.insert("curseforge".to_string(), platform); } + // Collect all known project identifiers for typo suggestions + let all_slugs: Vec = lockfile + .projects + .iter() + .flat_map(|p| { + let mut ids = Vec::new(); + if let Some(ref pakku_id) = p.pakku_id { + ids.push(pakku_id.clone()); + } + ids.extend(p.slug.values().cloned()); + ids.extend(p.name.values().cloned()); + ids.extend(p.aliases.iter().cloned()); + ids + }) + .collect(); + let project_indices: Vec<_> = if args.inputs.is_empty() { (0..lockfile.projects.len()).collect() } else { @@ -46,14 +62,29 @@ pub async fn execute( { indices.push(idx); } else { + // Try typo suggestion + if let Ok(Some(suggestion)) = prompt_typo_suggestion(input, &all_slugs) + && let Some((idx, _)) = lockfile + .projects + .iter() + .enumerate() + .find(|(_, p)| p.matches_input(&suggestion)) + { + log::info!("Using suggested project: {suggestion}"); + indices.push(idx); + continue; + } return Err(PakkerError::ProjectNotFound(input.clone())); } } indices }; + // Capture count before consuming the iterator + let total_projects = project_indices.len(); + // Create progress bar - let pb = ProgressBar::new(project_indices.len() as u64); + let pb = ProgressBar::new(total_projects as u64); pb.set_style( ProgressStyle::default_bar() .template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}") @@ -61,8 +92,23 @@ pub async fn execute( .progress_chars("#>-"), ); + let mut skipped_pinned = 0; + let mut update_errors = MultiError::new(); + for idx in project_indices { let old_project = &lockfile.projects[idx]; + + // Skip projects with UpdateStrategy::None (pinned) + if old_project.update_strategy == UpdateStrategy::None { + pb.println(format!( + " {} - Skipped (update strategy: NONE)", + old_project.get_name() + )); + skipped_pinned += 1; + pb.inc(1); + continue; + } + pb.set_message(format!("Updating {}...", old_project.get_name())); let slug = old_project @@ -87,54 +133,116 @@ pub async fn execute( } } + if updated_project.is_none() { + // Failed to fetch update info from any platform + update_errors.push(PakkerError::PlatformApiError(format!( + "Failed to check updates for '{}'", + old_project.get_name() + ))); + pb.inc(1); + continue; + } + if let Some(mut updated_project) = updated_project && !updated_project.files.is_empty() && let Some(old_file) = lockfile.projects[idx].files.first() { - let new_file = updated_project.files.first().unwrap(); + // Clone data needed for comparisons to avoid borrow issues + let new_file_id = updated_project.files.first().unwrap().id.clone(); + let new_file_name = + updated_project.files.first().unwrap().file_name.clone(); + let old_file_name = old_file.file_name.clone(); + let project_name = old_project.get_name(); - if new_file.id == old_file.id { - pb.println(format!( - " {} - Already up to date", - old_project.get_name() - )); + if new_file_id == old_file.id { + pb.println(format!(" {project_name} - Already up to date")); } else { - // Interactive version selection if not using --yes flag - if !args.yes && updated_project.files.len() > 1 { + // Interactive confirmation and version selection if not using --yes + // flag + let mut should_update = args.yes || args.all; + let mut selected_idx: Option = None; + + if !args.yes && !args.all { pb.suspend(|| { - let choices: Vec = updated_project - .files - .iter() - .map(|f| format!("{} ({})", f.file_name, f.id)) - .collect(); + // First, confirm the update + let prompt_msg = format!( + "Update '{project_name}' from {old_file_name} to \ + {new_file_name}?" + ); + should_update = prompt_yes_no(&prompt_msg, true).unwrap_or(false); - let choice_refs: Vec<&str> = - choices.iter().map(std::string::String::as_str).collect(); + // If confirmed and multiple versions available, offer selection + if should_update && updated_project.files.len() > 1 { + let choices: Vec = updated_project + .files + .iter() + .map(|f| format!("{} ({})", f.file_name, f.id)) + .collect(); - if let Ok(selected_idx) = prompt_select( - &format!("Select version for {}:", old_project.get_name()), - &choice_refs, - ) { - // Move selected file to front - if selected_idx > 0 { - updated_project.files.swap(0, selected_idx); + let choice_refs: Vec<&str> = + choices.iter().map(std::string::String::as_str).collect(); + + if let Ok(idx) = prompt_select( + &format!("Select version for {project_name}:"), + &choice_refs, + ) { + selected_idx = Some(idx); } } }); } - let selected_file = updated_project.files.first().unwrap(); - pb.println(format!( - " {} -> {}", - old_file.file_name, selected_file.file_name - )); - lockfile.projects[idx] = updated_project; + // Apply file selection outside the closure + if let Some(idx) = selected_idx + && idx > 0 + { + updated_project.files.swap(0, idx); + } + + if should_update { + let selected_file = updated_project.files.first().unwrap(); + pb.println(format!( + " {} -> {}", + old_file_name, selected_file.file_name + )); + lockfile.projects[idx] = updated_project; + } else { + pb.println(format!(" {project_name} - Skipped by user")); + } } } pb.inc(1); } - pb.finish_with_message("Update complete"); + if skipped_pinned > 0 { + pb.finish_with_message(format!( + "Update complete ({skipped_pinned} pinned projects skipped)" + )); + } else { + pb.finish_with_message("Update complete"); + } lockfile.save(lockfile_dir)?; + + // Report any errors that occurred during updates + if !update_errors.is_empty() { + let error_list = update_errors.errors(); + log::warn!( + "{} project(s) encountered errors during update check", + error_list.len() + ); + for err in error_list { + log::warn!(" - {err}"); + } + + // Extend with any additional collected errors and check if we should fail + let all_errors = update_errors.into_errors(); + if all_errors.len() == total_projects { + // All projects failed - return error + let mut multi = MultiError::new(); + multi.extend(all_errors); + return multi.into_result(()); + } + } + Ok(()) } diff --git a/src/git/mod.rs b/src/git/mod.rs index f2b93a9..70ebded 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -288,10 +288,9 @@ pub fn detect_vcs_type>(path: P) -> VcsType { .args(["root"]) .current_dir(path) .output() + && output.status.success() { - if output.status.success() { - return VcsType::Jujutsu; - } + return VcsType::Jujutsu; } // Check for git @@ -299,10 +298,9 @@ pub fn detect_vcs_type>(path: P) -> VcsType { .args(["rev-parse", "--show-toplevel"]) .current_dir(path) .output() + && output.status.success() { - if output.status.success() { - return VcsType::Git; - } + return VcsType::Git; } VcsType::None @@ -333,7 +331,7 @@ pub fn repo_has_uncommitted_changes>(path: P) -> Result { .current_dir(path) .output() .map_err(|e| { - PakkerError::GitError(format!("Failed to run jj status: {}", e)) + PakkerError::GitError(format!("Failed to run jj status: {e}")) })?; let output_str = String::from_utf8_lossy(&output.stdout); diff --git a/src/http.rs b/src/http.rs new file mode 100644 index 0000000..17ba51f --- /dev/null +++ b/src/http.rs @@ -0,0 +1,16 @@ +use std::time::Duration; + +use reqwest::Client; + +pub fn create_http_client() -> Client { + Client::builder() + .pool_max_idle_per_host(10) + .pool_idle_timeout(Duration::from_secs(30)) + .tcp_keepalive(Duration::from_secs(60)) + .tcp_nodelay(true) + .connect_timeout(Duration::from_secs(15)) + .timeout(Duration::from_secs(30)) + .user_agent("Pakker/0.1.0") + .build() + .expect("Failed to build HTTP client") +} diff --git a/src/main.rs b/src/main.rs index eee865b..aa7a529 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,15 @@ +// Allow pre-existing clippy warnings for functions with many arguments +// and complex types that would require significant refactoring +#![allow(clippy::too_many_arguments)] +#![allow(clippy::type_complexity)] +#![allow(clippy::large_enum_variant)] + mod cli; mod error; mod export; mod fetch; mod git; +mod http; mod ipc; mod model; mod platform; @@ -17,8 +24,6 @@ use clap::Parser; use cli::{Cli, Commands}; use error::PakkerError; -use crate::rate_limiter::RateLimiter; - #[tokio::main] async fn main() -> Result<(), PakkerError> { let cli = Cli::parse(); @@ -42,8 +47,6 @@ async fn main() -> Result<(), PakkerError> { let lockfile_path = working_dir.join("pakker-lock.json"); let config_path = working_dir.join("pakker.json"); - let _rate_limiter = std::sync::Arc::new(RateLimiter::new(None)); - match cli.command { Commands::Init(args) => { cli::commands::init::execute(args, &lockfile_path, &config_path).await diff --git a/src/model/project.rs b/src/model/project.rs index 96b98a6..75fa917 100644 --- a/src/model/project.rs +++ b/src/model/project.rs @@ -192,15 +192,29 @@ impl Project { return true; } - // Check if all providers have the same latest file name - // (simplified check - in reality would compare semantic versions) - let file_names: Vec<_> = versions_by_provider + // Compare semantic versions extracted from file names + let parse_version = |name: &str| { + // Try to extract version from patterns like "mod-1.0.0.jar" or + // "mod_v1.0.0" + let version_str = name + .rsplit_once('-') + .and_then(|(_, v)| v.strip_suffix(".jar")) + .or_else(|| { + name + .rsplit_once('_') + .and_then(|(_, v)| v.strip_suffix(".jar")) + }) + .unwrap_or(name); + semver::Version::parse(version_str).ok() + }; + + let versions: Vec<_> = versions_by_provider .values() - .filter_map(|files| files.first().copied()) + .filter_map(|files| files.first().copied().and_then(parse_version)) .collect(); - // All file names should be the same for versions to match - file_names.windows(2).all(|w| w[0] == w[1]) + // All versions should be the same + versions.windows(2).all(|w| w[0] == w[1]) } /// Check if versions do NOT match across providers. diff --git a/src/platform.rs b/src/platform.rs index af4a9a0..c9e0589 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -8,13 +8,19 @@ use std::sync::Arc; pub use curseforge::CurseForgePlatform; pub use github::GitHubPlatform; pub use modrinth::ModrinthPlatform; -use once_cell::sync::Lazy; pub use traits::PlatformClient; -use crate::{error::Result, rate_limiter::RateLimiter}; +use crate::{error::Result, http, rate_limiter::RateLimiter}; -static RATE_LIMITER: Lazy> = - Lazy::new(|| Arc::new(RateLimiter::new(None))); +static HTTP_CLIENT: std::sync::LazyLock> = + std::sync::LazyLock::new(|| Arc::new(http::create_http_client())); + +static RATE_LIMITER: std::sync::LazyLock> = + std::sync::LazyLock::new(|| Arc::new(RateLimiter::new(None))); + +pub fn get_http_client() -> Arc { + HTTP_CLIENT.clone() +} pub fn create_platform( platform: &str, @@ -34,9 +40,21 @@ fn create_client( api_key: Option, ) -> Result> { match platform { - "modrinth" => Ok(Box::new(ModrinthPlatform::new())), - "curseforge" => Ok(Box::new(CurseForgePlatform::new(api_key))), - "github" => Ok(Box::new(GitHubPlatform::new(api_key))), + "modrinth" => { + Ok(Box::new(ModrinthPlatform::with_client(get_http_client()))) + }, + "curseforge" => { + Ok(Box::new(CurseForgePlatform::with_client( + get_http_client(), + api_key, + ))) + }, + "github" => { + Ok(Box::new(GitHubPlatform::with_client( + get_http_client(), + api_key, + ))) + }, _ => { Err(crate::error::PakkerError::ConfigError(format!( "Unknown platform: {platform}" diff --git a/src/platform/curseforge.rs b/src/platform/curseforge.rs index 5419501..d36efee 100644 --- a/src/platform/curseforge.rs +++ b/src/platform/curseforge.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::{collections::HashMap, sync::Arc}; use async_trait::async_trait; use reqwest::Client; @@ -12,21 +12,30 @@ use crate::{ }; const CURSEFORGE_API_BASE: &str = "https://api.curseforge.com/v1"; +/// CurseForge game version type ID for loader versions (e.g., "fabric", +/// "forge") const LOADER_VERSION_TYPE_ID: i32 = 68441; +/// CurseForge relation type ID for "required dependency" (mod embeds or +/// requires another mod) +const DEPENDENCY_RELATION_TYPE_REQUIRED: u32 = 3; pub struct CurseForgePlatform { - client: Client, + client: Arc, api_key: Option, } impl CurseForgePlatform { pub fn new(api_key: Option) -> Self { Self { - client: Client::new(), + client: Arc::new(Client::new()), api_key, } } + pub fn with_client(client: Arc, api_key: Option) -> Self { + Self { client, api_key } + } + fn get_headers(&self) -> Result { let mut headers = reqwest::header::HeaderMap::new(); @@ -66,11 +75,81 @@ impl CurseForgePlatform { } } + /// Determine project side based on `CurseForge` categories. + /// `CurseForge` doesn't have explicit client/server fields like Modrinth, + /// so we infer from category names and IDs. + fn detect_side_from_categories( + categories: &[CurseForgeCategory], + ) -> ProjectSide { + // Known client-only category indicators (slugs and partial name matches) + const CLIENT_INDICATORS: &[&str] = &[ + "client", + "hud", + "gui", + "cosmetic", + "shader", + "optifine", + "resource-pack", + "texture", + "minimap", + "tooltip", + "inventory", + "quality-of-life", // Often client-side QoL + ]; + + // Known server-only category indicators + const SERVER_INDICATORS: &[&str] = &[ + "server-utility", + "bukkit", + "spigot", + "paper", + "admin-tools", + "anti-grief", + "economy", + "permissions", + "chat", + ]; + + let mut client_score = 0; + let mut server_score = 0; + + for category in categories { + let slug_lower = category.slug.to_lowercase(); + let name_lower = category.name.to_lowercase(); + + for indicator in CLIENT_INDICATORS { + if slug_lower.contains(indicator) || name_lower.contains(indicator) { + client_score += 1; + } + } + + for indicator in SERVER_INDICATORS { + if slug_lower.contains(indicator) || name_lower.contains(indicator) { + server_score += 1; + } + } + } + + // Only assign a specific side if there's clear indication + // and not conflicting signals + if client_score > 0 && server_score == 0 { + ProjectSide::Client + } else if server_score > 0 && client_score == 0 { + ProjectSide::Server + } else { + // Default to Both - works on both client and server + ProjectSide::Both + } + } + fn convert_project(&self, cf_project: CurseForgeProject) -> Project { let pakku_id = generate_pakku_id(); let project_type = Self::map_class_id(cf_project.class_id.unwrap_or(6)); - let mut project = Project::new(pakku_id, project_type, ProjectSide::Both); + // Detect side from categories + let side = Self::detect_side_from_categories(&cf_project.categories); + + let mut project = Project::new(pakku_id, project_type, side); project.add_platform( "curseforge".to_string(), @@ -124,7 +203,7 @@ impl CurseForgePlatform { required_dependencies: cf_file .dependencies .iter() - .filter(|d| d.relation_type == 3) + .filter(|d| d.relation_type == DEPENDENCY_RELATION_TYPE_REQUIRED) .map(|d| d.mod_id.to_string()) .collect(), size: cf_file.file_length, @@ -317,11 +396,20 @@ impl PlatformClient for CurseForgePlatform { // CurseForge API models #[derive(Debug, Clone, Deserialize, Serialize)] struct CurseForgeProject { - id: u32, - name: String, - slug: String, + id: u32, + name: String, + slug: String, #[serde(rename = "classId")] - class_id: Option, + class_id: Option, + #[serde(default)] + categories: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct CurseForgeCategory { + id: u32, + name: String, + slug: String, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -381,3 +469,112 @@ struct CurseForgeFilesResponse { struct CurseForgeSearchResponse { data: Vec, } + +#[cfg(test)] +mod tests { + use super::*; + + fn make_category(id: u32, name: &str, slug: &str) -> CurseForgeCategory { + CurseForgeCategory { + id, + name: name.to_string(), + slug: slug.to_string(), + } + } + + #[test] + fn test_detect_side_client_only() { + // HUD mod should be client-only + let categories = vec![ + make_category(1, "HUD Mods", "hud"), + make_category(2, "Fabric", "fabric"), + ]; + let side = CurseForgePlatform::detect_side_from_categories(&categories); + assert_eq!(side, ProjectSide::Client); + } + + #[test] + fn test_detect_side_server_only() { + // Server utility should be server-only + let categories = vec![ + make_category(1, "Server Utility", "server-utility"), + make_category(2, "Bukkit Plugins", "bukkit"), + ]; + let side = CurseForgePlatform::detect_side_from_categories(&categories); + assert_eq!(side, ProjectSide::Server); + } + + #[test] + fn test_detect_side_both() { + // Generic mod categories should be both + let categories = vec![ + make_category(1, "Technology", "technology"), + make_category(2, "Fabric", "fabric"), + ]; + let side = CurseForgePlatform::detect_side_from_categories(&categories); + assert_eq!(side, ProjectSide::Both); + } + + #[test] + fn test_detect_side_conflicting_signals() { + // Mixed categories should default to both + let categories = vec![ + make_category(1, "Client HUD", "client-hud"), + make_category(2, "Server Utility", "server-utility"), + ]; + let side = CurseForgePlatform::detect_side_from_categories(&categories); + assert_eq!(side, ProjectSide::Both); + } + + #[test] + fn test_detect_side_empty_categories() { + let categories = vec![]; + let side = CurseForgePlatform::detect_side_from_categories(&categories); + assert_eq!(side, ProjectSide::Both); + } + + #[test] + fn test_detect_side_gui_client() { + let categories = + vec![make_category(1, "GUI Enhancement", "gui-enhancement")]; + let side = CurseForgePlatform::detect_side_from_categories(&categories); + assert_eq!(side, ProjectSide::Client); + } + + #[test] + fn test_detect_side_permissions_server() { + let categories = vec![make_category(1, "Permissions", "permissions")]; + let side = CurseForgePlatform::detect_side_from_categories(&categories); + assert_eq!(side, ProjectSide::Server); + } + + #[test] + fn test_map_class_id() { + assert_eq!(CurseForgePlatform::map_class_id(6), ProjectType::Mod); + assert_eq!( + CurseForgePlatform::map_class_id(12), + ProjectType::ResourcePack + ); + assert_eq!( + CurseForgePlatform::map_class_id(6945), + ProjectType::DataPack + ); + assert_eq!(CurseForgePlatform::map_class_id(6552), ProjectType::Shader); + assert_eq!(CurseForgePlatform::map_class_id(17), ProjectType::World); + assert_eq!(CurseForgePlatform::map_class_id(9999), ProjectType::Mod); // Unknown + } + + #[test] + fn test_map_release_type() { + assert_eq!( + CurseForgePlatform::map_release_type(1), + ReleaseType::Release + ); + assert_eq!(CurseForgePlatform::map_release_type(2), ReleaseType::Beta); + assert_eq!(CurseForgePlatform::map_release_type(3), ReleaseType::Alpha); + assert_eq!( + CurseForgePlatform::map_release_type(99), + ReleaseType::Release + ); // Unknown + } +} diff --git a/src/platform/github.rs b/src/platform/github.rs index cfc4f65..0c7a735 100644 --- a/src/platform/github.rs +++ b/src/platform/github.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::{collections::HashMap, sync::Arc}; use async_trait::async_trait; use regex::Regex; @@ -20,9 +20,9 @@ pub struct GitHubPlatform { } impl GitHubPlatform { - pub fn new(token: Option) -> Self { + pub fn with_client(client: Arc, token: Option) -> Self { Self { - client: Client::new(), + client: (*client).clone(), token, } } diff --git a/src/platform/modrinth.rs b/src/platform/modrinth.rs index 34b3790..69f81a2 100644 --- a/src/platform/modrinth.rs +++ b/src/platform/modrinth.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::{collections::HashMap, sync::Arc}; use async_trait::async_trait; use reqwest::Client; @@ -14,16 +14,76 @@ use crate::{ const MODRINTH_API_BASE: &str = "https://api.modrinth.com/v2"; pub struct ModrinthPlatform { - client: Client, + client: Arc, } impl ModrinthPlatform { pub fn new() -> Self { Self { - client: Client::new(), + client: Arc::new(Client::new()), } } + pub fn with_client(client: Arc) -> Self { + Self { client } + } + + async fn request_project_url(&self, url: &str) -> Result { + let response = self.client.get(url).send().await?; + if !response.status().is_success() { + return Err(PakkerError::ProjectNotFound(url.to_string())); + } + let mr_project: ModrinthProject = response.json().await?; + Ok(self.convert_project(mr_project)) + } + + async fn request_project_files_url( + &self, + url: &str, + ) -> Result> { + let response = self.client.get(url).send().await?; + if !response.status().is_success() { + return Err(PakkerError::ProjectNotFound(url.to_string())); + } + let mr_versions: Vec = response.json().await?; + let project_id = url + .split('/') + .nth(4) + .ok_or_else(|| { + PakkerError::InvalidResponse( + "Cannot parse project ID from URL".to_string(), + ) + })? + .to_string(); + Ok( + mr_versions + .into_iter() + .map(|v| self.convert_version(v, &project_id)) + .collect(), + ) + } + + async fn lookup_by_hash_url(&self, url: &str) -> Result> { + let response = self.client.get(url).send().await?; + if response.status().as_u16() == 404 { + return Ok(None); + } + if !response.status().is_success() { + return Err(PakkerError::PlatformApiError(format!( + "Modrinth API error: {}", + response.status() + ))); + } + let version_data: serde_json::Value = response.json().await?; + let project_id = version_data["project_id"].as_str().ok_or_else(|| { + PakkerError::InvalidResponse("Missing project_id".to_string()) + })?; + self + .request_project_with_files(project_id, &[], &[]) + .await + .map(Some) + } + fn map_project_type(type_str: &str) -> ProjectType { match type_str { "mod" => ProjectType::Mod, @@ -123,15 +183,7 @@ impl PlatformClient for ModrinthPlatform { _loaders: &[String], ) -> Result { let url = format!("{MODRINTH_API_BASE}/project/{identifier}"); - - let response = self.client.get(&url).send().await?; - - if !response.status().is_success() { - return Err(PakkerError::ProjectNotFound(identifier.to_string())); - } - - let mr_project: ModrinthProject = response.json().await?; - Ok(self.convert_project(mr_project)) + self.request_project_url(&url).await } async fn request_project_files( @@ -170,20 +222,7 @@ impl PlatformClient for ModrinthPlatform { url.push_str(¶ms.join("&")); } - let response = self.client.get(&url).send().await?; - - if !response.status().is_success() { - return Err(PakkerError::ProjectNotFound(project_id.to_string())); - } - - let mr_versions: Vec = response.json().await?; - - Ok( - mr_versions - .into_iter() - .map(|v| self.convert_version(v, project_id)) - .collect(), - ) + self.request_project_files_url(&url).await } async fn request_project_with_files( @@ -213,30 +252,7 @@ impl PlatformClient for ModrinthPlatform { async fn lookup_by_hash(&self, hash: &str) -> Result> { // Modrinth uses SHA-1 hash for file lookups let url = format!("{MODRINTH_API_BASE}/version_file/{hash}"); - - let response = self.client.get(&url).send().await?; - - if response.status().as_u16() == 404 { - return Ok(None); - } - - if !response.status().is_success() { - return Err(PakkerError::PlatformApiError(format!( - "Modrinth API error: {}", - response.status() - ))); - } - - let version_data: serde_json::Value = response.json().await?; - - let project_id = version_data["project_id"].as_str().ok_or_else(|| { - PakkerError::InvalidResponse("Missing project_id".to_string()) - })?; - - self - .request_project_with_files(project_id, &[], &[]) - .await - .map(Some) + self.lookup_by_hash_url(&url).await } } @@ -280,3 +296,128 @@ struct ModrinthDependency { project_id: Option, dependency_type: String, } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use reqwest::Client; + + use super::*; + + impl ModrinthPlatform { + fn with_raw_client(client: Client) -> Self { + Self { + client: Arc::new(client), + } + } + } + + async fn create_platform_with_mock() + -> (ModrinthPlatform, mockito::ServerGuard) { + let server = mockito::Server::new_async().await; + let client = Client::new(); + let platform = ModrinthPlatform::with_raw_client(client); + (platform, server) + } + + #[tokio::test] + async fn test_request_project_success() { + let (platform, mut server) = create_platform_with_mock().await; + let url = format!("{}/project/test-mod", server.url()); + + let _mock = server + .mock("GET", "/project/test-mod") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{ + "id": "abc123", + "slug": "test-mod", + "title": "Test Mod", + "project_type": "mod", + "client_side": "required", + "server_side": "required" + }"#, + ) + .create(); + + let result = platform.request_project_url(&url).await; + + assert!(result.is_ok()); + let project = result.unwrap(); + assert!(project.get_platform_id("modrinth").is_some()); + } + + #[tokio::test] + async fn test_request_project_not_found() { + let (platform, mut server) = create_platform_with_mock().await; + let url = format!("{}/project/nonexistent", server.url()); + + let _mock = server + .mock("GET", "/project/nonexistent") + .with_status(404) + .create(); + + let result = platform.request_project_url(&url).await; + + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_request_project_files() { + let (platform, mut server) = create_platform_with_mock().await; + let url = format!("{}/project/abc123/version", server.url()); + + let _mock = server + .mock("GET", "/project/abc123/version") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"[ + { + "id": "v1", + "project_id": "abc123", + "name": "Test Mod v1.0.0", + "version_number": "1.0.0", + "game_versions": ["1.20.1"], + "version_type": "release", + "loaders": ["fabric"], + "date_published": "2024-01-01T00:00:00Z", + "files": [{ + "hashes": {"sha1": "abc123def456"}, + "url": "https://example.com/mod.jar", + "filename": "test-mod-1.0.0.jar", + "primary": true, + "size": 1024 + }], + "dependencies": [] + } + ]"#, + ) + .create(); + + let result = platform.request_project_files_url(&url).await; + + assert!(result.is_ok()); + let files = result.unwrap(); + assert_eq!(files.len(), 1); + assert_eq!(files[0].file_name, "test-mod-1.0.0.jar"); + } + + #[tokio::test] + async fn test_lookup_by_hash_not_found() { + let (platform, mut server) = create_platform_with_mock().await; + let url = format!("{}/version_file/unknownhash123", server.url()); + + let _mock = server + .mock("GET", "/version_file/unknownhash123") + .with_status(404) + .create(); + + let result = platform.lookup_by_hash_url(&url).await; + + assert!(result.is_ok()); + assert!(result.unwrap().is_none()); + } +} diff --git a/src/rate_limiter.rs b/src/rate_limiter.rs index dc5b97e..72dde8d 100644 --- a/src/rate_limiter.rs +++ b/src/rate_limiter.rs @@ -80,13 +80,13 @@ impl RateLimiter { platform_requests .retain(|t| now.duration_since(*t) < Duration::from_secs(60)); - if platform_requests.len() >= burst as usize { - if let Some(oldest) = platform_requests.first() { - let wait_time = interval.saturating_sub(now.duration_since(*oldest)); - if wait_time > Duration::ZERO { - drop(inner); - tokio::time::sleep(wait_time).await; - } + if platform_requests.len() >= burst as usize + && let Some(oldest) = platform_requests.first() + { + let wait_time = interval.saturating_sub(now.duration_since(*oldest)); + if wait_time > Duration::ZERO { + drop(inner); + tokio::time::sleep(wait_time).await; } } diff --git a/src/utils/id.rs b/src/utils/id.rs index 062f0dc..c664e7a 100644 --- a/src/utils/id.rs +++ b/src/utils/id.rs @@ -1,4 +1,4 @@ -use rand::Rng; +use rand::RngExt; const CHARSET: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";