Compare commits
18 commits
83343bc3dd
...
a642b976e9
| Author | SHA1 | Date | |
|---|---|---|---|
|
a642b976e9 |
|||
|
61ced09d25 |
|||
|
b93b234fc2 |
|||
|
ace9bcac8a |
|||
|
8b2140c057 |
|||
|
020514cd7a |
|||
|
20ea3c680b |
|||
|
e19df15ae5 |
|||
|
838ba82790 |
|||
|
0048a1cd73 |
|||
|
c0c9d741c1 |
|||
|
5772200da9 |
|||
|
a8bf8f9f3f |
|||
|
530ba8b581 |
|||
|
2c4058b54a |
|||
|
af3cdbf343 |
|||
|
66317d98de |
|||
|
1c08e00ccf |
53 changed files with 1930 additions and 628 deletions
265
Cargo.lock
generated
265
Cargo.lock
generated
|
|
@ -30,9 +30,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "0.6.21"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
|
||||
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
|
|
@ -51,9 +51,9 @@ checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
|
|||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "0.2.7"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
|
||||
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
|
@ -80,9 +80,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.101"
|
||||
version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "assert-json-diff"
|
||||
|
|
@ -160,6 +160,15 @@ dependencies = [
|
|||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.19.1"
|
||||
|
|
@ -228,15 +237,15 @@ version = "0.4.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"crypto-common 0.1.7",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.5.58"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806"
|
||||
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
|
|
@ -244,9 +253,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.5.58"
|
||||
version = "4.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2"
|
||||
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
|
|
@ -256,9 +265,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.5.55"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
|
||||
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
|
|
@ -330,6 +339,12 @@ dependencies = [
|
|||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.4.2"
|
||||
|
|
@ -380,21 +395,6 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc"
|
||||
version = "3.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675"
|
||||
dependencies = [
|
||||
"crc-catalog",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc-catalog"
|
||||
version = "2.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
|
|
@ -437,6 +437,15 @@ dependencies = [
|
|||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deflate64"
|
||||
version = "0.1.10"
|
||||
|
|
@ -470,11 +479,22 @@ version = "0.10.7"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"block-buffer 0.10.4",
|
||||
"crypto-common 0.1.7",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.0",
|
||||
"const-oid",
|
||||
"crypto-common 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.5"
|
||||
|
|
@ -528,9 +548,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "env_logger"
|
||||
version = "0.11.9"
|
||||
version = "0.11.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d"
|
||||
checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
|
|
@ -607,9 +627,9 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
|||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
|
||||
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
|
|
@ -622,9 +642,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
|
||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
|
|
@ -632,15 +652,15 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
|
||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-executor"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
|
||||
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
|
|
@ -649,15 +669,15 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "futures-io"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
|
||||
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
|
||||
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
|
@ -666,21 +686,21 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
|
||||
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
|
||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
|
|
@ -690,7 +710,6 @@ dependencies = [
|
|||
"futures-task",
|
||||
"memchr",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
"slab",
|
||||
]
|
||||
|
||||
|
|
@ -814,7 +833,7 @@ version = "0.12.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -862,6 +881,15 @@ version = "1.0.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.8.1"
|
||||
|
|
@ -1049,9 +1077,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "indicatif"
|
||||
version = "0.18.3"
|
||||
version = "0.18.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9375e112e4b463ec1b1c6c011953545c65a30164fbab5b581df32b3abf0dcb88"
|
||||
checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb"
|
||||
dependencies = [
|
||||
"console",
|
||||
"portable-atomic",
|
||||
|
|
@ -1099,9 +1127,9 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
|||
|
||||
[[package]]
|
||||
name = "jiff"
|
||||
version = "0.2.18"
|
||||
version = "0.2.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e67e8da4c49d6d9909fe03361f9b620f58898859f5c7aded68351e85e71ecf50"
|
||||
checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359"
|
||||
dependencies = [
|
||||
"jiff-static",
|
||||
"log",
|
||||
|
|
@ -1112,9 +1140,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "jiff-static"
|
||||
version = "0.2.18"
|
||||
version = "0.2.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0c84ee7f197eca9a86c6fd6cb771e55eb991632f15f2bc3ca6ec838929e6e78"
|
||||
checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
|
@ -1187,9 +1215,9 @@ checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7"
|
|||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.181"
|
||||
version = "0.2.185"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5"
|
||||
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
|
||||
|
||||
[[package]]
|
||||
name = "libgit2-sys"
|
||||
|
|
@ -1242,9 +1270,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.11.0"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
|
|
@ -1281,22 +1309,21 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
|||
|
||||
[[package]]
|
||||
name = "lzma-rust2"
|
||||
version = "0.15.6"
|
||||
version = "0.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "17f7337d278fec032975dc884152491580dd23750ee957047856735fe0e61ede"
|
||||
checksum = "47bb1e988e6fb779cf720ad431242d3f03167c1b3f2b1aae7f1a94b2495b36ae"
|
||||
dependencies = [
|
||||
"crc",
|
||||
"sha2",
|
||||
"sha2 0.10.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
version = "0.10.6"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
|
||||
checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"digest",
|
||||
"digest 0.11.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1323,9 +1350,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.1.1"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
|
||||
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi",
|
||||
|
|
@ -1418,14 +1445,14 @@ dependencies = [
|
|||
"log",
|
||||
"md-5",
|
||||
"mockito",
|
||||
"rand 0.10.0",
|
||||
"rand 0.10.1",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"sha1 0.11.0",
|
||||
"sha2 0.11.0",
|
||||
"strsim",
|
||||
"tempfile",
|
||||
"textwrap",
|
||||
|
|
@ -1465,7 +1492,7 @@ version = "0.12.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
"hmac",
|
||||
]
|
||||
|
||||
|
|
@ -1550,9 +1577,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.105"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
|
@ -1615,9 +1642,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.43"
|
||||
version = "1.0.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
|
@ -1640,9 +1667,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.0"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8"
|
||||
checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.1",
|
||||
|
|
@ -1774,9 +1801,9 @@ checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
|||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.3"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"errno",
|
||||
|
|
@ -1921,9 +1948,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.27"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
|
|
@ -1988,7 +2015,18 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
|||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
"digest 0.11.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1999,7 +2037,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
|||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
"digest 0.11.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2056,12 +2105,12 @@ checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c"
|
|||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.1"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881"
|
||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2084,9 +2133,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
|||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.114"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
|
@ -2136,9 +2185,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.25.0"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.1",
|
||||
|
|
@ -2245,9 +2294,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
|||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.49.0"
|
||||
version = "1.52.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
|
||||
checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
|
|
@ -2262,9 +2311,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.6.0"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
|
||||
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
|
@ -3090,20 +3139,6 @@ name = "zeroize"
|
|||
version = "1.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||
dependencies = [
|
||||
"zeroize_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize_derive"
|
||||
version = "1.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
|
|
@ -3140,9 +3175,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "7.4.0"
|
||||
version = "8.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc12baa6db2b15a140161ce53d72209dacea594230798c24774139b54ecaa980"
|
||||
checksum = "dcab981e19633ebcf0b001ddd37dd802996098bc1864f90b7c5d970ce76c1d59"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"bzip2",
|
||||
|
|
@ -3157,7 +3192,7 @@ dependencies = [
|
|||
"memchr",
|
||||
"pbkdf2",
|
||||
"ppmd-rust",
|
||||
"sha1",
|
||||
"sha1 0.10.6",
|
||||
"time",
|
||||
"typed-path",
|
||||
"zeroize",
|
||||
|
|
|
|||
81
Cargo.toml
81
Cargo.toml
|
|
@ -3,45 +3,86 @@ name = "pakker"
|
|||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
authors = [ "NotAShelf <raf@notashelf.dev>" ]
|
||||
rust-version = "1.91.0"
|
||||
description = "A fast, reliable multiplatform modpack manager for Minecraft"
|
||||
keywords = [ "minecraft", "modpack", "modrinth", "curseforge", "package-manager" ]
|
||||
categories = [ "command-line-utilities", "games" ]
|
||||
rust-version = "1.94.0"
|
||||
readme = true
|
||||
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.101"
|
||||
anyhow = "1.0.102"
|
||||
async-trait = "0.1.89"
|
||||
clap = { version = "4.5.58", features = [ "derive" ] }
|
||||
clap = { version = "4.6.1", features = [ "derive" ] }
|
||||
comfy-table = "7.2.2"
|
||||
dialoguer = "0.12.0"
|
||||
env_logger = "0.11.9"
|
||||
futures = "0.3.31"
|
||||
env_logger = "0.11.10"
|
||||
futures = "0.3.32"
|
||||
git2 = "0.20.4"
|
||||
glob = "0.3.3"
|
||||
indicatif = "0.18.3"
|
||||
indicatif = "0.18.4"
|
||||
keyring = "3.6.3"
|
||||
libc = "0.2.181"
|
||||
libc = "0.2.185"
|
||||
log = "0.4.29"
|
||||
md-5 = "0.10.6"
|
||||
rand = "0.10.0"
|
||||
md-5 = "0.11.0"
|
||||
rand = "0.10.1"
|
||||
regex = "1.12.3"
|
||||
reqwest = { version = "0.13.2", features = [ "json" ] }
|
||||
semver = "1.0.27"
|
||||
semver = "1.0.28"
|
||||
serde = { version = "1.0.228", features = [ "derive" ] }
|
||||
serde_json = "1.0.149"
|
||||
sha1 = "0.10.6"
|
||||
sha2 = "0.10.9"
|
||||
sha1 = "0.11.0"
|
||||
sha2 = "0.11.0"
|
||||
strsim = "0.11.1"
|
||||
tempfile = "3.25.0"
|
||||
tempfile = "3.27.0"
|
||||
textwrap = "0.16.2"
|
||||
thiserror = "2.0.18"
|
||||
tokio = { version = "1.49.0", features = [ "full" ] }
|
||||
tokio = { version = "1.52.1", features = [ "full" ] }
|
||||
walkdir = "2.5.0"
|
||||
yansi = "1.0.1"
|
||||
zip = "7.4.0"
|
||||
zip = "8.5.1"
|
||||
|
||||
[dev-dependencies]
|
||||
mockito = "1.7.2"
|
||||
tempfile = "3.25.0"
|
||||
tempfile = "3.27.0"
|
||||
|
||||
[lints.clippy]
|
||||
cargo = { level = "warn", priority = -1 }
|
||||
complexity = { level = "warn", priority = -1 }
|
||||
nursery = { level = "warn", priority = -1 }
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
perf = { level = "warn", priority = -1 }
|
||||
style = { level = "warn", priority = -1 }
|
||||
|
||||
# The lint groups above enable some less-than-desirable rules, we should manually
|
||||
# enable those to keep our sanity.
|
||||
absolute_paths = "allow"
|
||||
arbitrary_source_item_ordering = "allow"
|
||||
enum_variant_names = "allow"
|
||||
implicit_return = "allow"
|
||||
missing_docs_in_private_items = "allow"
|
||||
non_ascii_literal = "allow"
|
||||
pattern_type_mismatch = "allow"
|
||||
print_stdout = "allow"
|
||||
question_mark_used = "allow"
|
||||
similar_names = "allow"
|
||||
single_call_fn = "allow"
|
||||
std_instead_of_core = "allow"
|
||||
struct_excessive_bools = "allow"
|
||||
too_long_first_doc_paragraph = "allow"
|
||||
too_many_lines = "allow"
|
||||
unused_trait_names = "allow"
|
||||
|
||||
# In the honor of a recent Cloudflare regression
|
||||
# Let's get rid of them, what the hell
|
||||
panic = "deny"
|
||||
unwrap_used = "deny"
|
||||
|
||||
# Less dangerous, but we'd like to know
|
||||
expect_used = "warn"
|
||||
todo = "warn"
|
||||
unimplemented = "warn"
|
||||
unreachable = "warn"
|
||||
|
||||
# Optimize crypto stuff. Building them with optimizations makes that build script
|
||||
# run ~5x faster, more than offsetting the additional build time added to the
|
||||
|
|
@ -51,3 +92,11 @@ opt-level = 3
|
|||
|
||||
[profile.dev.package.sha1]
|
||||
opt-level = 3
|
||||
|
||||
[profile.dev.package.md-5]
|
||||
opt-level = 3
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
opt-level = 3
|
||||
strip = true
|
||||
|
|
|
|||
27
build.rs
27
build.rs
|
|
@ -1,27 +0,0 @@
|
|||
use std::fs;
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn create_pakku_symlink() {
|
||||
let exe_path =
|
||||
std::env::current_exe().expect("Failed to get current exe path");
|
||||
let exe_dir = exe_path.parent().expect("Failed to get exe directory");
|
||||
let pakker_path = exe_dir.join("pakker");
|
||||
let pakku_path = exe_dir.join("pakku");
|
||||
|
||||
if pakker_path.exists() {
|
||||
if pakku_path.exists() {
|
||||
let _ = fs::remove_file(&pakku_path);
|
||||
}
|
||||
let _ = std::os::unix::fs::symlink(&pakker_path, &pakku_path);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub fn create_pakku_symlink() {
|
||||
// No-op on non-Unix systems
|
||||
println!("This only works on an Unix system! Skipping Pakku symlink.")
|
||||
}
|
||||
6
flake.lock
generated
6
flake.lock
generated
|
|
@ -2,11 +2,11 @@
|
|||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1770197578,
|
||||
"narHash": "sha256-AYqlWrX09+HvGs8zM6ebZ1pwUqjkfpnv8mewYwAo+iM=",
|
||||
"lastModified": 1776548001,
|
||||
"narHash": "sha256-ZSK0NL4a1BwVbbTBoSnWgbJy9HeZFXLYQizjb2DPF24=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "00c21e4c93d963c50d4c0c89bfa84ed6e0694df2",
|
||||
"rev": "b12141ef619e0a9c1c84dc8c684040326f27cdcc",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ pub enum Commands {
|
|||
Credentials(CredentialsArgs),
|
||||
|
||||
/// Configure modpack properties
|
||||
Cfg(CfgArgs),
|
||||
Cfg(Box<CfgArgs>),
|
||||
|
||||
/// Manage fork configuration
|
||||
Fork(ForkArgs),
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ fn get_loaders(lockfile: &LockFile) -> Vec<String> {
|
|||
}
|
||||
|
||||
pub fn create_all_platforms()
|
||||
-> Result<HashMap<String, Box<dyn crate::platform::PlatformClient>>> {
|
||||
-> HashMap<String, Box<dyn crate::platform::PlatformClient>> {
|
||||
const MODRINTH: &str = "modrinth";
|
||||
const CURSEFORGE: &str = "curseforge";
|
||||
|
||||
|
|
@ -27,7 +27,7 @@ pub fn create_all_platforms()
|
|||
platforms.insert(CURSEFORGE.to_owned(), platform);
|
||||
}
|
||||
|
||||
Ok(platforms)
|
||||
platforms
|
||||
}
|
||||
|
||||
async fn resolve_input(
|
||||
|
|
@ -55,6 +55,10 @@ use std::path::Path;
|
|||
|
||||
use crate::{cli::AddArgs, model::fork::LocalConfig};
|
||||
|
||||
#[expect(
|
||||
clippy::future_not_send,
|
||||
reason = "not required to be Send; only called from single-threaded context"
|
||||
)]
|
||||
pub async fn execute(
|
||||
args: AddArgs,
|
||||
global_yes: bool,
|
||||
|
|
@ -66,8 +70,8 @@ pub async fn execute(
|
|||
|
||||
// Load lockfile
|
||||
// Load expects directory path, so get parent directory
|
||||
let lockfile_dir = lockfile_path.parent().unwrap_or(Path::new("."));
|
||||
let config_dir = config_path.parent().unwrap_or(Path::new("."));
|
||||
let lockfile_dir = lockfile_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let config_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
|
||||
// Check if lockfile exists (try both pakker-lock.json and pakku-lock.json)
|
||||
let lockfile_exists =
|
||||
|
|
@ -110,7 +114,7 @@ pub async fn execute(
|
|||
let parent_lockfile = parent_paths
|
||||
.iter()
|
||||
.find(|path| path.exists())
|
||||
.and_then(|path| LockFile::load(path.parent().unwrap()).ok())
|
||||
.and_then(|path| LockFile::load(path.parent()?).ok())
|
||||
.ok_or_else(|| {
|
||||
PakkerError::IoError(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
|
|
@ -141,7 +145,7 @@ pub async fn execute(
|
|||
let _config = Config::load(config_dir).ok();
|
||||
|
||||
// Create platforms
|
||||
let platforms = create_all_platforms()?;
|
||||
let platforms = create_all_platforms();
|
||||
|
||||
let mut new_projects = Vec::new();
|
||||
let mut errors = MultiError::new();
|
||||
|
|
|
|||
|
|
@ -44,6 +44,14 @@ fn get_loaders(lockfile: &LockFile) -> Vec<String> {
|
|||
lockfile.loaders.keys().cloned().collect()
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::future_not_send,
|
||||
reason = "not required to be Send; only called from single-threaded context"
|
||||
)]
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "CLI command handler maps directly from clap args"
|
||||
)]
|
||||
pub async fn execute(
|
||||
cf_arg: Option<String>,
|
||||
mr_arg: Option<String>,
|
||||
|
|
@ -71,8 +79,8 @@ pub async fn execute(
|
|||
log::info!("Adding project with explicit platform specification");
|
||||
|
||||
// Load lockfile
|
||||
let lockfile_dir = lockfile_path.parent().unwrap_or(Path::new("."));
|
||||
let config_dir = config_path.parent().unwrap_or(Path::new("."));
|
||||
let lockfile_dir = lockfile_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let config_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
|
||||
let mut lockfile = LockFile::load(lockfile_dir)?;
|
||||
|
||||
|
|
@ -258,7 +266,7 @@ pub async fn execute(
|
|||
if !no_deps {
|
||||
log::info!("Resolving dependencies...");
|
||||
|
||||
let platforms = create_all_platforms()?;
|
||||
let platforms = create_all_platforms();
|
||||
let mut resolver = DependencyResolver::new();
|
||||
|
||||
let deps = resolver
|
||||
|
|
@ -304,7 +312,7 @@ pub async fn execute(
|
|||
}
|
||||
|
||||
fn create_all_platforms()
|
||||
-> Result<HashMap<String, Box<dyn crate::platform::PlatformClient>>> {
|
||||
-> HashMap<String, Box<dyn crate::platform::PlatformClient>> {
|
||||
let mut platforms = HashMap::new();
|
||||
|
||||
if let Ok(platform) = create_platform("modrinth", None) {
|
||||
|
|
@ -321,7 +329,7 @@ fn create_all_platforms()
|
|||
platforms.insert("github".to_string(), platform);
|
||||
}
|
||||
|
||||
Ok(platforms)
|
||||
platforms
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ use crate::{
|
|||
ui_utils::prompt_input_optional,
|
||||
};
|
||||
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "CLI command handler maps directly from clap args"
|
||||
)]
|
||||
pub fn execute(
|
||||
config_path: &Path,
|
||||
name: Option<String>,
|
||||
|
|
@ -20,21 +24,27 @@ pub fn execute(
|
|||
worlds_path: Option<String>,
|
||||
shaders_path: Option<String>,
|
||||
) -> Result<()> {
|
||||
let config_dir = config_path.parent().unwrap_or(Path::new("."));
|
||||
let config_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let mut config = Config::load(config_dir)?;
|
||||
let mut changed = false;
|
||||
let mut changed = name.is_some()
|
||||
|| version.is_some()
|
||||
|| description.is_some()
|
||||
|| author.is_some()
|
||||
|| mods_path.is_some()
|
||||
|| resource_packs_path.is_some()
|
||||
|| data_packs_path.is_some()
|
||||
|| worlds_path.is_some()
|
||||
|| shaders_path.is_some();
|
||||
|
||||
// Modpack properties
|
||||
if let Some(new_name) = name {
|
||||
config.name = new_name.clone();
|
||||
config.name.clone_from(&new_name);
|
||||
println!("{}", format!("✓ 'name' set to '{new_name}'").green());
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if let Some(new_version) = version {
|
||||
config.version = new_version.clone();
|
||||
config.version.clone_from(&new_version);
|
||||
println!("{}", format!("✓ 'version' set to '{new_version}'").green());
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if let Some(new_description) = description {
|
||||
|
|
@ -43,20 +53,17 @@ pub fn execute(
|
|||
"{}",
|
||||
format!("✓ 'description' set to '{new_description}'").green()
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if let Some(new_author) = author {
|
||||
config.author = Some(new_author.clone());
|
||||
println!("{}", format!("✓ 'author' set to '{new_author}'").green());
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// Project type paths
|
||||
if let Some(path) = mods_path {
|
||||
config.paths.insert("mod".to_string(), path.clone());
|
||||
println!("{}", format!("✓ 'paths.mod' set to '{path}'").green());
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if let Some(path) = resource_packs_path {
|
||||
|
|
@ -67,25 +74,21 @@ pub fn execute(
|
|||
"{}",
|
||||
format!("✓ 'paths.resource-pack' set to '{path}'").green()
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if let Some(path) = data_packs_path {
|
||||
config.paths.insert("data-pack".to_string(), path.clone());
|
||||
println!("{}", format!("✓ 'paths.data-pack' set to '{path}'").green());
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if let Some(path) = worlds_path {
|
||||
config.paths.insert("world".to_string(), path.clone());
|
||||
println!("{}", format!("✓ 'paths.world' set to '{path}'").green());
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if let Some(path) = shaders_path {
|
||||
config.paths.insert("shader".to_string(), path.clone());
|
||||
println!("{}", format!("✓ 'paths.shader' set to '{path}'").green());
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if !changed {
|
||||
|
|
@ -99,13 +102,13 @@ pub fn execute(
|
|||
|
||||
// Prompt for each configurable field
|
||||
if let Ok(Some(new_name)) = prompt_input_optional(" Name") {
|
||||
config.name = new_name.clone();
|
||||
config.name.clone_from(&new_name);
|
||||
println!("{}", format!(" ✓ 'name' set to '{new_name}'").green());
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if let Ok(Some(new_version)) = prompt_input_optional(" Version") {
|
||||
config.version = new_version.clone();
|
||||
config.version.clone_from(&new_version);
|
||||
println!(
|
||||
"{}",
|
||||
format!(" ✓ 'version' set to '{new_version}'").green()
|
||||
|
|
@ -136,7 +139,7 @@ pub fn execute(
|
|||
}
|
||||
|
||||
// Config::save expects directory path, not file path
|
||||
let config_dir = config_path.parent().unwrap_or(Path::new("."));
|
||||
let config_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
config.save(config_dir)?;
|
||||
println!("\n{}", "Configuration updated successfully".green().bold());
|
||||
|
||||
|
|
|
|||
|
|
@ -11,10 +11,14 @@ use crate::{
|
|||
},
|
||||
};
|
||||
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "CLI command handler maps directly from clap args"
|
||||
)]
|
||||
pub fn execute(
|
||||
config_path: &Path,
|
||||
lockfile_path: &Path,
|
||||
project: String,
|
||||
project: &str,
|
||||
r#type: Option<&str>,
|
||||
side: Option<&str>,
|
||||
update_strategy: Option<&str>,
|
||||
|
|
@ -24,30 +28,30 @@ pub fn execute(
|
|||
remove_alias: Option<String>,
|
||||
export: Option<bool>,
|
||||
) -> Result<()> {
|
||||
let config_dir = config_path.parent().unwrap_or(Path::new("."));
|
||||
let config_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let mut config = Config::load(config_dir)?;
|
||||
let lockfile_dir = lockfile_path.parent().unwrap_or(Path::new("."));
|
||||
let lockfile_dir = lockfile_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let lockfile = LockFile::load(lockfile_dir)?;
|
||||
|
||||
// Find the project in lockfile to get its pakku_id
|
||||
// Try multiple lookup strategies: pakku_id first, then slug, then name
|
||||
let found_project = lockfile
|
||||
.get_project(&project)
|
||||
.get_project(project)
|
||||
.or_else(|| {
|
||||
// Try to find by slug on any platform
|
||||
lockfile
|
||||
.projects
|
||||
.iter()
|
||||
.find(|p| p.slug.values().any(|s| s.eq_ignore_ascii_case(&project)))
|
||||
.find(|p| p.slug.values().any(|s| s.eq_ignore_ascii_case(project)))
|
||||
})
|
||||
.or_else(|| {
|
||||
// Try to find by name on any platform
|
||||
lockfile
|
||||
.projects
|
||||
.iter()
|
||||
.find(|p| p.name.values().any(|n| n.eq_ignore_ascii_case(&project)))
|
||||
.find(|p| p.name.values().any(|n| n.eq_ignore_ascii_case(project)))
|
||||
})
|
||||
.ok_or_else(|| PakkerError::ProjectNotFound(project.clone()))?;
|
||||
.ok_or_else(|| PakkerError::ProjectNotFound(project.to_string()))?;
|
||||
|
||||
let pakku_id = found_project.pakku_id.as_ref().ok_or_else(|| {
|
||||
PakkerError::InvalidProject("Project has no pakku_id".to_string())
|
||||
|
|
@ -59,7 +63,14 @@ pub fn execute(
|
|||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut changed = false;
|
||||
let changed = r#type.is_some()
|
||||
|| side.is_some()
|
||||
|| update_strategy.is_some()
|
||||
|| redistributable.is_some()
|
||||
|| subpath.is_some()
|
||||
|| add_alias.is_some()
|
||||
|| remove_alias.is_some()
|
||||
|| export.is_some();
|
||||
|
||||
if let Some(type_str) = r#type {
|
||||
let parsed_type = match type_str.to_uppercase().as_str() {
|
||||
|
|
@ -79,7 +90,6 @@ pub fn execute(
|
|||
"{}",
|
||||
format!("✓ 'type' set to '{parsed_type:?}' for '{pakku_id}'").green()
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if let Some(side_str) = side {
|
||||
|
|
@ -98,7 +108,6 @@ pub fn execute(
|
|||
"{}",
|
||||
format!("✓ 'side' set to '{parsed_side:?}' for '{pakku_id}'").green()
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if let Some(strategy_str) = update_strategy {
|
||||
|
|
@ -119,7 +128,6 @@ pub fn execute(
|
|||
)
|
||||
.green()
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if let Some(new_redistributable) = redistributable {
|
||||
|
|
@ -131,7 +139,6 @@ pub fn execute(
|
|||
)
|
||||
.green()
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if let Some(new_subpath) = subpath {
|
||||
|
|
@ -140,7 +147,6 @@ pub fn execute(
|
|||
"{}",
|
||||
format!("✓ 'subpath' set to '{new_subpath}' for '{pakku_id}'").green()
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if let Some(alias_to_add) = add_alias {
|
||||
|
|
@ -152,7 +158,6 @@ pub fn execute(
|
|||
"{}",
|
||||
format!("✓ Added alias '{alias_to_add}' for '{pakku_id}'").green()
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -165,7 +170,6 @@ pub fn execute(
|
|||
"{}",
|
||||
format!("✓ Removed alias '{alias_to_remove}' from '{pakku_id}'").green()
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if let Some(new_export) = export {
|
||||
|
|
@ -174,7 +178,6 @@ pub fn execute(
|
|||
"{}",
|
||||
format!("✓ 'export' set to '{new_export}' for '{pakku_id}'").green()
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if !changed {
|
||||
|
|
@ -187,7 +190,7 @@ pub fn execute(
|
|||
|
||||
config.set_project_config(pakku_id.clone(), project_config);
|
||||
// Config::save expects directory path, not file path
|
||||
let config_dir = config_path.parent().unwrap_or(Path::new("."));
|
||||
let config_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
config.save(config_dir)?;
|
||||
|
||||
println!(
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ pub fn execute(
|
|||
return Ok(());
|
||||
}
|
||||
|
||||
let creds = ResolvedCredentials::load()?;
|
||||
let creds = ResolvedCredentials::load();
|
||||
|
||||
let has_any = creds.curseforge_api_key().is_some()
|
||||
|| creds.modrinth_token().is_some()
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ pub fn execute(
|
|||
github_access_token: Option<String>,
|
||||
) -> Result<()> {
|
||||
let mut creds = PakkerCredentialsFile::load()?;
|
||||
let mut updated_any = false;
|
||||
let updated_any = curseforge_api_key.is_some()
|
||||
|| modrinth_token.is_some()
|
||||
|| github_access_token.is_some();
|
||||
|
||||
if let Some(key) = curseforge_api_key {
|
||||
let key = key.trim().to_string();
|
||||
|
|
@ -22,7 +24,6 @@ pub fn execute(
|
|||
println!("Setting CurseForge API key...");
|
||||
set_keyring_secret("curseforge_api_key", &key)?;
|
||||
creds.curseforge_api_key = Some(key);
|
||||
updated_any = true;
|
||||
}
|
||||
|
||||
if let Some(token) = modrinth_token {
|
||||
|
|
@ -36,7 +37,6 @@ pub fn execute(
|
|||
println!("Setting Modrinth token...");
|
||||
set_keyring_secret("modrinth_token", &token)?;
|
||||
creds.modrinth_token = Some(token);
|
||||
updated_any = true;
|
||||
}
|
||||
|
||||
if let Some(token) = github_access_token {
|
||||
|
|
@ -50,7 +50,6 @@ pub fn execute(
|
|||
println!("Setting GitHub access token...");
|
||||
set_keyring_secret("github_access_token", &token)?;
|
||||
creds.github_access_token = Some(token);
|
||||
updated_any = true;
|
||||
}
|
||||
|
||||
if !updated_any {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fmt::Write,
|
||||
fs,
|
||||
path::Path,
|
||||
};
|
||||
|
|
@ -21,20 +22,20 @@ struct ProjectChange {
|
|||
new_file: Option<String>,
|
||||
}
|
||||
|
||||
pub fn execute(args: DiffArgs, _lockfile_path: &Path) -> Result<()> {
|
||||
pub fn execute(args: &DiffArgs, _lockfile_path: &Path) -> Result<()> {
|
||||
log::info!("Comparing lockfiles");
|
||||
|
||||
// Load old lockfile
|
||||
let old_path = Path::new(&args.old_lockfile);
|
||||
let old_dir = old_path.parent().unwrap_or(Path::new("."));
|
||||
let old_dir = old_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let old_lockfile = LockFile::load(old_dir)?;
|
||||
|
||||
// Load current lockfile
|
||||
let current_path = args
|
||||
.current_lockfile
|
||||
.as_ref()
|
||||
.map_or(Path::new("pakku-lock.json"), Path::new);
|
||||
let current_dir = current_path.parent().unwrap_or(Path::new("."));
|
||||
.map_or_else(|| Path::new("pakku-lock.json"), Path::new);
|
||||
let current_dir = current_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let current_lockfile = LockFile::load(current_dir)?;
|
||||
|
||||
// Compare metadata
|
||||
|
|
@ -145,6 +146,10 @@ pub fn execute(args: DiffArgs, _lockfile_path: &Path) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "diff formatting requires all display parameters"
|
||||
)]
|
||||
fn print_terminal_diff(
|
||||
old: &LockFile,
|
||||
new: &LockFile,
|
||||
|
|
@ -243,6 +248,10 @@ fn print_terminal_diff(
|
|||
}
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "diff markdown writer requires all context parameters"
|
||||
)]
|
||||
fn write_markdown_diff(
|
||||
path: &str,
|
||||
old: &LockFile,
|
||||
|
|
@ -260,17 +269,17 @@ fn write_markdown_diff(
|
|||
|
||||
// Metadata changes
|
||||
if old.target != new.target {
|
||||
content.push_str(&format!("- Target: {:?}\n", old.target));
|
||||
content.push_str(&format!("+ Target: {:?}\n", new.target));
|
||||
let _ = writeln!(content, "- Target: {:?}", old.target);
|
||||
let _ = writeln!(content, "+ Target: {:?}", new.target);
|
||||
}
|
||||
|
||||
if !mc_removed.is_empty() || !mc_added.is_empty() {
|
||||
content.push_str("\nMinecraft Versions:\n");
|
||||
for v in mc_removed {
|
||||
content.push_str(&format!("- {v}\n"));
|
||||
let _ = writeln!(content, "- {v}");
|
||||
}
|
||||
for v in mc_added {
|
||||
content.push_str(&format!("+ {v}\n"));
|
||||
let _ = writeln!(content, "+ {v}");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -278,16 +287,16 @@ fn write_markdown_diff(
|
|||
for (name, old_ver) in old_loaders {
|
||||
if let Some(new_ver) = new_loaders.get(name) {
|
||||
if old_ver != new_ver {
|
||||
content.push_str(&format!("- {name}: {old_ver}\n"));
|
||||
content.push_str(&format!("+ {name}: {new_ver}\n"));
|
||||
let _ = writeln!(content, "- {name}: {old_ver}");
|
||||
let _ = writeln!(content, "+ {name}: {new_ver}");
|
||||
}
|
||||
} else {
|
||||
content.push_str(&format!("- {name}: {old_ver}\n"));
|
||||
let _ = writeln!(content, "- {name}: {old_ver}");
|
||||
}
|
||||
}
|
||||
for (name, new_ver) in new_loaders {
|
||||
if !old_loaders.contains_key(name) {
|
||||
content.push_str(&format!("+ {name}: {new_ver}\n"));
|
||||
let _ = writeln!(content, "+ {name}: {new_ver}");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -297,16 +306,16 @@ fn write_markdown_diff(
|
|||
for change in changes {
|
||||
match change.change_type {
|
||||
ChangeType::Added => {
|
||||
content.push_str(&format!("+ {}", change.name));
|
||||
let _ = write!(content, "+ {}", change.name);
|
||||
if verbose && let Some(file) = &change.new_file {
|
||||
content.push_str(&format!(" ({file})"));
|
||||
let _ = write!(content, " ({file})");
|
||||
}
|
||||
content.push('\n');
|
||||
},
|
||||
ChangeType::Removed => {
|
||||
content.push_str(&format!("- {}", change.name));
|
||||
let _ = write!(content, "- {}", change.name);
|
||||
if verbose && let Some(file) = &change.old_file {
|
||||
content.push_str(&format!(" ({file})"));
|
||||
let _ = write!(content, " ({file})");
|
||||
}
|
||||
content.push('\n');
|
||||
},
|
||||
|
|
@ -314,11 +323,11 @@ fn write_markdown_diff(
|
|||
if verbose {
|
||||
if let (Some(old), Some(new)) = (&change.old_file, &change.new_file)
|
||||
{
|
||||
content.push_str(&format!("- {} ({})\n", change.name, old));
|
||||
content.push_str(&format!("+ {} ({})\n", change.name, new));
|
||||
let _ = writeln!(content, "- {} ({})", change.name, old);
|
||||
let _ = writeln!(content, "+ {} ({})", change.name, new);
|
||||
}
|
||||
} else {
|
||||
content.push_str(&format!("~ {}\n", change.name));
|
||||
let _ = writeln!(content, "~ {}", change.name);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
@ -331,6 +340,10 @@ fn write_markdown_diff(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "diff markdown writer requires all context parameters"
|
||||
)]
|
||||
fn write_markdown(
|
||||
path: &str,
|
||||
old: &LockFile,
|
||||
|
|
@ -346,24 +359,25 @@ fn write_markdown(
|
|||
let header = "#".repeat(header_size.min(5));
|
||||
let mut content = String::new();
|
||||
|
||||
content.push_str(&format!("{header} Lockfile Comparison\n\n"));
|
||||
let _ = write!(content, "{header} Lockfile Comparison\n\n");
|
||||
|
||||
// Target
|
||||
if old.target != new.target {
|
||||
content.push_str(&format!(
|
||||
let _ = write!(
|
||||
content,
|
||||
"**Target:** {:?} → {:?}\n\n",
|
||||
old.target, new.target
|
||||
));
|
||||
);
|
||||
}
|
||||
|
||||
// MC versions
|
||||
if !mc_removed.is_empty() || !mc_added.is_empty() {
|
||||
content.push_str(&format!("{header} Minecraft Versions\n\n"));
|
||||
let _ = write!(content, "{header} Minecraft Versions\n\n");
|
||||
for v in mc_removed {
|
||||
content.push_str(&format!("- ~~{v}~~\n"));
|
||||
let _ = writeln!(content, "- ~~{v}~~");
|
||||
}
|
||||
for v in mc_added {
|
||||
content.push_str(&format!("- **{v}** (new)\n"));
|
||||
let _ = writeln!(content, "- **{v}** (new)");
|
||||
}
|
||||
content.push('\n');
|
||||
}
|
||||
|
|
@ -375,29 +389,28 @@ fn write_markdown(
|
|||
if let Some(new_ver) = new_loaders.get(name) {
|
||||
if old_ver != new_ver {
|
||||
has_loader_changes = true;
|
||||
loader_content
|
||||
.push_str(&format!("- **{name}:** {old_ver} → {new_ver}\n"));
|
||||
let _ = writeln!(loader_content, "- **{name}:** {old_ver} → {new_ver}");
|
||||
}
|
||||
} else {
|
||||
has_loader_changes = true;
|
||||
loader_content.push_str(&format!("- ~~{name}: {old_ver}~~\n"));
|
||||
let _ = writeln!(loader_content, "- ~~{name}: {old_ver}~~");
|
||||
}
|
||||
}
|
||||
for (name, new_ver) in new_loaders {
|
||||
if !old_loaders.contains_key(name) {
|
||||
has_loader_changes = true;
|
||||
loader_content.push_str(&format!("- **{name}: {new_ver}** (new)\n"));
|
||||
let _ = writeln!(loader_content, "- **{name}: {new_ver}** (new)");
|
||||
}
|
||||
}
|
||||
if has_loader_changes {
|
||||
content.push_str(&format!("{header} Loaders\n\n"));
|
||||
let _ = write!(content, "{header} Loaders\n\n");
|
||||
content.push_str(&loader_content);
|
||||
content.push('\n');
|
||||
}
|
||||
|
||||
// Projects
|
||||
if !changes.is_empty() {
|
||||
content.push_str(&format!("{header} Projects\n\n"));
|
||||
let _ = write!(content, "{header} Projects\n\n");
|
||||
|
||||
let added: Vec<_> = changes
|
||||
.iter()
|
||||
|
|
@ -413,11 +426,11 @@ fn write_markdown(
|
|||
.collect();
|
||||
|
||||
if !added.is_empty() {
|
||||
content.push_str(&format!("{}# Added ({})\n\n", header, added.len()));
|
||||
let _ = write!(content, "{}# Added ({})\n\n", header, added.len());
|
||||
for change in added {
|
||||
content.push_str(&format!("- **{}**", change.name));
|
||||
let _ = write!(content, "- **{}**", change.name);
|
||||
if verbose && let Some(file) = &change.new_file {
|
||||
content.push_str(&format!(" ({file})"));
|
||||
let _ = write!(content, " ({file})");
|
||||
}
|
||||
content.push('\n');
|
||||
}
|
||||
|
|
@ -425,11 +438,11 @@ fn write_markdown(
|
|||
}
|
||||
|
||||
if !removed.is_empty() {
|
||||
content.push_str(&format!("{}# Removed ({})\n\n", header, removed.len()));
|
||||
let _ = write!(content, "{}# Removed ({})\n\n", header, removed.len());
|
||||
for change in removed {
|
||||
content.push_str(&format!("- ~~{}~~", change.name));
|
||||
let _ = write!(content, "- ~~{}~~", change.name);
|
||||
if verbose && let Some(file) = &change.old_file {
|
||||
content.push_str(&format!(" ({file})"));
|
||||
let _ = write!(content, " ({file})");
|
||||
}
|
||||
content.push('\n');
|
||||
}
|
||||
|
|
@ -437,13 +450,13 @@ fn write_markdown(
|
|||
}
|
||||
|
||||
if !updated.is_empty() {
|
||||
content.push_str(&format!("{}# Updated ({})\n\n", header, updated.len()));
|
||||
let _ = write!(content, "{}# Updated ({})\n\n", header, updated.len());
|
||||
for change in updated {
|
||||
content.push_str(&format!("- **{}**", change.name));
|
||||
let _ = write!(content, "- **{}**", change.name);
|
||||
if verbose
|
||||
&& let (Some(old), Some(new)) = (&change.old_file, &change.new_file)
|
||||
{
|
||||
content.push_str(&format!(" ({old} → {new})"));
|
||||
let _ = write!(content, " ({old} → {new})");
|
||||
}
|
||||
content.push('\n');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ use crate::{
|
|||
utils::hash::compute_sha256_bytes,
|
||||
};
|
||||
|
||||
#[expect(clippy::future_not_send, reason = "not required to be Send")]
|
||||
pub async fn execute(
|
||||
args: ExportArgs,
|
||||
lockfile_path: &Path,
|
||||
|
|
@ -31,8 +32,8 @@ pub async fn execute(
|
|||
log::info!("IO errors will be shown during export");
|
||||
}
|
||||
|
||||
let lockfile_dir = lockfile_path.parent().unwrap_or(Path::new("."));
|
||||
let config_dir = config_path.parent().unwrap_or(Path::new("."));
|
||||
let lockfile_dir = lockfile_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let config_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
|
||||
// IPC coordination - prevent concurrent operations on the same modpack
|
||||
let ipc = IpcCoordinator::new(config_dir)?;
|
||||
|
|
@ -113,7 +114,7 @@ pub async fn execute(
|
|||
LockFile::load_with_validation(lockfile_dir, false)?;
|
||||
|
||||
// Merge: start with parent, override with local
|
||||
merge_lockfiles(parent_lockfile, local_lockfile, local_cfg)?
|
||||
merge_lockfiles(parent_lockfile, &local_lockfile, local_cfg)
|
||||
} else {
|
||||
log::info!("No local lockfile - using parent lockfile");
|
||||
parent_lockfile
|
||||
|
|
@ -188,7 +189,7 @@ pub async fn execute(
|
|||
};
|
||||
|
||||
// Create exporter
|
||||
let mut exporter = Exporter::new(".");
|
||||
let exporter = Exporter::new(".");
|
||||
|
||||
// Export based on profile argument
|
||||
if let Some(profile_name) = args.profile {
|
||||
|
|
@ -197,7 +198,7 @@ pub async fn execute(
|
|||
.export(&profile_name, &lockfile, &config, Path::new(output_path))
|
||||
.await?;
|
||||
|
||||
println!("Export complete: {output_file:?}");
|
||||
println!("Export complete: {}", output_file.display());
|
||||
} else {
|
||||
// Multi-profile export (Pakker-compatible default behavior)
|
||||
let output_files = exporter
|
||||
|
|
@ -206,7 +207,7 @@ pub async fn execute(
|
|||
|
||||
println!("\nExported {} files:", output_files.len());
|
||||
for output_file in output_files {
|
||||
println!(" - {output_file:?}");
|
||||
println!(" - {}", output_file.display());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -218,9 +219,9 @@ pub async fn execute(
|
|||
/// with same slug
|
||||
fn merge_lockfiles(
|
||||
parent: LockFile,
|
||||
local: LockFile,
|
||||
local: &LockFile,
|
||||
local_config: &LocalConfig,
|
||||
) -> Result<LockFile> {
|
||||
) -> LockFile {
|
||||
let mut merged = LockFile {
|
||||
target: parent.target, // Use parent target
|
||||
mc_versions: parent.mc_versions, // Use parent MC versions
|
||||
|
|
@ -298,5 +299,5 @@ fn merge_lockfiles(
|
|||
merged.projects.len()
|
||||
);
|
||||
|
||||
Ok(merged)
|
||||
merged
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ pub async fn execute(
|
|||
config_path: &Path,
|
||||
) -> Result<()> {
|
||||
// Load expects directory path, so get parent directory
|
||||
let lockfile_dir = lockfile_path.parent().unwrap_or(Path::new("."));
|
||||
let config_dir = config_path.parent().unwrap_or(Path::new("."));
|
||||
let lockfile_dir = lockfile_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let config_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
|
||||
let lockfile = LockFile::load(lockfile_dir)?;
|
||||
let config = Config::load(config_dir)?;
|
||||
|
|
@ -38,7 +38,9 @@ pub async fn execute(
|
|||
let _guard = OperationGuard::new(coordinator, operation_id);
|
||||
|
||||
// Create fetcher with shelve option
|
||||
let fetcher = Fetcher::new(".").with_shelve(args.shelve);
|
||||
let fetcher = Fetcher::new(".")
|
||||
.with_shelve(args.shelve)
|
||||
.with_retry(args.retry);
|
||||
|
||||
// Fetch all projects (progress indicators handled in fetch.rs)
|
||||
fetcher.fetch_all(&lockfile, &config).await?;
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ pub fn execute(args: &ForkArgs) -> Result<(), PakkerError> {
|
|||
crate::cli::ForkSubcommand::Unset => execute_unset(),
|
||||
crate::cli::ForkSubcommand::Sync => execute_sync(),
|
||||
crate::cli::ForkSubcommand::Promote { projects } => {
|
||||
execute_promote(projects.clone())
|
||||
execute_promote(projects)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -361,13 +361,11 @@ fn execute_set(
|
|||
let config_dir = Path::new(".");
|
||||
let mut local_config = LocalConfig::load(config_dir)?;
|
||||
|
||||
if local_config.parent.is_none() {
|
||||
let Some(mut parent) = local_config.parent else {
|
||||
return Err(PakkerError::Fork(
|
||||
"No parent configured. Run 'pakku fork init' first.".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut parent = local_config.parent.unwrap();
|
||||
};
|
||||
|
||||
if let Some(url) = git_url {
|
||||
validate_git_url(&url)?;
|
||||
|
|
@ -461,10 +459,12 @@ fn execute_unset() -> Result<(), PakkerError> {
|
|||
|
||||
// Prompt for confirmation
|
||||
print!("Are you sure you want to remove fork configuration? [y/N] ");
|
||||
std::io::stdout().flush().unwrap();
|
||||
std::io::stdout().flush().map_err(PakkerError::IoError)?;
|
||||
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input).unwrap();
|
||||
std::io::stdin()
|
||||
.read_line(&mut input)
|
||||
.map_err(PakkerError::IoError)?;
|
||||
|
||||
if !input.trim().eq_ignore_ascii_case("y") {
|
||||
println!("Cancelled.");
|
||||
|
|
@ -596,7 +596,7 @@ fn execute_sync() -> Result<(), PakkerError> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn execute_promote(projects: Vec<String>) -> Result<(), PakkerError> {
|
||||
fn execute_promote(projects: &[String]) -> Result<(), PakkerError> {
|
||||
let config_dir = Path::new(".");
|
||||
let local_config = LocalConfig::load(config_dir)?;
|
||||
|
||||
|
|
@ -617,7 +617,7 @@ fn execute_promote(projects: Vec<String>) -> Result<(), PakkerError> {
|
|||
let config = Config::load(config_dir)?;
|
||||
|
||||
// Verify all projects exist
|
||||
for project_arg in &projects {
|
||||
for project_arg in projects {
|
||||
let found = config
|
||||
.projects
|
||||
.as_ref()
|
||||
|
|
@ -635,7 +635,7 @@ fn execute_promote(projects: Vec<String>) -> Result<(), PakkerError> {
|
|||
println!("automatically merged with parent projects during export.");
|
||||
println!();
|
||||
println!("The following projects are already in pakku.json:");
|
||||
for project in &projects {
|
||||
for project in projects {
|
||||
println!(" - {project}");
|
||||
}
|
||||
println!();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use std::path::Path;
|
||||
use std::{collections::HashMap, path::Path};
|
||||
|
||||
use crate::{
|
||||
cli::ImportArgs,
|
||||
|
|
@ -49,8 +49,8 @@ pub async fn execute(
|
|||
let file = std::fs::File::open(path)?;
|
||||
let mut archive = zip::ZipArchive::new(file)?;
|
||||
|
||||
let lockfile_dir = lockfile_path.parent().unwrap_or(Path::new("."));
|
||||
let config_dir = config_path.parent().unwrap_or(Path::new("."));
|
||||
let lockfile_dir = lockfile_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let config_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
|
||||
if archive.by_name("modrinth.index.json").is_ok() {
|
||||
drop(archive);
|
||||
|
|
@ -94,14 +94,15 @@ async fn import_modrinth(
|
|||
.unwrap_or("1.20.1")
|
||||
.to_string();
|
||||
|
||||
let loader =
|
||||
if let Some(fabric) = index["dependencies"]["fabric-loader"].as_str() {
|
||||
("fabric".to_string(), fabric.to_string())
|
||||
} else if let Some(forge) = index["dependencies"]["forge"].as_str() {
|
||||
("forge".to_string(), forge.to_string())
|
||||
} else {
|
||||
("fabric".to_string(), "latest".to_string())
|
||||
};
|
||||
let loader = index["dependencies"]["fabric-loader"].as_str().map_or_else(
|
||||
|| {
|
||||
index["dependencies"]["forge"].as_str().map_or_else(
|
||||
|| ("fabric".to_string(), "latest".to_string()),
|
||||
|forge| ("forge".to_string(), forge.to_string()),
|
||||
)
|
||||
},
|
||||
|fabric| ("fabric".to_string(), fabric.to_string()),
|
||||
);
|
||||
|
||||
let mut loaders = std::collections::HashMap::new();
|
||||
loaders.insert(loader.0.clone(), loader.1);
|
||||
|
|
@ -119,12 +120,10 @@ async fn import_modrinth(
|
|||
log::info!("Importing {} projects from modpack", files.len());
|
||||
|
||||
// Create platform client
|
||||
let creds = crate::model::credentials::ResolvedCredentials::load().ok();
|
||||
let creds = crate::model::credentials::ResolvedCredentials::load();
|
||||
let platform = create_platform(
|
||||
"modrinth",
|
||||
creds
|
||||
.as_ref()
|
||||
.and_then(|c| c.modrinth_token().map(std::string::ToString::to_string)),
|
||||
creds.modrinth_token().map(std::string::ToString::to_string),
|
||||
)?;
|
||||
|
||||
for file_entry in files {
|
||||
|
|
@ -184,7 +183,7 @@ async fn import_modrinth(
|
|||
overrides: vec!["overrides".to_string()],
|
||||
server_overrides: None,
|
||||
client_overrides: None,
|
||||
paths: Default::default(),
|
||||
paths: HashMap::default(),
|
||||
projects: None,
|
||||
export_profiles: None,
|
||||
export_server_side_projects_to_client: None,
|
||||
|
|
@ -205,7 +204,9 @@ async fn import_modrinth(
|
|||
})?;
|
||||
|
||||
if outpath.starts_with("overrides/") {
|
||||
let target = outpath.strip_prefix("overrides/").unwrap();
|
||||
let Some(target) = outpath.strip_prefix("overrides/").ok() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if file.is_dir() {
|
||||
std::fs::create_dir_all(target)?;
|
||||
|
|
@ -231,6 +232,8 @@ async fn import_curseforge(
|
|||
|
||||
use zip::ZipArchive;
|
||||
|
||||
use crate::platform::create_platform;
|
||||
|
||||
let file = File::open(path)?;
|
||||
let mut archive = ZipArchive::new(file)?;
|
||||
|
||||
|
|
@ -283,7 +286,6 @@ async fn import_curseforge(
|
|||
log::info!("Importing {} projects from modpack", files.len());
|
||||
|
||||
// Create platform client
|
||||
use crate::platform::create_platform;
|
||||
let curseforge_token = std::env::var("CURSEFORGE_TOKEN").ok();
|
||||
let platform = create_platform("curseforge", curseforge_token)?;
|
||||
|
||||
|
|
@ -370,7 +372,7 @@ async fn import_curseforge(
|
|||
overrides: vec!["overrides".to_string()],
|
||||
server_overrides: None,
|
||||
client_overrides: None,
|
||||
paths: Default::default(),
|
||||
paths: HashMap::default(),
|
||||
projects: None,
|
||||
export_profiles: None,
|
||||
export_server_side_projects_to_client: None,
|
||||
|
|
@ -393,7 +395,9 @@ async fn import_curseforge(
|
|||
})?;
|
||||
|
||||
if outpath.starts_with(overrides_prefix) {
|
||||
let target = outpath.strip_prefix(overrides_prefix).unwrap();
|
||||
let Some(target) = outpath.strip_prefix(overrides_prefix).ok() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if file.is_dir() {
|
||||
std::fs::create_dir_all(target)?;
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ use crate::{
|
|||
},
|
||||
};
|
||||
|
||||
pub async fn execute(
|
||||
pub fn execute(
|
||||
args: InitArgs,
|
||||
global_yes: bool,
|
||||
lockfile_path: &Path,
|
||||
|
|
@ -125,7 +125,7 @@ pub async fn execute(
|
|||
};
|
||||
|
||||
// Save expects directory path, so get parent directory
|
||||
let lockfile_dir = lockfile_path.parent().unwrap_or(Path::new("."));
|
||||
let lockfile_dir = lockfile_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
lockfile.save(lockfile_dir)?;
|
||||
|
||||
let config = Config {
|
||||
|
|
@ -143,7 +143,7 @@ pub async fn execute(
|
|||
file_count_preference: None,
|
||||
};
|
||||
|
||||
let config_dir = config_path.parent().unwrap_or(Path::new("."));
|
||||
let config_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
config.save(config_dir)?;
|
||||
|
||||
println!("Initialized new modpack '{name}' v{version}");
|
||||
|
|
@ -161,10 +161,8 @@ pub async fn execute(
|
|||
|
||||
// Check if CurseForge API key is needed and prompt if interactive
|
||||
if is_interactive && (target == "curseforge" || target == "multiplatform") {
|
||||
let credentials = ResolvedCredentials::load().ok();
|
||||
let has_cf_key = credentials
|
||||
.as_ref()
|
||||
.is_some_and(|c| c.curseforge_api_key().is_some());
|
||||
let credentials = ResolvedCredentials::load();
|
||||
let has_cf_key = credentials.curseforge_api_key().is_some();
|
||||
|
||||
if !has_cf_key {
|
||||
println!();
|
||||
|
|
|
|||
|
|
@ -9,13 +9,13 @@ use crate::{
|
|||
model::{Config, LockFile, Project, ProjectFile},
|
||||
};
|
||||
|
||||
pub async fn execute(
|
||||
projects: Vec<String>,
|
||||
pub fn execute(
|
||||
projects: &[String],
|
||||
lockfile_path: &Path,
|
||||
config_path: &Path,
|
||||
) -> Result<()> {
|
||||
let lockfile_dir = lockfile_path.parent().unwrap_or(Path::new("."));
|
||||
let config_dir = config_path.parent().unwrap_or(Path::new("."));
|
||||
let lockfile_dir = lockfile_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let config_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
|
||||
let lockfile = LockFile::load(lockfile_dir)?;
|
||||
let _config = Config::load(config_dir)?;
|
||||
|
|
@ -172,15 +172,15 @@ fn display_project_inspection(
|
|||
lockfile: &LockFile,
|
||||
) -> Result<()> {
|
||||
// Display project header panel
|
||||
display_project_header(project)?;
|
||||
display_project_header(project);
|
||||
|
||||
// Display project files
|
||||
println!();
|
||||
display_project_files(&project.files, project)?;
|
||||
display_project_files(&project.files, project);
|
||||
|
||||
// Display properties
|
||||
println!();
|
||||
display_properties(project)?;
|
||||
display_properties(project);
|
||||
|
||||
// Display dependency tree
|
||||
println!();
|
||||
|
|
@ -191,7 +191,7 @@ fn display_project_inspection(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn display_project_header(project: &Project) -> Result<()> {
|
||||
fn display_project_header(project: &Project) {
|
||||
let name = get_project_name(project);
|
||||
let default_slug = String::from("N/A");
|
||||
let slug = project.slug.values().next().unwrap_or(&default_slug);
|
||||
|
|
@ -213,7 +213,7 @@ fn display_project_header(project: &Project) -> Result<()> {
|
|||
let metadata = format!(
|
||||
"{} ({}) • {} • {}",
|
||||
slug,
|
||||
project.id.keys().next().unwrap_or(&"unknown".to_string()),
|
||||
project.id.keys().next().map_or("unknown", String::as_str),
|
||||
format!("{:?}", project.r#type).to_lowercase(),
|
||||
format!("{:?}", project.side).to_lowercase()
|
||||
);
|
||||
|
|
@ -224,17 +224,12 @@ fn display_project_header(project: &Project) -> Result<()> {
|
|||
]);
|
||||
|
||||
println!("{table}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn display_project_files(
|
||||
files: &[ProjectFile],
|
||||
project: &Project,
|
||||
) -> Result<()> {
|
||||
fn display_project_files(files: &[ProjectFile], project: &Project) {
|
||||
if files.is_empty() {
|
||||
println!("{}", "No files available".yellow());
|
||||
return Ok(());
|
||||
return;
|
||||
}
|
||||
|
||||
println!("{}", "Project Files".cyan().bold());
|
||||
|
|
@ -255,13 +250,14 @@ fn display_project_files(
|
|||
|
||||
// File path line with optional site URL
|
||||
let file_path = format!("{}={}", file.file_type, file.file_name);
|
||||
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}")
|
||||
};
|
||||
let file_display = file.get_site_url(project).map_or_else(
|
||||
|| format!("{file_path}:{status_text}"),
|
||||
|site_url| {
|
||||
// Create hyperlink for the file
|
||||
let hyperlink = crate::ui_utils::hyperlink(&site_url, &file_path);
|
||||
format!("{hyperlink}:{status_text}")
|
||||
},
|
||||
);
|
||||
|
||||
table.add_row(vec![Cell::new(file_display).fg(if idx == 0 {
|
||||
Color::Green
|
||||
|
|
@ -302,11 +298,9 @@ fn display_project_files(
|
|||
println!("{table}");
|
||||
println!();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn display_properties(project: &Project) -> Result<()> {
|
||||
fn display_properties(project: &Project) {
|
||||
println!("{}", "Properties".cyan().bold());
|
||||
|
||||
println!(
|
||||
|
|
@ -338,8 +332,6 @@ fn display_properties(project: &Project) -> Result<()> {
|
|||
let aliases: Vec<_> = project.aliases.iter().cloned().collect();
|
||||
println!(" {}={}", "aliases".yellow(), aliases.join(", "));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn display_dependencies(project: &Project, lockfile: &LockFile) -> Result<()> {
|
||||
|
|
|
|||
|
|
@ -6,11 +6,11 @@ use crate::{
|
|||
model::LockFile,
|
||||
};
|
||||
|
||||
pub fn execute(args: LinkArgs, lockfile_path: &Path) -> Result<()> {
|
||||
pub fn execute(args: &LinkArgs, lockfile_path: &Path) -> Result<()> {
|
||||
log::info!("Linking {} -> {}", args.from, args.to);
|
||||
|
||||
// Load expects directory path, so get parent directory
|
||||
let lockfile_dir = lockfile_path.parent().unwrap_or(Path::new("."));
|
||||
let lockfile_dir = lockfile_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let mut lockfile = LockFile::load(lockfile_dir)?;
|
||||
|
||||
// Find projects
|
||||
|
|
|
|||
|
|
@ -14,9 +14,9 @@ fn truncate_name(name: &str, max_len: usize) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn execute(args: LsArgs, lockfile_path: &Path) -> Result<()> {
|
||||
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("."));
|
||||
let lockfile_dir = lockfile_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let lockfile = LockFile::load(lockfile_dir)?;
|
||||
|
||||
if lockfile.projects.is_empty() {
|
||||
|
|
|
|||
|
|
@ -29,11 +29,13 @@ pub async fn execute(args: RemoteArgs) -> Result<()> {
|
|||
|
||||
// If no URL provided, show status
|
||||
if args.url.is_none() {
|
||||
show_remote_status(&remote_path)?;
|
||||
show_remote_status(&remote_path);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let url = args.url.unwrap();
|
||||
let url = args
|
||||
.url
|
||||
.ok_or_else(|| PakkerError::InvalidInput("URL is required".to_string()))?;
|
||||
log::info!("Installing modpack from: {url}");
|
||||
|
||||
// Clone or update repository
|
||||
|
|
@ -90,10 +92,10 @@ pub async fn execute(args: RemoteArgs) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn show_remote_status(remote_path: &Path) -> Result<()> {
|
||||
fn show_remote_status(remote_path: &Path) {
|
||||
if !remote_path.exists() {
|
||||
println!("No remote configured");
|
||||
return Ok(());
|
||||
return;
|
||||
}
|
||||
|
||||
println!("Remote status:");
|
||||
|
|
@ -107,8 +109,6 @@ fn show_remote_status(remote_path: &Path) -> Result<()> {
|
|||
println!(" Commit: {}", &sha[..8]);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sync_overrides(remote_path: &Path, server_pack: bool) -> Result<()> {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use crate::{cli::RemoteUpdateArgs, error::PakkerError, git, model::Config};
|
|||
///
|
||||
/// This command updates the current modpack from its remote Git repository.
|
||||
/// It fetches the latest changes from the remote and syncs overrides.
|
||||
pub async fn execute(args: RemoteUpdateArgs) -> Result<(), PakkerError> {
|
||||
pub fn execute(args: &RemoteUpdateArgs) -> Result<(), PakkerError> {
|
||||
// Check if lockfile exists in current directory - if it does, we're in a
|
||||
// modpack directory and should not update remote (use regular update
|
||||
// instead)
|
||||
|
|
@ -60,7 +60,7 @@ pub async fn execute(args: RemoteUpdateArgs) -> Result<(), PakkerError> {
|
|||
|
||||
// Sync overrides from remote directory
|
||||
println!("Syncing overrides...");
|
||||
sync_overrides(&remote_dir).await?;
|
||||
sync_overrides(&remote_dir)?;
|
||||
|
||||
// Clean up remote directory
|
||||
std::fs::remove_dir_all(&remote_dir)?;
|
||||
|
|
@ -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: &Path) -> Result<(), PakkerError> {
|
||||
fn sync_overrides(remote_dir: &Path) -> Result<(), PakkerError> {
|
||||
let remote_config_path = remote_dir.join("pakku.json");
|
||||
if !remote_config_path.exists() {
|
||||
return Ok(());
|
||||
|
|
|
|||
|
|
@ -7,15 +7,15 @@ use crate::{
|
|||
ui_utils::{prompt_typo_suggestion, prompt_yes_no},
|
||||
};
|
||||
|
||||
pub async fn execute(
|
||||
args: RmArgs,
|
||||
pub fn execute(
|
||||
args: &RmArgs,
|
||||
global_yes: bool,
|
||||
lockfile_path: &Path,
|
||||
_config_path: &Path,
|
||||
) -> Result<()> {
|
||||
let skip_prompts = global_yes;
|
||||
// Load expects directory path, so get parent directory
|
||||
let lockfile_dir = lockfile_path.parent().unwrap_or(Path::new("."));
|
||||
let lockfile_dir = lockfile_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let mut lockfile = LockFile::load(lockfile_dir)?;
|
||||
|
||||
// Determine which projects to remove
|
||||
|
|
|
|||
|
|
@ -6,14 +6,14 @@ use crate::{
|
|||
model::{Config, LockFile, ProjectSide, ProjectType, Target, UpdateStrategy},
|
||||
};
|
||||
|
||||
pub async fn execute(
|
||||
args: SetArgs,
|
||||
pub fn execute(
|
||||
args: &SetArgs,
|
||||
lockfile_path: &Path,
|
||||
config_path: &Path,
|
||||
) -> Result<(), PakkerError> {
|
||||
// Load expects directory path, so get parent directory
|
||||
let lockfile_dir = lockfile_path.parent().unwrap_or(Path::new("."));
|
||||
let config_dir = config_path.parent().unwrap_or(Path::new("."));
|
||||
let lockfile_dir = lockfile_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let config_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
|
||||
let mut lockfile = LockFile::load(lockfile_dir)?;
|
||||
let config = Config::load(config_dir)?;
|
||||
|
|
@ -61,7 +61,7 @@ pub async fn execute(
|
|||
}
|
||||
}
|
||||
|
||||
lockfile.mc_versions = mc_versions.clone();
|
||||
lockfile.mc_versions.clone_from(&mc_versions);
|
||||
println!("Set Minecraft versions to: {mc_versions:?}");
|
||||
}
|
||||
|
||||
|
|
@ -101,7 +101,7 @@ pub async fn execute(
|
|||
}
|
||||
}
|
||||
|
||||
lockfile.loaders = loaders.clone();
|
||||
lockfile.loaders.clone_from(&loaders);
|
||||
println!("Set loaders to: {loaders:?}");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ pub async fn execute(
|
|||
lockfile_path: &Path,
|
||||
config_path: &Path,
|
||||
) -> Result<()> {
|
||||
let lockfile_dir = lockfile_path.parent().unwrap_or(Path::new("."));
|
||||
let config_dir = config_path.parent().unwrap_or(Path::new("."));
|
||||
let lockfile_dir = lockfile_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let config_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
|
||||
let lockfile = LockFile::load(lockfile_dir)?;
|
||||
let config = Config::load(config_dir)?;
|
||||
|
|
@ -67,7 +67,6 @@ pub async fn execute(
|
|||
}
|
||||
|
||||
// Log info level summary
|
||||
let _info_severity = ErrorSeverity::Info;
|
||||
log::info!(
|
||||
"Update check completed with {} warning(s) and {} error(s)",
|
||||
warnings.len(),
|
||||
|
|
@ -138,6 +137,10 @@ struct FileUpdate {
|
|||
new_filename: String,
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::expect_used,
|
||||
reason = "progress bar template is a string literal and is always valid"
|
||||
)]
|
||||
async fn check_updates_sequential(
|
||||
lockfile: &LockFile,
|
||||
) -> Result<(Vec<ProjectUpdate>, Vec<(String, String)>)> {
|
||||
|
|
@ -150,7 +153,7 @@ async fn check_updates_sequential(
|
|||
pb.set_style(
|
||||
ProgressStyle::default_bar()
|
||||
.template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}")
|
||||
.unwrap()
|
||||
.expect("progress bar template is valid")
|
||||
.progress_chars("#>-"),
|
||||
);
|
||||
pb.set_message("Checking for updates...");
|
||||
|
|
@ -160,8 +163,8 @@ async fn check_updates_sequential(
|
|||
.name
|
||||
.values()
|
||||
.next()
|
||||
.unwrap_or(&"Unknown".to_string())
|
||||
.clone();
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
pb.set_message(format!("Checking {project_name}..."));
|
||||
|
||||
match check_project_update(project, lockfile).await {
|
||||
|
|
@ -184,6 +187,11 @@ async fn check_updates_sequential(
|
|||
Ok((updates, errors))
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::expect_used,
|
||||
reason = "progress bar template and semaphore acquire are infallible in \
|
||||
this context"
|
||||
)]
|
||||
async fn check_updates_parallel(
|
||||
lockfile: &LockFile,
|
||||
) -> Result<(Vec<ProjectUpdate>, Vec<(String, String)>)> {
|
||||
|
|
@ -196,7 +204,7 @@ async fn check_updates_parallel(
|
|||
pb.set_style(
|
||||
ProgressStyle::default_bar()
|
||||
.template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}")
|
||||
.unwrap()
|
||||
.expect("progress bar template is valid")
|
||||
.progress_chars("#>-"),
|
||||
);
|
||||
pb.set_message("Checking for updates (parallel)...");
|
||||
|
|
@ -208,7 +216,7 @@ async fn check_updates_parallel(
|
|||
let lockfile_clone = lockfile.clone();
|
||||
|
||||
futures.push(async move {
|
||||
let _permit = sem.acquire().await.unwrap();
|
||||
let _permit = sem.acquire().await.expect("semaphore closed unexpectedly");
|
||||
let result = check_project_update(&project, &lockfile_clone).await;
|
||||
pb_clone.inc(1);
|
||||
(project, result)
|
||||
|
|
@ -230,8 +238,8 @@ async fn check_updates_parallel(
|
|||
.name
|
||||
.values()
|
||||
.next()
|
||||
.unwrap_or(&"Unknown".to_string())
|
||||
.clone();
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
errors.push((project_name, e.to_string()));
|
||||
},
|
||||
}
|
||||
|
|
@ -260,37 +268,30 @@ async fn check_project_update(
|
|||
// Try each platform in project
|
||||
for platform_name in project.id.keys() {
|
||||
let api_key = get_api_key(platform_name);
|
||||
let platform = match create_platform(platform_name, api_key) {
|
||||
Ok(p) => p,
|
||||
Err(_) => continue,
|
||||
let Ok(platform) = create_platform(platform_name, api_key) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let loaders: Vec<String> = lockfile.loaders.keys().cloned().collect();
|
||||
|
||||
match platform
|
||||
if let Ok(updated_project) = platform
|
||||
.request_project_with_files(&slug, &lockfile.mc_versions, &loaders)
|
||||
.await
|
||||
{
|
||||
Ok(updated_project) => {
|
||||
// Compare files to detect updates
|
||||
let file_updates = detect_file_updates(project, &updated_project);
|
||||
// Compare files to detect updates
|
||||
let file_updates = detect_file_updates(project, &updated_project);
|
||||
|
||||
if !file_updates.is_empty() {
|
||||
return Ok(Some(ProjectUpdate {
|
||||
slug: project.slug.clone(),
|
||||
name: project.name.values().next().cloned().unwrap_or_default(),
|
||||
project_type: format!("{:?}", project.r#type),
|
||||
side: format!("{:?}", project.side),
|
||||
file_updates,
|
||||
}));
|
||||
}
|
||||
if !file_updates.is_empty() {
|
||||
return Ok(Some(ProjectUpdate {
|
||||
slug: project.slug.clone(),
|
||||
name: project.name.values().next().cloned().unwrap_or_default(),
|
||||
project_type: format!("{:?}", project.r#type),
|
||||
side: format!("{:?}", project.side),
|
||||
file_updates,
|
||||
}));
|
||||
}
|
||||
|
||||
return Ok(None); // No updates
|
||||
},
|
||||
Err(_) => {
|
||||
// Try next platform
|
||||
continue;
|
||||
},
|
||||
return Ok(None); // No updates
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,10 @@ enum SyncChange {
|
|||
Removal(String), // project_pakku_id
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::expect_used,
|
||||
reason = "spinner template is a string literal and is always valid"
|
||||
)]
|
||||
pub async fn execute(
|
||||
args: SyncArgs,
|
||||
global_yes: bool,
|
||||
|
|
@ -27,14 +31,14 @@ pub async fn execute(
|
|||
) -> Result<()> {
|
||||
log::info!("Synchronizing with lockfile");
|
||||
|
||||
let lockfile_dir = lockfile_path.parent().unwrap_or(Path::new("."));
|
||||
let config_dir = config_path.parent().unwrap_or(Path::new("."));
|
||||
let lockfile_dir = lockfile_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let config_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
|
||||
let mut lockfile = LockFile::load(lockfile_dir)?;
|
||||
let config = Config::load(config_dir)?;
|
||||
|
||||
// Detect changes
|
||||
let changes = detect_changes(&lockfile, &config)?;
|
||||
let changes = detect_changes(&lockfile, &config);
|
||||
|
||||
if changes.is_empty() {
|
||||
println!("✓ Everything is in sync");
|
||||
|
|
@ -59,10 +63,12 @@ pub async fn execute(
|
|||
spinner.set_style(
|
||||
ProgressStyle::default_spinner()
|
||||
.template("{spinner:.green} {msg}")
|
||||
.unwrap(),
|
||||
.expect("spinner template is valid"),
|
||||
);
|
||||
|
||||
if no_filter || args.additions {
|
||||
let mut file_hashes = Vec::new();
|
||||
|
||||
for (file_path, _) in &additions {
|
||||
spinner
|
||||
.set_message(format!("Processing addition: {}", file_path.display()));
|
||||
|
|
@ -70,8 +76,34 @@ pub async fn execute(
|
|||
&format!("Add {} to lockfile?", file_path.display()),
|
||||
false,
|
||||
global_yes,
|
||||
)? {
|
||||
add_file_to_lockfile(&mut lockfile, file_path, &config).await?;
|
||||
)? && let Ok(file_data) = fs::read(file_path)
|
||||
{
|
||||
use sha1::Digest;
|
||||
let mut hasher = sha1::Sha1::new();
|
||||
hasher.update(&file_data);
|
||||
let hash =
|
||||
crate::utils::hash::hash_to_hex(hasher.finalize().as_slice());
|
||||
file_hashes.push(FileHash {
|
||||
path: file_path.clone(),
|
||||
hash,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if !file_hashes.is_empty() {
|
||||
let fallback_hashes = file_hashes.clone();
|
||||
let result = add_files_batch(&mut lockfile, file_hashes).await;
|
||||
if let Err(e) = result {
|
||||
log::warn!(
|
||||
"Batch lookup failed, falling back to individual lookups: {e}"
|
||||
);
|
||||
for fh in fallback_hashes {
|
||||
if let Err(e) =
|
||||
add_file_to_lockfile(&mut lockfile, &fh.path, &config).await
|
||||
{
|
||||
log::warn!("Failed to add {}: {}", fh.path.display(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -117,10 +149,7 @@ pub async fn execute(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn detect_changes(
|
||||
lockfile: &LockFile,
|
||||
config: &Config,
|
||||
) -> Result<Vec<SyncChange>> {
|
||||
fn detect_changes(lockfile: &LockFile, config: &Config) -> Vec<SyncChange> {
|
||||
let mut changes = Vec::new();
|
||||
|
||||
// Get paths for each project type
|
||||
|
|
@ -149,23 +178,26 @@ fn detect_changes(
|
|||
&& ext == "jar"
|
||||
&& !lockfile_files.contains_key(&path)
|
||||
{
|
||||
let name = path.file_name().unwrap().to_string_lossy().to_string();
|
||||
let name = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
changes.push(SyncChange::Addition(path, name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for removals (projects in lockfile but files missing)
|
||||
let filesystem_files: HashSet<_> =
|
||||
if let Ok(entries) = fs::read_dir(mods_path) {
|
||||
let filesystem_files: HashSet<_> = fs::read_dir(mods_path).map_or_else(
|
||||
|_| HashSet::new(),
|
||||
|entries| {
|
||||
entries
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.is_file())
|
||||
.collect()
|
||||
} else {
|
||||
HashSet::new()
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
for (lockfile_path, pakku_id) in &lockfile_files {
|
||||
if !filesystem_files.contains(lockfile_path) {
|
||||
|
|
@ -173,7 +205,7 @@ fn detect_changes(
|
|||
}
|
||||
}
|
||||
|
||||
Ok(changes)
|
||||
changes
|
||||
}
|
||||
|
||||
async fn add_file_to_lockfile(
|
||||
|
|
@ -181,17 +213,17 @@ async fn add_file_to_lockfile(
|
|||
file_path: &Path,
|
||||
_config: &Config,
|
||||
) -> Result<()> {
|
||||
use sha1::Digest;
|
||||
|
||||
// Try to identify the file by hash lookup
|
||||
let modrinth = ModrinthPlatform::new();
|
||||
let curseforge = CurseForgePlatform::new(None);
|
||||
|
||||
// Compute file hash
|
||||
let file_data = fs::read(file_path)?;
|
||||
// Compute SHA-1 hash from file bytes
|
||||
use sha1::Digest;
|
||||
let mut hasher = sha1::Sha1::new();
|
||||
hasher.update(&file_data);
|
||||
let hash = format!("{:x}", hasher.finalize());
|
||||
let hash = crate::utils::hash::hash_to_hex(hasher.finalize().as_slice());
|
||||
|
||||
// Try Modrinth first (SHA-1 hash)
|
||||
if let Ok(Some(project)) = modrinth.lookup_by_hash(&hash).await {
|
||||
|
|
@ -210,3 +242,68 @@ async fn add_file_to_lockfile(
|
|||
println!("⚠ Could not identify {}, skipping", file_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FileHash {
|
||||
path: PathBuf,
|
||||
hash: String,
|
||||
}
|
||||
|
||||
async fn add_files_batch(
|
||||
lockfile: &mut LockFile,
|
||||
file_hashes: Vec<FileHash>,
|
||||
) -> Result<()> {
|
||||
if file_hashes.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let modrinth = ModrinthPlatform::new();
|
||||
|
||||
let hashes: Vec<String> =
|
||||
file_hashes.iter().map(|fh| fh.hash.clone()).collect();
|
||||
|
||||
let projects = modrinth
|
||||
.request_projects_from_hashes(&hashes, "sha1")
|
||||
.await?;
|
||||
|
||||
let mut matched_indices: std::collections::HashSet<usize> =
|
||||
std::collections::HashSet::new();
|
||||
let mut added_pakku_ids: std::collections::HashSet<String> =
|
||||
std::collections::HashSet::new();
|
||||
|
||||
for project in &projects {
|
||||
let pakku_id = match &project.pakku_id {
|
||||
Some(id) => id.clone(),
|
||||
None => continue,
|
||||
};
|
||||
if added_pakku_ids.contains(&pakku_id) {
|
||||
continue;
|
||||
}
|
||||
for file_info in &project.files {
|
||||
for (idx, fh) in file_hashes.iter().enumerate() {
|
||||
if !matched_indices.contains(&idx)
|
||||
&& file_info
|
||||
.hashes
|
||||
.get("sha1")
|
||||
.map(std::string::String::as_str)
|
||||
== Some(&fh.hash)
|
||||
{
|
||||
lockfile.add_project(project.clone());
|
||||
added_pakku_ids.insert(pakku_id.clone());
|
||||
matched_indices.insert(idx);
|
||||
println!("✓ Added {} (from Modrinth)", fh.path.display());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (idx, fh) in file_hashes.iter().enumerate() {
|
||||
if matched_indices.contains(&idx) {
|
||||
continue;
|
||||
}
|
||||
println!("⚠ Could not identify {}, skipping", fh.path.display());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,11 +6,11 @@ use crate::{
|
|||
model::LockFile,
|
||||
};
|
||||
|
||||
pub fn execute(args: UnlinkArgs, lockfile_path: &Path) -> Result<()> {
|
||||
pub fn execute(args: &UnlinkArgs, lockfile_path: &Path) -> Result<()> {
|
||||
log::info!("Unlinking {} -> {}", args.from, args.to);
|
||||
|
||||
// Load expects directory path, so get parent directory
|
||||
let lockfile_dir = lockfile_path.parent().unwrap_or(Path::new("."));
|
||||
let lockfile_dir = lockfile_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let mut lockfile = LockFile::load(lockfile_dir)?;
|
||||
|
||||
// Find projects
|
||||
|
|
|
|||
|
|
@ -7,8 +7,13 @@ use crate::{
|
|||
error::{MultiError, PakkerError},
|
||||
model::{Config, LockFile, UpdateStrategy},
|
||||
ui_utils::{prompt_select, prompt_typo_suggestion, prompt_yes_no},
|
||||
utils::FlexVer,
|
||||
};
|
||||
|
||||
#[expect(
|
||||
clippy::expect_used,
|
||||
reason = "progress bar template is a string literal and is always valid"
|
||||
)]
|
||||
pub async fn execute(
|
||||
args: UpdateArgs,
|
||||
global_yes: bool,
|
||||
|
|
@ -17,14 +22,14 @@ pub async fn execute(
|
|||
) -> Result<(), PakkerError> {
|
||||
let skip_prompts = global_yes;
|
||||
// Load expects directory path, so get parent directory
|
||||
let lockfile_dir = lockfile_path.parent().unwrap_or(Path::new("."));
|
||||
let config_dir = config_path.parent().unwrap_or(Path::new("."));
|
||||
let lockfile_dir = lockfile_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let config_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
|
||||
let mut lockfile = LockFile::load(lockfile_dir)?;
|
||||
let _config = Config::load(config_dir)?;
|
||||
|
||||
// Create platforms
|
||||
let platforms = super::add::create_all_platforms()?;
|
||||
let platforms = super::add::create_all_platforms();
|
||||
|
||||
// Collect all known project identifiers for typo suggestions
|
||||
let all_slugs: Vec<String> = lockfile
|
||||
|
|
@ -82,7 +87,7 @@ pub async fn execute(
|
|||
pb.set_style(
|
||||
ProgressStyle::default_bar()
|
||||
.template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}")
|
||||
.unwrap()
|
||||
.expect("progress bar template is valid")
|
||||
.progress_chars("#>-"),
|
||||
);
|
||||
|
||||
|
|
@ -141,10 +146,22 @@ pub async fn execute(
|
|||
&& !updated_project.files.is_empty()
|
||||
&& let Some(old_file) = lockfile.projects[idx].files.first()
|
||||
{
|
||||
// Sort files by FlexVer if that strategy is set
|
||||
if old_project.update_strategy == UpdateStrategy::FlexVer {
|
||||
updated_project.files.sort_by(|a, b| {
|
||||
// Use FlexVer for comparison - b.cmp(a) gives descending order
|
||||
// (newest first)
|
||||
FlexVer(&b.file_name).cmp(&FlexVer(&a.file_name))
|
||||
});
|
||||
}
|
||||
|
||||
// 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 first_file = updated_project
|
||||
.files
|
||||
.first()
|
||||
.ok_or_else(|| PakkerError::InvalidProject("No files found".into()))?;
|
||||
let new_file_id = first_file.id.clone();
|
||||
let new_file_name = first_file.file_name.clone();
|
||||
let old_file_name = old_file.file_name.clone();
|
||||
let project_name = old_project.get_name();
|
||||
|
||||
|
|
@ -195,7 +212,12 @@ pub async fn execute(
|
|||
}
|
||||
|
||||
if should_update {
|
||||
let selected_file = updated_project.files.first().unwrap();
|
||||
let selected_file =
|
||||
updated_project.files.first().ok_or_else(|| {
|
||||
PakkerError::InvalidProject(
|
||||
"No files found after selection".into(),
|
||||
)
|
||||
})?;
|
||||
pb.println(format!(
|
||||
" {} -> {}",
|
||||
old_file_name, selected_file.file_name
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
use std::fmt::Write;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
pub type Result<T> = std::result::Result<T, PakkerError>;
|
||||
|
|
@ -11,6 +13,7 @@ pub enum ErrorSeverity {
|
|||
/// Warning - operation can continue but may have issues
|
||||
Warning,
|
||||
/// Info - informational message
|
||||
#[expect(dead_code, reason = "reserved for future use")]
|
||||
Info,
|
||||
}
|
||||
|
||||
|
|
@ -177,7 +180,7 @@ fn format_multiple_errors(errors: &[PakkerError]) -> String {
|
|||
|
||||
let mut msg = format!("{} errors occurred:\n", errors.len());
|
||||
for (idx, error) in errors.iter().enumerate() {
|
||||
msg.push_str(&format!(" {}. {}\n", idx + 1, error));
|
||||
let _ = writeln!(msg, " {}. {}", idx + 1, error);
|
||||
}
|
||||
msg
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,8 +36,9 @@ impl Exporter {
|
|||
///
|
||||
/// Returns successfully exported files. If any profile failed (non-skip),
|
||||
/// returns an error after attempting all profiles.
|
||||
#[expect(clippy::future_not_send, reason = "not required to be Send")]
|
||||
pub async fn export_all_profiles(
|
||||
&mut self,
|
||||
&self,
|
||||
lockfile: &LockFile,
|
||||
config: &Config,
|
||||
output_path: &Path,
|
||||
|
|
@ -99,8 +100,13 @@ impl Exporter {
|
|||
}
|
||||
|
||||
/// Export modpack using specified profile
|
||||
#[expect(clippy::future_not_send, reason = "not required to be Send")]
|
||||
#[expect(
|
||||
clippy::expect_used,
|
||||
reason = "spinner template string is a literal and always valid"
|
||||
)]
|
||||
pub async fn export(
|
||||
&mut self,
|
||||
&self,
|
||||
profile_name: &str,
|
||||
lockfile: &LockFile,
|
||||
config: &Config,
|
||||
|
|
@ -110,7 +116,7 @@ impl Exporter {
|
|||
spinner.set_style(
|
||||
ProgressStyle::default_spinner()
|
||||
.template("{spinner:.cyan} {msg}")
|
||||
.unwrap(),
|
||||
.expect("spinner template is valid"),
|
||||
);
|
||||
spinner.set_message(format!("Preparing {profile_name} export..."));
|
||||
|
||||
|
|
@ -175,7 +181,7 @@ impl Exporter {
|
|||
spinner.set_message("Creating archive...");
|
||||
// Package export
|
||||
let output_file =
|
||||
self.package_export(export_dir, output_path, profile_name, config)?;
|
||||
Self::package_export(export_dir, output_path, profile_name, config)?;
|
||||
|
||||
// Cleanup
|
||||
drop(temp_dir);
|
||||
|
|
@ -187,7 +193,6 @@ impl Exporter {
|
|||
|
||||
/// Package export directory into final format
|
||||
fn package_export(
|
||||
&self,
|
||||
export_dir: &Path,
|
||||
output_path: &Path,
|
||||
profile_name: &str,
|
||||
|
|
@ -224,7 +229,7 @@ impl Exporter {
|
|||
.unix_permissions(0o755);
|
||||
|
||||
// Add all files from export directory
|
||||
self.add_directory_to_zip(&mut zip, export_dir, export_dir, options)?;
|
||||
Self::add_directory_to_zip(&mut zip, export_dir, export_dir, options)?;
|
||||
|
||||
zip.finish()?;
|
||||
|
||||
|
|
@ -233,7 +238,6 @@ impl Exporter {
|
|||
|
||||
/// Recursively add directory to zip
|
||||
fn add_directory_to_zip(
|
||||
&self,
|
||||
zip: &mut zip::ZipWriter<fs::File>,
|
||||
base_path: &Path,
|
||||
current_path: &Path,
|
||||
|
|
@ -255,7 +259,7 @@ impl Exporter {
|
|||
relative_path.to_string_lossy().to_string(),
|
||||
options,
|
||||
)?;
|
||||
self.add_directory_to_zip(zip, base_path, &path, options)?;
|
||||
Self::add_directory_to_zip(zip, base_path, &path, options)?;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ impl ProfileConfig {
|
|||
self
|
||||
.server_overrides
|
||||
.as_deref()
|
||||
.or(global_server_overrides.map(std::vec::Vec::as_slice))
|
||||
.or_else(|| global_server_overrides.map(std::vec::Vec::as_slice))
|
||||
}
|
||||
|
||||
/// Get effective client override paths, falling back to global config
|
||||
|
|
@ -77,7 +77,7 @@ impl ProfileConfig {
|
|||
self
|
||||
.client_overrides
|
||||
.as_deref()
|
||||
.or(global_client_overrides.map(std::vec::Vec::as_slice))
|
||||
.or_else(|| global_client_overrides.map(std::vec::Vec::as_slice))
|
||||
}
|
||||
|
||||
/// Get default config for `CurseForge` profile
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ impl Effect for CopyProjectFilesEffect {
|
|||
use crate::model::ResolvedCredentials;
|
||||
|
||||
// Resolve credentials (env -> keyring -> Pakker file -> Pakku file).
|
||||
let credentials = ResolvedCredentials::load()?;
|
||||
let credentials = ResolvedCredentials::load();
|
||||
let curseforge_key =
|
||||
credentials.curseforge_api_key().map(ToOwned::to_owned);
|
||||
let modrinth_token = credentials.modrinth_token().map(ToOwned::to_owned);
|
||||
|
|
@ -66,14 +66,13 @@ impl Effect for CopyProjectFilesEffect {
|
|||
|
||||
if let Some(file) = project.files.first() {
|
||||
// Get the target directory based on project type and paths config
|
||||
let type_dir = get_project_type_dir(&project.r#type, &context.config);
|
||||
let type_dir = get_project_type_dir(project.r#type, &context.config);
|
||||
|
||||
// Handle subpath if specified
|
||||
let target_subdir = if let Some(subpath) = &project.subpath {
|
||||
PathBuf::from(&type_dir).join(subpath)
|
||||
} else {
|
||||
PathBuf::from(&type_dir)
|
||||
};
|
||||
let target_subdir = project.subpath.as_ref().map_or_else(
|
||||
|| PathBuf::from(&type_dir),
|
||||
|subpath| PathBuf::from(&type_dir).join(subpath),
|
||||
);
|
||||
|
||||
let export_dir = context.export_path.join(&target_subdir);
|
||||
fs::create_dir_all(&export_dir)?;
|
||||
|
|
@ -204,7 +203,15 @@ async fn download_file(
|
|||
let attempts: usize = 5;
|
||||
|
||||
for attempt in 1..=attempts {
|
||||
let response = request_builder.try_clone().unwrap().send().await;
|
||||
let response = request_builder
|
||||
.try_clone()
|
||||
.ok_or_else(|| {
|
||||
crate::error::PakkerError::InternalError(
|
||||
"Failed to clone request builder".into(),
|
||||
)
|
||||
})?
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match response {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
|
|
@ -295,11 +302,12 @@ impl Effect for CopyOverridesEffect {
|
|||
|
||||
async fn execute(&self, context: &RuleContext) -> Result<()> {
|
||||
// Use profile-specific overrides if available, otherwise use global config
|
||||
let overrides = if let Some(profile_config) = &context.profile_config {
|
||||
profile_config.get_overrides(&context.config.overrides)
|
||||
} else {
|
||||
&context.config.overrides
|
||||
};
|
||||
let overrides = context
|
||||
.profile_config
|
||||
.as_ref()
|
||||
.map_or(context.config.overrides.as_slice(), |profile_config| {
|
||||
profile_config.get_overrides(&context.config.overrides)
|
||||
});
|
||||
|
||||
// Expand any glob patterns in override paths
|
||||
let expanded_paths = expand_override_globs(&context.base_path, overrides);
|
||||
|
|
@ -342,13 +350,13 @@ impl Effect for CopyServerOverridesEffect {
|
|||
async fn execute(&self, context: &RuleContext) -> Result<()> {
|
||||
// Use profile-specific server overrides if available, otherwise use global
|
||||
// config
|
||||
let server_overrides = if let Some(profile_config) = &context.profile_config
|
||||
{
|
||||
profile_config
|
||||
.get_server_overrides(context.config.server_overrides.as_ref())
|
||||
} else {
|
||||
context.config.server_overrides.as_deref()
|
||||
};
|
||||
let server_overrides = context.profile_config.as_ref().map_or(
|
||||
context.config.server_overrides.as_deref(),
|
||||
|profile_config| {
|
||||
profile_config
|
||||
.get_server_overrides(context.config.server_overrides.as_ref())
|
||||
},
|
||||
);
|
||||
|
||||
if let Some(overrides) = server_overrides {
|
||||
// Expand any glob patterns in override paths
|
||||
|
|
@ -393,13 +401,13 @@ impl Effect for CopyClientOverridesEffect {
|
|||
async fn execute(&self, context: &RuleContext) -> Result<()> {
|
||||
// Use profile-specific client overrides if available, otherwise use global
|
||||
// config
|
||||
let client_overrides = if let Some(profile_config) = &context.profile_config
|
||||
{
|
||||
profile_config
|
||||
.get_client_overrides(context.config.client_overrides.as_ref())
|
||||
} else {
|
||||
context.config.client_overrides.as_deref()
|
||||
};
|
||||
let client_overrides = context.profile_config.as_ref().map_or(
|
||||
context.config.client_overrides.as_deref(),
|
||||
|profile_config| {
|
||||
profile_config
|
||||
.get_client_overrides(context.config.client_overrides.as_ref())
|
||||
},
|
||||
);
|
||||
|
||||
if let Some(overrides) = client_overrides {
|
||||
// Expand any glob patterns in override paths
|
||||
|
|
@ -459,7 +467,7 @@ impl Effect for FilterClientOnlyEffect {
|
|||
&& let Some(file) = project.files.first()
|
||||
{
|
||||
// Get the target directory based on project type and paths config
|
||||
let type_dir = get_project_type_dir(&project.r#type, &context.config);
|
||||
let type_dir = get_project_type_dir(project.r#type, &context.config);
|
||||
let project_dir = context.export_path.join(&type_dir);
|
||||
let file_path = project_dir.join(&file.file_name);
|
||||
|
||||
|
|
@ -514,7 +522,7 @@ impl Effect for FilterServerOnlyEffect {
|
|||
&& let Some(file) = project.files.first()
|
||||
{
|
||||
// Get the target directory based on project type and paths config
|
||||
let type_dir = get_project_type_dir(&project.r#type, &context.config);
|
||||
let type_dir = get_project_type_dir(project.r#type, &context.config);
|
||||
let project_dir = context.export_path.join(&type_dir);
|
||||
let file_path = project_dir.join(&file.file_name);
|
||||
|
||||
|
|
@ -573,7 +581,7 @@ impl Effect for FilterNonRedistributableEffect {
|
|||
&& let Some(file) = project.files.first()
|
||||
{
|
||||
// Get the target directory based on project type and paths config
|
||||
let type_dir = get_project_type_dir(&project.r#type, &context.config);
|
||||
let type_dir = get_project_type_dir(project.r#type, &context.config);
|
||||
let project_dir = context.export_path.join(&type_dir);
|
||||
let file_path = project_dir.join(&file.file_name);
|
||||
|
||||
|
|
@ -668,7 +676,7 @@ fn generate_curseforge_manifest(context: &RuleContext) -> Result<String> {
|
|||
|
||||
let manifest = json!({
|
||||
"minecraft": {
|
||||
"version": context.lockfile.mc_versions.first().unwrap_or(&"1.20.1".to_string()),
|
||||
"version": context.lockfile.mc_versions.first().map_or("1.20.1", String::as_str),
|
||||
"modLoaders": context.lockfile.loaders.iter().map(|(name, version)| {
|
||||
json!({
|
||||
"id": format!("{}-{}", name, version),
|
||||
|
|
@ -736,7 +744,7 @@ fn generate_modrinth_manifest(context: &RuleContext) -> Result<String> {
|
|||
.lockfile
|
||||
.mc_versions
|
||||
.first()
|
||||
.unwrap_or(&"1.20.1".to_string())
|
||||
.map_or("1.20.1", String::as_str)
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -781,7 +789,7 @@ fn copy_recursive(
|
|||
|
||||
/// Get the target directory for a project type, respecting the paths config.
|
||||
/// Falls back to default directories if not configured.
|
||||
fn get_project_type_dir(project_type: &ProjectType, config: &Config) -> String {
|
||||
fn get_project_type_dir(project_type: ProjectType, config: &Config) -> String {
|
||||
// Check if there's a custom path configured for this project type
|
||||
let type_key = project_type.to_string();
|
||||
if let Some(custom_path) = config.paths.get(&type_key) {
|
||||
|
|
@ -881,7 +889,7 @@ impl Effect for FilterByPlatformEffect {
|
|||
if let Some(file) = project.files.first() {
|
||||
// Get the target directory based on project type and paths config
|
||||
let type_dir =
|
||||
get_project_type_dir(&project.r#type, &context.config);
|
||||
get_project_type_dir(project.r#type, &context.config);
|
||||
let project_dir = context.export_path.join(&type_dir);
|
||||
let file_path = project_dir.join(&file.file_name);
|
||||
|
||||
|
|
@ -942,13 +950,10 @@ impl Effect for MissingProjectsAsOverridesEffect {
|
|||
async fn execute(&self, context: &RuleContext) -> Result<()> {
|
||||
use crate::model::ResolvedCredentials;
|
||||
|
||||
let credentials = ResolvedCredentials::load().ok();
|
||||
let curseforge_key = credentials
|
||||
.as_ref()
|
||||
.and_then(|c| c.curseforge_api_key().map(ToOwned::to_owned));
|
||||
let modrinth_token = credentials
|
||||
.as_ref()
|
||||
.and_then(|c| c.modrinth_token().map(ToOwned::to_owned));
|
||||
let credentials = ResolvedCredentials::load();
|
||||
let curseforge_key =
|
||||
credentials.curseforge_api_key().map(ToOwned::to_owned);
|
||||
let modrinth_token = credentials.modrinth_token().map(ToOwned::to_owned);
|
||||
|
||||
for project in &context.lockfile.projects {
|
||||
if !project.export {
|
||||
|
|
@ -977,7 +982,7 @@ impl Effect for MissingProjectsAsOverridesEffect {
|
|||
|
||||
// Download to overrides directory
|
||||
let overrides_dir = context.export_path.join("overrides");
|
||||
let type_dir = get_project_type_dir(&project.r#type, &context.config);
|
||||
let type_dir = get_project_type_dir(project.r#type, &context.config);
|
||||
let target_dir = overrides_dir.join(&type_dir);
|
||||
fs::create_dir_all(&target_dir)?;
|
||||
|
||||
|
|
@ -1128,11 +1133,6 @@ fn process_text_files(
|
|||
dir: &std::path::Path,
|
||||
replacements: &std::collections::HashMap<&str, String>,
|
||||
) -> Result<()> {
|
||||
if !dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// File extensions that should be processed for text replacement
|
||||
const TEXT_EXTENSIONS: &[&str] = &[
|
||||
"txt",
|
||||
"md",
|
||||
|
|
@ -1150,6 +1150,10 @@ fn process_text_files(
|
|||
"xml",
|
||||
];
|
||||
|
||||
if !dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for entry in walkdir::WalkDir::new(dir)
|
||||
.into_iter()
|
||||
.filter_map(std::result::Result::ok)
|
||||
|
|
@ -1170,9 +1174,8 @@ fn process_text_files(
|
|||
}
|
||||
|
||||
// Read file content
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue, // Skip binary files or unreadable files
|
||||
let Ok(content) = fs::read_to_string(path) else {
|
||||
continue; // Skip binary files or unreadable files
|
||||
};
|
||||
|
||||
// Check if any replacements are needed
|
||||
|
|
@ -1366,20 +1369,20 @@ mod tests {
|
|||
file_count_preference: None,
|
||||
};
|
||||
|
||||
assert_eq!(get_project_type_dir(&ProjectType::Mod, &config), "mods");
|
||||
assert_eq!(get_project_type_dir(ProjectType::Mod, &config), "mods");
|
||||
assert_eq!(
|
||||
get_project_type_dir(&ProjectType::ResourcePack, &config),
|
||||
get_project_type_dir(ProjectType::ResourcePack, &config),
|
||||
"resourcepacks"
|
||||
);
|
||||
assert_eq!(
|
||||
get_project_type_dir(&ProjectType::DataPack, &config),
|
||||
get_project_type_dir(ProjectType::DataPack, &config),
|
||||
"datapacks"
|
||||
);
|
||||
assert_eq!(
|
||||
get_project_type_dir(&ProjectType::Shader, &config),
|
||||
get_project_type_dir(ProjectType::Shader, &config),
|
||||
"shaderpacks"
|
||||
);
|
||||
assert_eq!(get_project_type_dir(&ProjectType::World, &config), "saves");
|
||||
assert_eq!(get_project_type_dir(ProjectType::World, &config), "saves");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1404,16 +1407,16 @@ mod tests {
|
|||
};
|
||||
|
||||
assert_eq!(
|
||||
get_project_type_dir(&ProjectType::Mod, &config),
|
||||
get_project_type_dir(ProjectType::Mod, &config),
|
||||
"custom-mods"
|
||||
);
|
||||
assert_eq!(
|
||||
get_project_type_dir(&ProjectType::ResourcePack, &config),
|
||||
get_project_type_dir(ProjectType::ResourcePack, &config),
|
||||
"custom-rp"
|
||||
);
|
||||
// Non-customized type should use default
|
||||
assert_eq!(
|
||||
get_project_type_dir(&ProjectType::Shader, &config),
|
||||
get_project_type_dir(ProjectType::Shader, &config),
|
||||
"shaderpacks"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
133
src/fetch.rs
133
src/fetch.rs
|
|
@ -11,25 +11,27 @@ use tokio::sync::Semaphore;
|
|||
|
||||
use crate::{
|
||||
error::{PakkerError, Result},
|
||||
model::{Config, LockFile, Project, ProjectFile},
|
||||
utils::verify_hash,
|
||||
model::{Config, LockFile, Project, ProjectFile, UpdateStrategy},
|
||||
utils::{FlexVer, verify_hash},
|
||||
};
|
||||
|
||||
/// Maximum number of concurrent downloads
|
||||
const MAX_CONCURRENT_DOWNLOADS: usize = 8;
|
||||
|
||||
pub struct Fetcher {
|
||||
client: Client,
|
||||
base_path: PathBuf,
|
||||
shelve: bool,
|
||||
client: Client,
|
||||
base_path: PathBuf,
|
||||
shelve: bool,
|
||||
retry_count: u32,
|
||||
}
|
||||
|
||||
impl Fetcher {
|
||||
pub fn new<P: AsRef<Path>>(base_path: P) -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
base_path: base_path.as_ref().to_path_buf(),
|
||||
shelve: false,
|
||||
client: Client::new(),
|
||||
base_path: base_path.as_ref().to_path_buf(),
|
||||
shelve: false,
|
||||
retry_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -38,11 +40,20 @@ impl Fetcher {
|
|||
self
|
||||
}
|
||||
|
||||
pub const fn with_retry(mut self, retry_count: u32) -> Self {
|
||||
self.retry_count = retry_count;
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn sync(&self, lockfile: &LockFile, config: &Config) -> Result<()> {
|
||||
self.fetch_all(lockfile, config).await
|
||||
}
|
||||
|
||||
/// Fetch all project files according to lockfile with parallel downloads
|
||||
#[expect(
|
||||
clippy::expect_used,
|
||||
reason = "progress bar template string is a literal and always valid"
|
||||
)]
|
||||
pub async fn fetch_all(
|
||||
&self,
|
||||
lockfile: &LockFile,
|
||||
|
|
@ -64,7 +75,7 @@ impl Fetcher {
|
|||
overall_bar.set_style(
|
||||
ProgressStyle::default_bar()
|
||||
.template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}")
|
||||
.unwrap()
|
||||
.expect("progress bar template is valid")
|
||||
.progress_chars("#>-"),
|
||||
);
|
||||
overall_bar.set_message("Fetching projects...");
|
||||
|
|
@ -96,6 +107,7 @@ impl Fetcher {
|
|||
client,
|
||||
base_path,
|
||||
shelve: false, // Shelving happens at sync level, not per-project
|
||||
retry_count: 0,
|
||||
};
|
||||
|
||||
let result = fetcher.fetch_project(&project, lockfile, config).await;
|
||||
|
|
@ -172,23 +184,23 @@ impl Fetcher {
|
|||
let project_dirs = [
|
||||
(
|
||||
"mod",
|
||||
self.get_default_path(&crate::model::ProjectType::Mod),
|
||||
Self::get_default_path(crate::model::ProjectType::Mod),
|
||||
),
|
||||
(
|
||||
"resource-pack",
|
||||
self.get_default_path(&crate::model::ProjectType::ResourcePack),
|
||||
Self::get_default_path(crate::model::ProjectType::ResourcePack),
|
||||
),
|
||||
(
|
||||
"shader",
|
||||
self.get_default_path(&crate::model::ProjectType::Shader),
|
||||
Self::get_default_path(crate::model::ProjectType::Shader),
|
||||
),
|
||||
(
|
||||
"data-pack",
|
||||
self.get_default_path(&crate::model::ProjectType::DataPack),
|
||||
Self::get_default_path(crate::model::ProjectType::DataPack),
|
||||
),
|
||||
(
|
||||
"world",
|
||||
self.get_default_path(&crate::model::ProjectType::World),
|
||||
Self::get_default_path(crate::model::ProjectType::World),
|
||||
),
|
||||
];
|
||||
|
||||
|
|
@ -211,9 +223,8 @@ impl Fetcher {
|
|||
continue;
|
||||
}
|
||||
|
||||
let entries = match fs::read_dir(&dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => continue,
|
||||
let Ok(entries) = fs::read_dir(&dir) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
|
|
@ -233,7 +244,10 @@ impl Fetcher {
|
|||
}
|
||||
|
||||
// Skip non-jar files (might be configs, etc.)
|
||||
if !file_name.ends_with(".jar") {
|
||||
if !std::path::Path::new(&file_name)
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("jar"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -271,7 +285,7 @@ impl Fetcher {
|
|||
config: &Config,
|
||||
) -> Result<()> {
|
||||
// Select the best file for this project
|
||||
let file = self.select_best_file(project, lockfile)?;
|
||||
let file = Self::select_best_file(project, lockfile)?;
|
||||
|
||||
// Determine target path
|
||||
let target_path = self.get_target_path(project, file, config);
|
||||
|
|
@ -306,8 +320,11 @@ impl Fetcher {
|
|||
}
|
||||
|
||||
/// Select the best file for a project based on constraints
|
||||
#[expect(
|
||||
clippy::expect_used,
|
||||
reason = "compatible_files is checked to be non-empty above"
|
||||
)]
|
||||
fn select_best_file<'a>(
|
||||
&self,
|
||||
project: &'a Project,
|
||||
lockfile: &LockFile,
|
||||
) -> Result<&'a ProjectFile> {
|
||||
|
|
@ -326,18 +343,27 @@ impl Fetcher {
|
|||
)));
|
||||
}
|
||||
|
||||
// Prefer release over beta over alpha
|
||||
let best = compatible_files
|
||||
.iter()
|
||||
.max_by_key(|f| {
|
||||
let type_priority = match f.release_type {
|
||||
crate::model::ReleaseType::Release => 3,
|
||||
crate::model::ReleaseType::Beta => 2,
|
||||
crate::model::ReleaseType::Alpha => 1,
|
||||
};
|
||||
(type_priority, &f.date_published)
|
||||
})
|
||||
.unwrap();
|
||||
// Select best file based on update strategy
|
||||
let best = if project.update_strategy == UpdateStrategy::FlexVer {
|
||||
let mut sorted: Vec<_> = compatible_files.iter().collect();
|
||||
sorted.sort_by(|a, b| FlexVer(&b.file_name).cmp(&FlexVer(&a.file_name)));
|
||||
*sorted
|
||||
.first()
|
||||
.expect("compatible_files is non-empty, checked above")
|
||||
} else {
|
||||
// Prefer release over beta over alpha, then by date published
|
||||
compatible_files
|
||||
.iter()
|
||||
.max_by_key(|f| {
|
||||
let type_priority = match f.release_type {
|
||||
crate::model::ReleaseType::Release => 3,
|
||||
crate::model::ReleaseType::Beta => 2,
|
||||
crate::model::ReleaseType::Alpha => 1,
|
||||
};
|
||||
(type_priority, &f.date_published)
|
||||
})
|
||||
.expect("compatible_files is non-empty, checked above")
|
||||
};
|
||||
|
||||
Ok(best)
|
||||
}
|
||||
|
|
@ -356,7 +382,7 @@ impl Fetcher {
|
|||
path.push(custom_path);
|
||||
} else {
|
||||
// Default path based on project type
|
||||
path.push(self.get_default_path(&project.r#type));
|
||||
path.push(Self::get_default_path(project.r#type));
|
||||
}
|
||||
|
||||
// Add subpath if specified
|
||||
|
|
@ -370,9 +396,8 @@ impl Fetcher {
|
|||
|
||||
/// Get default path for project type
|
||||
const fn get_default_path(
|
||||
&self,
|
||||
project_type: &crate::model::ProjectType,
|
||||
) -> &str {
|
||||
project_type: crate::model::ProjectType,
|
||||
) -> &'static str {
|
||||
match project_type {
|
||||
crate::model::ProjectType::Mod => "mods",
|
||||
crate::model::ProjectType::ResourcePack => "resourcepacks",
|
||||
|
|
@ -382,14 +407,39 @@ impl Fetcher {
|
|||
}
|
||||
}
|
||||
|
||||
/// Download a file from URL to target path
|
||||
/// Download a file from URL to target path with retry
|
||||
async fn download_file(&self, url: &str, target_path: &Path) -> Result<()> {
|
||||
// Create parent directory
|
||||
if let Some(parent) = target_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
// Download file
|
||||
let max_attempts = self.retry_count.saturating_add(1);
|
||||
|
||||
for attempt in 0..max_attempts {
|
||||
match self.download_single_attempt(url, target_path).await {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(_e) if attempt < self.retry_count => {
|
||||
log::warn!(
|
||||
"Download attempt {}/{} failed for {}, retrying...",
|
||||
attempt + 1,
|
||||
max_attempts,
|
||||
url
|
||||
);
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||
},
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn download_single_attempt(
|
||||
&self,
|
||||
url: &str,
|
||||
target_path: &Path,
|
||||
) -> Result<()> {
|
||||
let response = self.client.get(url).send().await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
|
|
@ -398,7 +448,6 @@ impl Fetcher {
|
|||
|
||||
let bytes = response.bytes().await?;
|
||||
|
||||
// Write to temporary file first (atomic write)
|
||||
let temp_path = target_path.with_extension("tmp");
|
||||
fs::write(&temp_path, bytes)?;
|
||||
fs::rename(temp_path, target_path)?;
|
||||
|
|
@ -415,14 +464,14 @@ impl Fetcher {
|
|||
}
|
||||
|
||||
// Copy override files to target locations
|
||||
self.copy_recursive(&source, &self.base_path)?;
|
||||
Self::copy_recursive(&source, &self.base_path)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copy directory recursively
|
||||
fn copy_recursive(&self, source: &Path, dest: &Path) -> Result<()> {
|
||||
fn copy_recursive(source: &Path, dest: &Path) -> Result<()> {
|
||||
if source.is_file() {
|
||||
fs::copy(source, dest)?;
|
||||
} else if source.is_dir() {
|
||||
|
|
@ -430,7 +479,7 @@ impl Fetcher {
|
|||
for entry in fs::read_dir(source)? {
|
||||
let entry = entry?;
|
||||
let target = dest.join(entry.file_name());
|
||||
self.copy_recursive(&entry.path(), &target)?;
|
||||
Self::copy_recursive(&entry.path(), &target)?;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ use git2::{
|
|||
|
||||
use crate::error::{PakkerError, Result};
|
||||
|
||||
type ProgressCallback =
|
||||
Option<Box<dyn FnMut(usize, usize, Option<usize>) + 'static>>;
|
||||
|
||||
/// Check if a directory is a Git repository
|
||||
pub fn is_git_repository<P: AsRef<Path>>(path: P) -> bool {
|
||||
Repository::open(path).is_ok()
|
||||
|
|
@ -65,9 +68,7 @@ pub fn clone_repository<P: AsRef<Path>>(
|
|||
url: &str,
|
||||
target_path: P,
|
||||
ref_name: &str,
|
||||
progress_callback: Option<
|
||||
Box<dyn FnMut(usize, usize, Option<usize>) + 'static>,
|
||||
>,
|
||||
progress_callback: ProgressCallback,
|
||||
) -> Result<Repository> {
|
||||
let target_path = target_path.as_ref();
|
||||
|
||||
|
|
@ -147,9 +148,7 @@ pub fn fetch_updates<P: AsRef<Path>>(
|
|||
path: P,
|
||||
remote_name: &str,
|
||||
ref_name: &str,
|
||||
progress_callback: Option<
|
||||
Box<dyn FnMut(usize, usize, Option<usize>) + 'static>,
|
||||
>,
|
||||
progress_callback: ProgressCallback,
|
||||
) -> Result<()> {
|
||||
let repo = Repository::open(path)?;
|
||||
let mut remote = repo.find_remote(remote_name).map_err(|e| {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,11 @@ use reqwest::Client;
|
|||
///
|
||||
/// Panics if the HTTP client cannot be built, which should only happen in
|
||||
/// extreme cases like OOM or broken TLS configuration.
|
||||
#[expect(
|
||||
clippy::expect_used,
|
||||
reason = "HTTP client build failure is unrecoverable - only fails under \
|
||||
extreme system resource exhaustion"
|
||||
)]
|
||||
pub fn create_http_client() -> Client {
|
||||
Client::builder()
|
||||
.pool_max_idle_per_host(10)
|
||||
|
|
|
|||
22
src/ipc.rs
22
src/ipc.rs
|
|
@ -63,7 +63,7 @@ pub struct OngoingOperation {
|
|||
pub status: OperationStatus,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OperationType {
|
||||
Fetch,
|
||||
|
|
@ -99,11 +99,10 @@ impl IpcCoordinator {
|
|||
/// Get the base IPC directory in tmpfs
|
||||
fn get_ipc_base_dir() -> PathBuf {
|
||||
// Use XDG_RUNTIME_DIR if available, otherwise fallback to /tmp
|
||||
if let Ok(runtime) = std::env::var("XDG_RUNTIME_DIR") {
|
||||
PathBuf::from(runtime).join("pakker")
|
||||
} else {
|
||||
PathBuf::from("/tmp/pakker")
|
||||
}
|
||||
std::env::var("XDG_RUNTIME_DIR").map_or_else(
|
||||
|_| PathBuf::from("/tmp/pakker"),
|
||||
|runtime| PathBuf::from(runtime).join("pakker"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Extract modpack hash from pakku.json's parentLockHash field.
|
||||
|
|
@ -181,7 +180,7 @@ impl IpcCoordinator {
|
|||
/// Acquire an exclusive advisory lock on the ops file for atomic operations.
|
||||
/// Returns a guard that releases the lock on drop.
|
||||
fn lock_ops_file(&self) -> Result<FileLock, IpcError> {
|
||||
log::debug!("Acquiring file lock on {:?}", self.ops_file);
|
||||
log::debug!("Acquiring file lock on {}", self.ops_file.display());
|
||||
|
||||
// Open or create the ops file with read/write access
|
||||
let file = OpenOptions::new()
|
||||
|
|
@ -200,14 +199,17 @@ impl IpcCoordinator {
|
|||
// Acquire exclusive lock using flock
|
||||
unsafe {
|
||||
if flock(file.as_raw_fd(), LOCK_EX) != 0 {
|
||||
log::warn!("Failed to acquire file lock on {:?}", self.ops_file);
|
||||
log::warn!(
|
||||
"Failed to acquire file lock on {}",
|
||||
self.ops_file.display()
|
||||
);
|
||||
return Err(IpcError::InvalidFormat(
|
||||
"failed to acquire file lock".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
log::debug!("File lock acquired on {:?}", self.ops_file);
|
||||
log::debug!("File lock acquired on {}", self.ops_file.display());
|
||||
|
||||
// Return a guard that releases the lock on drop
|
||||
Ok(FileLock { file })
|
||||
|
|
@ -435,7 +437,7 @@ impl IpcCoordinator {
|
|||
}
|
||||
|
||||
impl OperationType {
|
||||
pub const fn as_str(&self) -> &'static str {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Fetch => "fetch",
|
||||
Self::Export => "export",
|
||||
|
|
|
|||
39
src/main.rs
39
src/main.rs
|
|
@ -1,8 +1,11 @@
|
|||
// 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)]
|
||||
#![expect(
|
||||
clippy::multiple_crate_versions,
|
||||
reason = "transitive dependency version conflicts from upstream crates"
|
||||
)]
|
||||
#![expect(
|
||||
clippy::cargo_common_metadata,
|
||||
reason = "license and repository not yet configured"
|
||||
)]
|
||||
|
||||
mod cli;
|
||||
mod error;
|
||||
|
|
@ -78,7 +81,6 @@ async fn main() -> Result<(), PakkerError> {
|
|||
&lockfile_path,
|
||||
&config_path,
|
||||
)
|
||||
.await
|
||||
},
|
||||
Commands::Import(args) => {
|
||||
cli::commands::import::execute(
|
||||
|
|
@ -118,8 +120,12 @@ async fn main() -> Result<(), PakkerError> {
|
|||
.await
|
||||
},
|
||||
Commands::Rm(args) => {
|
||||
cli::commands::rm::execute(args, global_yes, &lockfile_path, &config_path)
|
||||
.await
|
||||
cli::commands::rm::execute(
|
||||
&args,
|
||||
global_yes,
|
||||
&lockfile_path,
|
||||
&config_path,
|
||||
)
|
||||
},
|
||||
Commands::Update(args) => {
|
||||
cli::commands::update::execute(
|
||||
|
|
@ -130,15 +136,15 @@ async fn main() -> Result<(), PakkerError> {
|
|||
)
|
||||
.await
|
||||
},
|
||||
Commands::Ls(args) => cli::commands::ls::execute(args, &lockfile_path),
|
||||
Commands::Ls(args) => cli::commands::ls::execute(&args, &lockfile_path),
|
||||
Commands::Set(args) => {
|
||||
cli::commands::set::execute(args, &lockfile_path, &config_path).await
|
||||
cli::commands::set::execute(&args, &lockfile_path, &config_path)
|
||||
},
|
||||
Commands::Link(args) => cli::commands::link::execute(args, &lockfile_path),
|
||||
Commands::Link(args) => cli::commands::link::execute(&args, &lockfile_path),
|
||||
Commands::Unlink(args) => {
|
||||
cli::commands::unlink::execute(args, &lockfile_path)
|
||||
cli::commands::unlink::execute(&args, &lockfile_path)
|
||||
},
|
||||
Commands::Diff(args) => cli::commands::diff::execute(args, &lockfile_path),
|
||||
Commands::Diff(args) => cli::commands::diff::execute(&args, &lockfile_path),
|
||||
Commands::Fetch(args) => {
|
||||
cli::commands::fetch::execute(args, &lockfile_path, &config_path).await
|
||||
},
|
||||
|
|
@ -156,7 +162,7 @@ async fn main() -> Result<(), PakkerError> {
|
|||
},
|
||||
Commands::Remote(args) => cli::commands::remote::execute(args).await,
|
||||
Commands::RemoteUpdate(args) => {
|
||||
cli::commands::remote_update::execute(args).await
|
||||
cli::commands::remote_update::execute(&args)
|
||||
},
|
||||
Commands::Status(args) => {
|
||||
cli::commands::status::execute(
|
||||
|
|
@ -169,11 +175,10 @@ async fn main() -> Result<(), PakkerError> {
|
|||
},
|
||||
Commands::Inspect(args) => {
|
||||
cli::commands::inspect::execute(
|
||||
args.projects,
|
||||
&args.projects,
|
||||
&lockfile_path,
|
||||
&config_path,
|
||||
)
|
||||
.await
|
||||
},
|
||||
Commands::Credentials(args) => {
|
||||
match args.subcommand {
|
||||
|
|
@ -199,7 +204,7 @@ async fn main() -> Result<(), PakkerError> {
|
|||
cli::commands::cfg_prj::execute(
|
||||
&config_path,
|
||||
&lockfile_path,
|
||||
prj_args.project,
|
||||
&prj_args.project,
|
||||
prj_args.r#type.as_deref(),
|
||||
prj_args.side.as_deref(),
|
||||
prj_args.update_strategy.as_deref(),
|
||||
|
|
|
|||
|
|
@ -119,18 +119,17 @@ impl Config {
|
|||
Ok(config)
|
||||
},
|
||||
Ok(ConfigWrapper::Pakku { pakku }) => {
|
||||
let name = pakku
|
||||
.parent
|
||||
.as_ref()
|
||||
.map(|p| {
|
||||
let name = pakku.parent.as_ref().map_or_else(
|
||||
|| "unknown".to_string(),
|
||||
|p| {
|
||||
p.id
|
||||
.split('/')
|
||||
.next_back()
|
||||
.unwrap_or(&p.id)
|
||||
.trim_end_matches(".git")
|
||||
.to_string()
|
||||
})
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
},
|
||||
);
|
||||
|
||||
let version = pakku
|
||||
.parent
|
||||
|
|
|
|||
|
|
@ -155,11 +155,11 @@ pub struct ResolvedCredentials {
|
|||
}
|
||||
|
||||
impl ResolvedCredentials {
|
||||
pub fn load() -> Result<Self> {
|
||||
pub fn load() -> Self {
|
||||
let pakker_file = PakkerCredentialsFile::load().ok();
|
||||
let pakku_file = PakkerCompatCredentialsFile::load().ok();
|
||||
|
||||
Ok(Self {
|
||||
Self {
|
||||
curseforge_api_key: resolve_secret(
|
||||
"PAKKER_CURSEFORGE_API_KEY",
|
||||
"curseforge_api_key",
|
||||
|
|
@ -169,13 +169,13 @@ impl ResolvedCredentials {
|
|||
pakku_file
|
||||
.as_ref()
|
||||
.and_then(|f| f.curseforge_api_key.clone()),
|
||||
)?,
|
||||
),
|
||||
modrinth_token: resolve_secret(
|
||||
"PAKKER_MODRINTH_TOKEN",
|
||||
"modrinth_token",
|
||||
pakker_file.as_ref().and_then(|f| f.modrinth_token.clone()),
|
||||
None,
|
||||
)?,
|
||||
),
|
||||
github_access_token: resolve_secret(
|
||||
"PAKKER_GITHUB_TOKEN",
|
||||
"github_access_token",
|
||||
|
|
@ -185,8 +185,8 @@ impl ResolvedCredentials {
|
|||
pakku_file
|
||||
.as_ref()
|
||||
.and_then(|f| f.github_access_token.clone()),
|
||||
)?,
|
||||
})
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn curseforge_api_key(&self) -> Option<&str> {
|
||||
|
|
@ -226,28 +226,26 @@ fn resolve_secret(
|
|||
keyring_entry: &str,
|
||||
pakker_file_value: Option<String>,
|
||||
pakku_file_value: Option<String>,
|
||||
) -> Result<Option<(String, CredentialsSource)>> {
|
||||
) -> Option<(String, CredentialsSource)> {
|
||||
if let Ok(v) = std::env::var(env_key)
|
||||
&& !v.trim().is_empty()
|
||||
{
|
||||
return Ok(Some((v.trim().to_string(), CredentialsSource::Env)));
|
||||
return Some((v.trim().to_string(), CredentialsSource::Env));
|
||||
}
|
||||
|
||||
if let Ok(v) = get_keyring_secret(keyring_entry)
|
||||
&& !v.trim().is_empty()
|
||||
{
|
||||
return Ok(Some((v.trim().to_string(), CredentialsSource::Keyring)));
|
||||
return Some((v.trim().to_string(), CredentialsSource::Keyring));
|
||||
}
|
||||
|
||||
if let Some(v) = pakker_file_value.filter(|v| !v.trim().is_empty()) {
|
||||
return Ok(Some((v, CredentialsSource::PakkerFile)));
|
||||
return Some((v, CredentialsSource::PakkerFile));
|
||||
}
|
||||
|
||||
Ok(
|
||||
pakku_file_value
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.map(|v| (v, CredentialsSource::PakkerFile)),
|
||||
)
|
||||
pakku_file_value
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.map(|v| (v, CredentialsSource::PakkerFile))
|
||||
}
|
||||
|
||||
fn get_keyring_secret(
|
||||
|
|
@ -279,8 +277,7 @@ fn delete_keyring_secret(entry: &str) -> Result<()> {
|
|||
})?;
|
||||
|
||||
match e.delete_credential() {
|
||||
Ok(()) => Ok(()),
|
||||
Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(e) => {
|
||||
Err(PakkerError::InternalError(format!(
|
||||
"Failed to delete keyring entry {entry}: {e}"
|
||||
|
|
|
|||
|
|
@ -79,6 +79,8 @@ impl std::fmt::Display for ProjectSide {
|
|||
pub enum UpdateStrategy {
|
||||
#[serde(rename = "LATEST")]
|
||||
Latest,
|
||||
#[serde(rename = "FLEXVER")]
|
||||
FlexVer,
|
||||
#[serde(rename = "NONE")]
|
||||
None,
|
||||
}
|
||||
|
|
@ -88,6 +90,7 @@ impl FromStr for UpdateStrategy {
|
|||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_uppercase().as_str() {
|
||||
"LATEST" => Ok(Self::Latest),
|
||||
"FLEXVER" => Ok(Self::FlexVer),
|
||||
"NONE" => Ok(Self::None),
|
||||
_ => Err(format!("Invalid update strategy: {s}")),
|
||||
}
|
||||
|
|
@ -98,6 +101,7 @@ impl std::fmt::Display for UpdateStrategy {
|
|||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Latest => write!(f, "LATEST"),
|
||||
Self::FlexVer => write!(f, "FLEXVER"),
|
||||
Self::None => write!(f, "NONE"),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ impl ForkIntegrity {
|
|||
pub fn hash_content(content: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(content.as_bytes());
|
||||
format!("{:x}", hasher.finalize())
|
||||
crate::utils::hash::hash_to_hex(hasher.finalize().as_slice())
|
||||
}
|
||||
|
||||
/// Reference type for Git operations
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ use std::collections::{HashMap, HashSet};
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::enums::{ProjectSide, ProjectType, ReleaseType, UpdateStrategy};
|
||||
use crate::error::{PakkerError, Result};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Project {
|
||||
|
|
@ -55,14 +56,26 @@ const fn default_redistributable() -> bool {
|
|||
true
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::trivially_copy_pass_by_ref,
|
||||
reason = "required by serde skip_serializing_if which expects fn(&T) -> bool"
|
||||
)]
|
||||
const fn is_default_update_strategy(strategy: &UpdateStrategy) -> bool {
|
||||
matches!(strategy, UpdateStrategy::Latest)
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::trivially_copy_pass_by_ref,
|
||||
reason = "required by serde skip_serializing_if which expects fn(&T) -> bool"
|
||||
)]
|
||||
const fn is_default_redistributable(redistributable: &bool) -> bool {
|
||||
*redistributable
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::trivially_copy_pass_by_ref,
|
||||
reason = "required by serde skip_serializing_if which expects fn(&T) -> bool"
|
||||
)]
|
||||
const fn is_default_export(export: &bool) -> bool {
|
||||
*export
|
||||
}
|
||||
|
|
@ -95,8 +108,8 @@ impl Project {
|
|||
.name
|
||||
.values()
|
||||
.next()
|
||||
.map(|s| s.to_owned())
|
||||
.or_else(|| self.pakku_id.as_ref().map(|s| s.to_owned()))
|
||||
.map(std::borrow::ToOwned::to_owned)
|
||||
.or_else(|| self.pakku_id.as_ref().map(std::borrow::ToOwned::to_owned))
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
}
|
||||
|
||||
|
|
@ -169,6 +182,80 @@ impl Project {
|
|||
self.aliases.extend(other.aliases);
|
||||
}
|
||||
|
||||
/// Merge this project with another, returning a new combined project.
|
||||
/// Like Pakku's `Project.plus()`, this is a pure operation that doesn't
|
||||
/// modify either project.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns `PakkerError::InvalidProject` if the projects have different types
|
||||
/// or conflicting `pakku_links`.
|
||||
pub fn merged(&self, other: Self) -> Result<Self> {
|
||||
if self.r#type != other.r#type {
|
||||
return Err(PakkerError::InvalidProject(format!(
|
||||
"Cannot merge projects of different types: {:?} vs {:?}",
|
||||
self.r#type, other.r#type
|
||||
)));
|
||||
}
|
||||
|
||||
if !other.pakku_links.is_empty() && self.pakku_links != other.pakku_links {
|
||||
return Err(PakkerError::InvalidProject(
|
||||
"Cannot merge projects with conflicting pakku_links".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Prefer non-default side
|
||||
let side = if self.side == ProjectSide::Both {
|
||||
other.side
|
||||
} else {
|
||||
self.side
|
||||
};
|
||||
|
||||
let mut id = self.id.clone();
|
||||
for (platform, other_id) in other.id {
|
||||
id.entry(platform).or_insert(other_id);
|
||||
}
|
||||
|
||||
let mut slug = self.slug.clone();
|
||||
for (platform, other_slug) in other.slug {
|
||||
slug.entry(platform).or_insert(other_slug);
|
||||
}
|
||||
|
||||
let mut name = self.name.clone();
|
||||
for (platform, other_name) in other.name {
|
||||
name.entry(platform).or_insert(other_name);
|
||||
}
|
||||
|
||||
let mut files = self.files.clone();
|
||||
for file in other.files {
|
||||
if !files.iter().any(|f| f.id == file.id) {
|
||||
files.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
let mut aliases = self.aliases.clone();
|
||||
aliases.extend(other.aliases);
|
||||
|
||||
Ok(Self {
|
||||
pakku_id: self.pakku_id.clone(),
|
||||
pakku_links: self.pakku_links.clone(),
|
||||
r#type: self.r#type,
|
||||
side,
|
||||
slug,
|
||||
name,
|
||||
id,
|
||||
update_strategy: self.update_strategy,
|
||||
redistributable: self.redistributable && other.redistributable,
|
||||
subpath: self.subpath.clone().or_else(|| other.subpath.clone()),
|
||||
aliases,
|
||||
export: if self.export {
|
||||
self.export
|
||||
} else {
|
||||
other.export
|
||||
},
|
||||
files,
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if versions match across all providers.
|
||||
/// Returns true if all provider files have the same version/file,
|
||||
/// or if there's only one provider.
|
||||
|
|
@ -263,7 +350,7 @@ impl Project {
|
|||
}
|
||||
|
||||
// Sort by release type (Release < Beta < Alpha) and date (newest first)
|
||||
let mut sorted_files = compatible_files.to_vec();
|
||||
let mut sorted_files = compatible_files.clone();
|
||||
sorted_files.sort_by(|a, b| {
|
||||
a.release_type
|
||||
.cmp(&b.release_type)
|
||||
|
|
@ -760,4 +847,112 @@ mod tests {
|
|||
let url = file.get_site_url(&project);
|
||||
assert!(url.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merged_different_types_returns_error() {
|
||||
let mut p1 =
|
||||
Project::new("id1".to_string(), ProjectType::Mod, ProjectSide::Both);
|
||||
p1.name.insert("modrinth".to_string(), "Mod1".to_string());
|
||||
|
||||
let mut p2 = Project::new(
|
||||
"id2".to_string(),
|
||||
ProjectType::ResourcePack,
|
||||
ProjectSide::Both,
|
||||
);
|
||||
p2.name.insert("modrinth".to_string(), "RP1".to_string());
|
||||
|
||||
assert!(p1.merged(p2).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merged_combines_ids_and_slugs() {
|
||||
let mut p1 =
|
||||
Project::new("id1".to_string(), ProjectType::Mod, ProjectSide::Both);
|
||||
p1.add_platform(
|
||||
"modrinth".to_string(),
|
||||
"mr1".to_string(),
|
||||
"mod1".to_string(),
|
||||
"Mod 1".to_string(),
|
||||
);
|
||||
|
||||
let mut p2 =
|
||||
Project::new("id2".to_string(), ProjectType::Mod, ProjectSide::Both);
|
||||
p2.add_platform(
|
||||
"curseforge".to_string(),
|
||||
"cf1".to_string(),
|
||||
"mod1".to_string(),
|
||||
"Mod 1".to_string(),
|
||||
);
|
||||
|
||||
let merged = p1.merged(p2).unwrap();
|
||||
assert_eq!(merged.id.get("modrinth"), Some(&"mr1".to_string()));
|
||||
assert_eq!(merged.id.get("curseforge"), Some(&"cf1".to_string()));
|
||||
assert_eq!(merged.slug.get("modrinth"), Some(&"mod1".to_string()));
|
||||
assert_eq!(merged.slug.get("curseforge"), Some(&"mod1".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merged_prefers_non_both_side() {
|
||||
let p1 =
|
||||
Project::new("id1".to_string(), ProjectType::Mod, ProjectSide::Client);
|
||||
let p2 =
|
||||
Project::new("id2".to_string(), ProjectType::Mod, ProjectSide::Both);
|
||||
|
||||
let merged = p1.merged(p2.clone()).unwrap();
|
||||
assert_eq!(merged.side, ProjectSide::Client);
|
||||
|
||||
let merged2 = p2.merged(p1).unwrap();
|
||||
assert_eq!(merged2.side, ProjectSide::Client);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merged_preserves_pakku_id() {
|
||||
let p1 =
|
||||
Project::new("id1".to_string(), ProjectType::Mod, ProjectSide::Both);
|
||||
let p2 =
|
||||
Project::new("id2".to_string(), ProjectType::Mod, ProjectSide::Both);
|
||||
|
||||
let merged = p1.merged(p2).unwrap();
|
||||
assert_eq!(merged.pakku_id, Some("id1".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merged_deduplicates_files() {
|
||||
let mut p1 =
|
||||
Project::new("id1".to_string(), ProjectType::Mod, ProjectSide::Both);
|
||||
p1.files.push(ProjectFile {
|
||||
file_type: "modrinth".to_string(),
|
||||
file_name: "mod-1.0.0.jar".to_string(),
|
||||
mc_versions: vec!["1.20.1".to_string()],
|
||||
loaders: vec!["fabric".to_string()],
|
||||
release_type: ReleaseType::Release,
|
||||
url: "https://example.com/mod.jar".to_string(),
|
||||
id: "file1".to_string(),
|
||||
parent_id: "mod123".to_string(),
|
||||
hashes: HashMap::new(),
|
||||
required_dependencies: vec![],
|
||||
size: 1024,
|
||||
date_published: "2024-01-01T00:00:00Z".to_string(),
|
||||
});
|
||||
|
||||
let mut p2 =
|
||||
Project::new("id2".to_string(), ProjectType::Mod, ProjectSide::Both);
|
||||
p2.files.push(ProjectFile {
|
||||
file_type: "modrinth".to_string(),
|
||||
file_name: "mod-1.0.0.jar".to_string(),
|
||||
mc_versions: vec!["1.20.1".to_string()],
|
||||
loaders: vec!["fabric".to_string()],
|
||||
release_type: ReleaseType::Release,
|
||||
url: "https://example.com/mod.jar".to_string(),
|
||||
id: "file1".to_string(),
|
||||
parent_id: "mod123".to_string(),
|
||||
hashes: HashMap::new(),
|
||||
required_dependencies: vec![],
|
||||
size: 1024,
|
||||
date_published: "2024-01-01T00:00:00Z".to_string(),
|
||||
});
|
||||
|
||||
let merged = p1.merged(p2).unwrap();
|
||||
assert_eq!(merged.files.len(), 1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
mod curseforge;
|
||||
mod github;
|
||||
mod modrinth;
|
||||
mod multiplatform;
|
||||
mod traits;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
|
@ -8,6 +9,7 @@ use std::sync::Arc;
|
|||
pub use curseforge::CurseForgePlatform;
|
||||
pub use github::GitHubPlatform;
|
||||
pub use modrinth::ModrinthPlatform;
|
||||
pub use multiplatform::MultiplatformPlatform;
|
||||
pub use traits::PlatformClient;
|
||||
|
||||
use crate::{error::Result, http, rate_limiter::RateLimiter};
|
||||
|
|
@ -51,10 +53,18 @@ fn create_client(
|
|||
},
|
||||
"github" => {
|
||||
Ok(Box::new(GitHubPlatform::with_client(
|
||||
get_http_client(),
|
||||
&get_http_client(),
|
||||
api_key,
|
||||
)))
|
||||
},
|
||||
"multiplatform" => {
|
||||
let cf = CurseForgePlatform::with_client(get_http_client(), api_key);
|
||||
let mr = ModrinthPlatform::with_client(get_http_client());
|
||||
Ok(Box::new(MultiplatformPlatform::new(
|
||||
Arc::new(cf),
|
||||
Arc::new(mr),
|
||||
)))
|
||||
},
|
||||
_ => {
|
||||
Err(crate::error::PakkerError::ConfigError(format!(
|
||||
"Unknown platform: {platform}"
|
||||
|
|
@ -117,4 +127,24 @@ impl PlatformClient for RateLimitedPlatform {
|
|||
self.rate_limiter.wait_for(&self.platform_name).await;
|
||||
self.platform.lookup_by_hash(hash).await
|
||||
}
|
||||
|
||||
async fn request_project_from_slug(
|
||||
&self,
|
||||
slug: &str,
|
||||
) -> Result<Option<crate::model::Project>> {
|
||||
self.rate_limiter.wait_for(&self.platform_name).await;
|
||||
self.platform.request_project_from_slug(slug).await
|
||||
}
|
||||
|
||||
async fn request_projects_from_hashes(
|
||||
&self,
|
||||
hashes: &[String],
|
||||
algorithm: &str,
|
||||
) -> Result<Vec<crate::model::Project>> {
|
||||
self.rate_limiter.wait_for(&self.platform_name).await;
|
||||
self
|
||||
.platform
|
||||
.request_projects_from_hashes(hashes, algorithm)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,10 +12,10 @@ use crate::{
|
|||
};
|
||||
|
||||
const CURSEFORGE_API_BASE: &str = "https://api.curseforge.com/v1";
|
||||
/// CurseForge game version type ID for loader versions (e.g., "fabric",
|
||||
/// `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
|
||||
/// `CurseForge` relation type ID for "required dependency" (mod embeds or
|
||||
/// requires another mod)
|
||||
const DEPENDENCY_RELATION_TYPE_REQUIRED: u32 = 3;
|
||||
|
||||
|
|
@ -32,7 +32,10 @@ impl CurseForgePlatform {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn with_client(client: Arc<Client>, api_key: Option<String>) -> Self {
|
||||
pub const fn with_client(
|
||||
client: Arc<Client>,
|
||||
api_key: Option<String>,
|
||||
) -> Self {
|
||||
Self { client, api_key }
|
||||
}
|
||||
|
||||
|
|
@ -57,7 +60,6 @@ impl CurseForgePlatform {
|
|||
|
||||
const fn map_class_id(class_id: u32) -> ProjectType {
|
||||
match class_id {
|
||||
6 => ProjectType::Mod,
|
||||
12 => ProjectType::ResourcePack,
|
||||
6945 => ProjectType::DataPack,
|
||||
6552 => ProjectType::Shader,
|
||||
|
|
@ -68,7 +70,6 @@ impl CurseForgePlatform {
|
|||
|
||||
const fn map_release_type(release_type: u32) -> ReleaseType {
|
||||
match release_type {
|
||||
1 => ReleaseType::Release,
|
||||
2 => ReleaseType::Beta,
|
||||
3 => ReleaseType::Alpha,
|
||||
_ => ReleaseType::Release,
|
||||
|
|
@ -142,7 +143,7 @@ impl CurseForgePlatform {
|
|||
}
|
||||
}
|
||||
|
||||
fn convert_project(&self, cf_project: CurseForgeProject) -> Project {
|
||||
fn convert_project(cf_project: CurseForgeProject) -> Project {
|
||||
let pakku_id = generate_pakku_id();
|
||||
let project_type = Self::map_class_id(cf_project.class_id.unwrap_or(6));
|
||||
|
||||
|
|
@ -162,11 +163,7 @@ impl CurseForgePlatform {
|
|||
project
|
||||
}
|
||||
|
||||
fn convert_file(
|
||||
&self,
|
||||
cf_file: CurseForgeFile,
|
||||
project_id: &str,
|
||||
) -> ProjectFile {
|
||||
fn convert_file(cf_file: CurseForgeFile, project_id: &str) -> ProjectFile {
|
||||
let mut hashes = HashMap::new();
|
||||
|
||||
for hash in cf_file.hashes {
|
||||
|
|
@ -259,12 +256,12 @@ impl PlatformClient for CurseForgePlatform {
|
|||
|
||||
if response.status().is_success() {
|
||||
let result: CurseForgeProjectResponse = response.json().await?;
|
||||
return Ok(self.convert_project(result.data));
|
||||
return Ok(Self::convert_project(result.data));
|
||||
}
|
||||
}
|
||||
|
||||
let cf_project = self.search_project_by_slug(identifier).await?;
|
||||
Ok(self.convert_project(cf_project))
|
||||
Ok(Self::convert_project(cf_project))
|
||||
}
|
||||
|
||||
async fn request_project_files(
|
||||
|
|
@ -319,7 +316,7 @@ impl PlatformClient for CurseForgePlatform {
|
|||
let files: Vec<ProjectFile> = result
|
||||
.data
|
||||
.into_iter()
|
||||
.map(|f| self.convert_file(f, project_id))
|
||||
.map(|f| Self::convert_file(f, project_id))
|
||||
.collect();
|
||||
|
||||
Ok(files)
|
||||
|
|
@ -391,6 +388,92 @@ impl PlatformClient for CurseForgePlatform {
|
|||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn request_project_from_slug(
|
||||
&self,
|
||||
slug: &str,
|
||||
) -> Result<Option<Project>> {
|
||||
// Try to fetch project by slug using search API
|
||||
match self.search_project_by_slug(slug).await {
|
||||
Ok(cf_project) => Ok(Some(Self::convert_project(cf_project))),
|
||||
Err(PakkerError::ProjectNotFound(_)) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Uses `CurseForge`'s `/fingerprints/432` endpoint to resolve projects by
|
||||
/// their hashes in batch.
|
||||
async fn request_projects_from_hashes(
|
||||
&self,
|
||||
hashes: &[String],
|
||||
_algorithm: &str,
|
||||
) -> Result<Vec<Project>> {
|
||||
#[derive(Serialize)]
|
||||
struct FingerprintRequest {
|
||||
fingerprints: Vec<u32>,
|
||||
}
|
||||
|
||||
if hashes.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let fingerprints: Vec<u32> = hashes
|
||||
.iter()
|
||||
.filter_map(|h| h.parse::<u32>().ok())
|
||||
.collect();
|
||||
|
||||
if fingerprints.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let url = format!("{CURSEFORGE_API_BASE}/fingerprints/432");
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.headers(self.get_headers()?)
|
||||
.json(&FingerprintRequest {
|
||||
fingerprints: fingerprints.clone(),
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(PakkerError::PlatformApiError(format!(
|
||||
"CurseForge batch API error: {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let response_data: serde_json::Value = response.json().await?;
|
||||
|
||||
let matches = response_data["data"]["exactMatches"]
|
||||
.as_array()
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut projects = Vec::new();
|
||||
let mut seen_ids = std::collections::HashSet::new();
|
||||
|
||||
for m in matches {
|
||||
if let Some(file) = m["file"].as_object()
|
||||
&& let Some(mod_id) = file["modId"].as_u64()
|
||||
{
|
||||
let mod_id_str = mod_id.to_string();
|
||||
if seen_ids.contains(&mod_id_str) {
|
||||
continue;
|
||||
}
|
||||
seen_ids.insert(mod_id_str.clone());
|
||||
|
||||
if let Ok(project) =
|
||||
self.request_project_with_files(&mod_id_str, &[], &[]).await
|
||||
{
|
||||
projects.push(project);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(projects)
|
||||
}
|
||||
}
|
||||
|
||||
// CurseForge API models
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
use std::{collections::HashMap, sync::Arc};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Arc, LazyLock},
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use regex::Regex;
|
||||
|
|
@ -20,9 +23,9 @@ pub struct GitHubPlatform {
|
|||
}
|
||||
|
||||
impl GitHubPlatform {
|
||||
pub fn with_client(client: Arc<Client>, token: Option<String>) -> Self {
|
||||
pub fn with_client(client: &Arc<Client>, token: Option<String>) -> Self {
|
||||
Self {
|
||||
client: (*client).clone(),
|
||||
client: (**client).clone(),
|
||||
token,
|
||||
}
|
||||
}
|
||||
|
|
@ -70,7 +73,6 @@ impl GitHubPlatform {
|
|||
}
|
||||
|
||||
fn convert_release(
|
||||
&self,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
release: GitHubRelease,
|
||||
|
|
@ -91,9 +93,15 @@ impl GitHubPlatform {
|
|||
}
|
||||
}
|
||||
|
||||
#[expect(clippy::expect_used, reason = "regex literal is always valid")]
|
||||
static MC_VERSION_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?:^|[^\d.])(\d+\.\d+(?:\.\d+)?)(?:[^\d]|$)")
|
||||
.expect("MC_VERSION_RE pattern is valid")
|
||||
});
|
||||
|
||||
// Helper functions for extracting metadata from GitHub releases
|
||||
fn extract_mc_versions(tag: &str, asset_name: &str) -> Vec<String> {
|
||||
let re = Regex::new(r"(?:^|[^\d.])(\d+\.\d+(?:\.\d+)?)(?:[^\d]|$)").unwrap();
|
||||
let re = &*MC_VERSION_RE;
|
||||
let mut versions = Vec::new();
|
||||
|
||||
log::debug!("Extracting MC versions from tag='{tag}', asset='{asset_name}'");
|
||||
|
|
@ -182,8 +190,7 @@ fn detect_project_type(asset_name: &str, repo_name: &str) -> ProjectType {
|
|||
|
||||
impl GitHubPlatform {
|
||||
fn convert_asset(
|
||||
&self,
|
||||
asset: GitHubAsset,
|
||||
asset: &GitHubAsset,
|
||||
release: &GitHubRelease,
|
||||
repo_id: &str,
|
||||
repo_name: &str,
|
||||
|
|
@ -278,7 +285,7 @@ impl PlatformClient for GitHubPlatform {
|
|||
) -> Result<Project> {
|
||||
let (owner, repo) = Self::parse_repo_identifier(identifier)?;
|
||||
let release = self.get_latest_release(&owner, &repo).await?;
|
||||
Ok(self.convert_release(&owner, &repo, release))
|
||||
Ok(Self::convert_release(&owner, &repo, release))
|
||||
}
|
||||
|
||||
async fn request_project_files(
|
||||
|
|
@ -295,9 +302,14 @@ impl PlatformClient for GitHubPlatform {
|
|||
for release in releases {
|
||||
for asset in &release.assets {
|
||||
// Filter for .jar files (mods) or .zip files (modpacks)
|
||||
if asset.name.ends_with(".jar") || asset.name.ends_with(".zip") {
|
||||
let file =
|
||||
self.convert_asset(asset.clone(), &release, project_id, &repo);
|
||||
if std::path::Path::new(&asset.name)
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("jar"))
|
||||
|| std::path::Path::new(&asset.name)
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("zip"))
|
||||
{
|
||||
let file = Self::convert_asset(asset, &release, project_id, &repo);
|
||||
files.push(file);
|
||||
}
|
||||
}
|
||||
|
|
@ -403,6 +415,31 @@ impl PlatformClient for GitHubPlatform {
|
|||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
async fn request_project_from_slug(
|
||||
&self,
|
||||
slug: &str,
|
||||
) -> Result<Option<Project>> {
|
||||
match self.request_project(slug, &[], &[]).await {
|
||||
Ok(project) => Ok(Some(project)),
|
||||
Err(PakkerError::ProjectNotFound(_)) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// GitHub does not support hash-based batch lookup. Returns an empty list.
|
||||
async fn request_projects_from_hashes(
|
||||
&self,
|
||||
hashes: &[String],
|
||||
algorithm: &str,
|
||||
) -> Result<Vec<Project>> {
|
||||
log::debug!(
|
||||
"GitHub does not support batch hash lookup ({} hashes, algorithm={})",
|
||||
hashes.len(),
|
||||
algorithm
|
||||
);
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
// GitHub API models
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ impl ModrinthPlatform {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn with_client(client: Arc<Client>) -> Self {
|
||||
pub const fn with_client(client: Arc<Client>) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
|
||||
|
|
@ -34,7 +34,7 @@ impl ModrinthPlatform {
|
|||
return Err(PakkerError::ProjectNotFound(url.to_string()));
|
||||
}
|
||||
let mr_project: ModrinthProject = response.json().await?;
|
||||
Ok(self.convert_project(mr_project))
|
||||
Ok(Self::convert_project(mr_project))
|
||||
}
|
||||
|
||||
async fn request_project_files_url(
|
||||
|
|
@ -57,8 +57,8 @@ impl ModrinthPlatform {
|
|||
.to_string();
|
||||
Ok(
|
||||
mr_versions
|
||||
.into_iter()
|
||||
.map(|v| self.convert_version(v, &project_id))
|
||||
.iter()
|
||||
.map(|v| Self::convert_version(v, &project_id))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
|
@ -86,7 +86,6 @@ impl ModrinthPlatform {
|
|||
|
||||
fn map_project_type(type_str: &str) -> ProjectType {
|
||||
match type_str {
|
||||
"mod" => ProjectType::Mod,
|
||||
"resourcepack" => ProjectType::ResourcePack,
|
||||
"datapack" => ProjectType::DataPack,
|
||||
"shader" => ProjectType::Shader,
|
||||
|
|
@ -96,7 +95,6 @@ impl ModrinthPlatform {
|
|||
|
||||
const fn map_side(client: bool, server: bool) -> ProjectSide {
|
||||
match (client, server) {
|
||||
(true, true) => ProjectSide::Both,
|
||||
(true, false) => ProjectSide::Client,
|
||||
(false, true) => ProjectSide::Server,
|
||||
_ => ProjectSide::Both,
|
||||
|
|
@ -105,14 +103,13 @@ impl ModrinthPlatform {
|
|||
|
||||
fn map_release_type(type_str: &str) -> ReleaseType {
|
||||
match type_str {
|
||||
"release" => ReleaseType::Release,
|
||||
"beta" => ReleaseType::Beta,
|
||||
"alpha" => ReleaseType::Alpha,
|
||||
_ => ReleaseType::Release,
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_project(&self, mr_project: ModrinthProject) -> Project {
|
||||
fn convert_project(mr_project: ModrinthProject) -> Project {
|
||||
let pakku_id = generate_pakku_id();
|
||||
let mut project = Project::new(
|
||||
pakku_id,
|
||||
|
|
@ -133,9 +130,12 @@ impl ModrinthPlatform {
|
|||
project
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::expect_used,
|
||||
reason = "Modrinth API guarantees every version has at least one file"
|
||||
)]
|
||||
fn convert_version(
|
||||
&self,
|
||||
mr_version: ModrinthVersion,
|
||||
mr_version: &ModrinthVersion,
|
||||
project_id: &str,
|
||||
) -> ProjectFile {
|
||||
let mut hashes = HashMap::new();
|
||||
|
|
@ -254,6 +254,88 @@ impl PlatformClient for ModrinthPlatform {
|
|||
let url = format!("{MODRINTH_API_BASE}/version_file/{hash}");
|
||||
self.lookup_by_hash_url(&url).await
|
||||
}
|
||||
|
||||
async fn request_project_from_slug(
|
||||
&self,
|
||||
slug: &str,
|
||||
) -> Result<Option<Project>> {
|
||||
let url = format!("{MODRINTH_API_BASE}/project/{slug}");
|
||||
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 mr_project: ModrinthProject = response.json().await?;
|
||||
Ok(Some(Self::convert_project(mr_project)))
|
||||
}
|
||||
|
||||
/// Uses Modrinth's `/v2/version_files` endpoint to resolve projects by
|
||||
/// their hashes in batch.
|
||||
async fn request_projects_from_hashes(
|
||||
&self,
|
||||
hashes: &[String],
|
||||
algorithm: &str,
|
||||
) -> Result<Vec<Project>> {
|
||||
#[derive(Serialize)]
|
||||
struct HashBatchRequest<'a> {
|
||||
hashes: &'a [String],
|
||||
algorithm: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HashBatchResponse {
|
||||
project_id: String,
|
||||
}
|
||||
|
||||
if hashes.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let url = format!("{MODRINTH_API_BASE}/version_files");
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.json(&HashBatchRequest { hashes, algorithm })
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(PakkerError::PlatformApiError(format!(
|
||||
"Modrinth batch API error: {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let versions_map: std::collections::HashMap<String, HashBatchResponse> =
|
||||
response.json().await?;
|
||||
|
||||
let mut projects = Vec::new();
|
||||
let mut seen_project_ids = std::collections::HashSet::new();
|
||||
|
||||
for version in versions_map.values() {
|
||||
if seen_project_ids.contains(&version.project_id) {
|
||||
continue;
|
||||
}
|
||||
seen_project_ids.insert(version.project_id.clone());
|
||||
|
||||
if let Ok(project) = self
|
||||
.request_project_with_files(&version.project_id, &[], &[])
|
||||
.await
|
||||
{
|
||||
projects.push(project);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(projects)
|
||||
}
|
||||
}
|
||||
|
||||
// Modrinth API models
|
||||
|
|
|
|||
234
src/platform/multiplatform.rs
Normal file
234
src/platform/multiplatform.rs
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use super::traits::PlatformClient;
|
||||
use crate::{
|
||||
error::{PakkerError, Result},
|
||||
model::{Project, ProjectFile},
|
||||
};
|
||||
|
||||
/// Multiplatform platform client that aggregates `CurseForge` and Modrinth.
|
||||
/// It attempts to resolve projects on both platforms and cross-references
|
||||
/// them via slugs when a project exists on only one platform.
|
||||
pub struct MultiplatformPlatform {
|
||||
curseforge: Arc<dyn PlatformClient>,
|
||||
modrinth: Arc<dyn PlatformClient>,
|
||||
}
|
||||
|
||||
impl MultiplatformPlatform {
|
||||
pub fn new(
|
||||
curseforge: Arc<dyn PlatformClient>,
|
||||
modrinth: Arc<dyn PlatformClient>,
|
||||
) -> Self {
|
||||
Self {
|
||||
curseforge,
|
||||
modrinth,
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to fetch a project, returning Ok(None) for "not found" errors.
|
||||
async fn try_request_project(
|
||||
&self,
|
||||
client: &Arc<dyn PlatformClient>,
|
||||
identifier: &str,
|
||||
) -> Result<Option<Project>> {
|
||||
match client.request_project(identifier, &[], &[]).await {
|
||||
Ok(project) => Ok(Some(project)),
|
||||
Err(e) => {
|
||||
let is_not_found = matches!(
|
||||
e,
|
||||
PakkerError::ProjectNotFound(_) | PakkerError::InvalidResponse(_)
|
||||
);
|
||||
if is_not_found { Ok(None) } else { Err(e) }
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PlatformClient for MultiplatformPlatform {
|
||||
async fn request_project(
|
||||
&self,
|
||||
identifier: &str,
|
||||
_mc_versions: &[String],
|
||||
_loaders: &[String],
|
||||
) -> Result<Project> {
|
||||
// Try both platforms in parallel
|
||||
let cf_future = self.try_request_project(&self.curseforge, identifier);
|
||||
let mr_future = self.try_request_project(&self.modrinth, identifier);
|
||||
|
||||
let (cf_result, mr_result) = tokio::join!(cf_future, mr_future);
|
||||
|
||||
// Handle results - extract Options, propagate first error if both fail
|
||||
let (cf_project, mr_project) = match (cf_result, mr_result) {
|
||||
(Ok(Some(cf)), Ok(Some(mr))) => (Some(cf), Some(mr)),
|
||||
(Ok(None), Ok(None)) => (None, None),
|
||||
(Ok(None), Ok(Some(mr))) => (None, Some(mr)),
|
||||
(Ok(Some(cf)), Ok(None)) => (Some(cf), None),
|
||||
(Err(_e), Ok(None)) | (Ok(None), Err(_e)) => (None, None),
|
||||
(Err(e), Ok(Some(_))) | (Ok(Some(_)), Err(e)) => {
|
||||
return Err(e);
|
||||
},
|
||||
(Err(e), Err(_)) => return Err(e),
|
||||
};
|
||||
|
||||
// Cross-reference: if project exists on only one platform, fetch from the
|
||||
// other using the slug
|
||||
let mut cf_project = cf_project;
|
||||
let mut mr_project = mr_project;
|
||||
|
||||
let mr_found_and_cf_missing = mr_project.is_some() && cf_project.is_none();
|
||||
if mr_found_and_cf_missing
|
||||
&& let Some(ref mr) = mr_project
|
||||
&& let Some(cf_slug) = mr.slug.get("curseforge")
|
||||
&& let Ok(Some(cf)) =
|
||||
self.curseforge.request_project_from_slug(cf_slug).await
|
||||
{
|
||||
cf_project = Some(cf);
|
||||
}
|
||||
let cf_found_and_mr_missing = cf_project.is_some() && mr_project.is_none();
|
||||
if cf_found_and_mr_missing
|
||||
&& let Some(ref cf) = cf_project
|
||||
&& let Some(mr_slug) = cf.slug.get("modrinth")
|
||||
&& let Ok(Some(mr)) =
|
||||
self.modrinth.request_project_from_slug(mr_slug).await
|
||||
{
|
||||
mr_project = Some(mr);
|
||||
}
|
||||
|
||||
// Merge projects or return whichever was found
|
||||
let combined = match (cf_project, mr_project) {
|
||||
(Some(cf), Some(mr)) => cf.merged(mr)?,
|
||||
(Some(cf), None) => cf,
|
||||
(None, Some(mr)) => mr,
|
||||
(None, None) => {
|
||||
return Err(PakkerError::ProjectNotFound(identifier.to_string()));
|
||||
},
|
||||
};
|
||||
|
||||
Ok(combined)
|
||||
}
|
||||
|
||||
async fn request_project_files(
|
||||
&self,
|
||||
project_id: &str,
|
||||
mc_versions: &[String],
|
||||
loaders: &[String],
|
||||
) -> Result<Vec<ProjectFile>> {
|
||||
// Multiplatform doesn't directly support files - use
|
||||
// request_project_with_files
|
||||
let project = self
|
||||
.request_project_with_files(project_id, mc_versions, loaders)
|
||||
.await?;
|
||||
Ok(project.files)
|
||||
}
|
||||
|
||||
async fn request_project_with_files(
|
||||
&self,
|
||||
identifier: &str,
|
||||
mc_versions: &[String],
|
||||
loaders: &[String],
|
||||
) -> Result<Project> {
|
||||
// First get the combined project from both platforms
|
||||
let mut project = self
|
||||
.request_project(identifier, mc_versions, loaders)
|
||||
.await?;
|
||||
|
||||
// Now fetch files from both platforms in parallel
|
||||
let cf_project_id = project.id.get("curseforge").cloned();
|
||||
let mr_project_id = project.id.get("modrinth").cloned();
|
||||
|
||||
let cf_files_future = async {
|
||||
if let Some(ref id) = cf_project_id {
|
||||
self
|
||||
.curseforge
|
||||
.request_project_files(id, mc_versions, loaders)
|
||||
.await
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
};
|
||||
|
||||
let mr_files_future = async {
|
||||
if let Some(ref id) = mr_project_id {
|
||||
self
|
||||
.modrinth
|
||||
.request_project_files(id, mc_versions, loaders)
|
||||
.await
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
};
|
||||
|
||||
let (cf_files, mr_files) = tokio::join!(cf_files_future, mr_files_future);
|
||||
|
||||
let mut all_files = cf_files?;
|
||||
all_files.extend(mr_files?);
|
||||
|
||||
project.files = all_files;
|
||||
Ok(project)
|
||||
}
|
||||
|
||||
async fn lookup_by_hash(&self, hash: &str) -> Result<Option<Project>> {
|
||||
// Try both platforms in parallel
|
||||
let cf_future = self.curseforge.lookup_by_hash(hash);
|
||||
let mr_future = self.modrinth.lookup_by_hash(hash);
|
||||
|
||||
let (cf_result, mr_result) = tokio::join!(cf_future, mr_future);
|
||||
|
||||
match (cf_result?, mr_result?) {
|
||||
(Some(cf), Some(mr)) => cf.merged(mr).map(Some),
|
||||
(Some(project), None) | (None, Some(project)) => Ok(Some(project)),
|
||||
(None, None) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn request_project_from_slug(
|
||||
&self,
|
||||
slug: &str,
|
||||
) -> Result<Option<Project>> {
|
||||
let cf_future = self.curseforge.request_project_from_slug(slug);
|
||||
let mr_future = self.modrinth.request_project_from_slug(slug);
|
||||
|
||||
let (cf_result, mr_result) = tokio::join!(cf_future, mr_future);
|
||||
|
||||
match (cf_result, mr_result) {
|
||||
(Ok(Some(cf)), Ok(Some(mr))) => cf.merged(mr).map(Some),
|
||||
(Ok(Some(project)), Ok(None)) | (Ok(None), Ok(Some(project))) => {
|
||||
Ok(Some(project))
|
||||
},
|
||||
(Ok(None), Ok(None)) => Ok(None),
|
||||
(Err(e), _) | (_, Err(e)) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Delegates to both `CurseForge` and Modrinth in parallel, then deduplicates
|
||||
/// results.
|
||||
async fn request_projects_from_hashes(
|
||||
&self,
|
||||
hashes: &[String],
|
||||
algorithm: &str,
|
||||
) -> Result<Vec<Project>> {
|
||||
let cf_future = self
|
||||
.curseforge
|
||||
.request_projects_from_hashes(hashes, algorithm);
|
||||
let mr_future = self
|
||||
.modrinth
|
||||
.request_projects_from_hashes(hashes, algorithm);
|
||||
|
||||
let (cf_projects, mr_projects) = tokio::join!(cf_future, mr_future);
|
||||
|
||||
let mut all_projects = cf_projects?;
|
||||
for mr_project in mr_projects? {
|
||||
if !all_projects.iter().any(|p| {
|
||||
p.id.get("modrinth") == mr_project.id.get("modrinth")
|
||||
|| p.id.get("curseforge") == mr_project.id.get("curseforge")
|
||||
}) {
|
||||
all_projects.push(mr_project);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(all_projects)
|
||||
}
|
||||
}
|
||||
|
|
@ -29,4 +29,25 @@ pub trait PlatformClient: Send + Sync {
|
|||
) -> Result<Project>;
|
||||
|
||||
async fn lookup_by_hash(&self, hash: &str) -> Result<Option<Project>>;
|
||||
|
||||
/// Request a project using its platform-specific slug.
|
||||
/// This is used by Multiplatform to cross-reference projects between
|
||||
/// platforms.
|
||||
async fn request_project_from_slug(
|
||||
&self,
|
||||
slug: &str,
|
||||
) -> Result<Option<Project>>;
|
||||
|
||||
/// Request multiple projects by their hashes (Modrinth) or bytes
|
||||
/// (`CurseForge`).
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A list of projects found. Platforms that do not support hash-based
|
||||
/// lookup return an empty list.
|
||||
async fn request_projects_from_hashes(
|
||||
&self,
|
||||
hashes: &[String],
|
||||
algorithm: &str,
|
||||
) -> Result<Vec<Project>>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,6 +100,10 @@ impl DependencyResolver {
|
|||
})
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::expect_used,
|
||||
reason = "projects.len() == 1 is checked directly above"
|
||||
)]
|
||||
async fn fetch_dependency(
|
||||
&self,
|
||||
dep_id: &str,
|
||||
|
|
@ -132,7 +136,7 @@ impl DependencyResolver {
|
|||
}
|
||||
|
||||
if projects.len() == 1 {
|
||||
Ok(projects.into_iter().next().unwrap())
|
||||
Ok(projects.into_iter().next().expect("length is exactly 1"))
|
||||
} else {
|
||||
let mut merged = projects.remove(0);
|
||||
for project in projects {
|
||||
|
|
|
|||
|
|
@ -166,12 +166,12 @@ pub fn prompt_input_optional(prompt: &str) -> io::Result<Option<String>> {
|
|||
pub fn prompt_curseforge_api_key(
|
||||
skip_prompts: bool,
|
||||
) -> io::Result<Option<String>> {
|
||||
use dialoguer::Password;
|
||||
|
||||
if skip_prompts {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
use dialoguer::Password;
|
||||
|
||||
println!();
|
||||
println!("CurseForge API key is required but not configured.");
|
||||
println!("Get your API key from: https://console.curseforge.com/");
|
||||
|
|
|
|||
325
src/utils/flexver.rs
Normal file
325
src/utils/flexver.rs
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
// FlexVer - Flexible Version Comparison
|
||||
//
|
||||
// This implementation is based on the original implementation of the
|
||||
// `flexver-rs` crate, which no longer appears to be maintained.
|
||||
//
|
||||
// See:
|
||||
// <https://git.sleeping.town/exa/FlexVer/src/branch/trunk/rust/src/lib.rs>
|
||||
//
|
||||
// This implementation provides semver-like version comparison with support for:
|
||||
//
|
||||
// - Flexible version string parsing (not strict semver)
|
||||
// - Pre-release handling (parts starting with `-`)
|
||||
// - Build metadata stripping (parts after `+`)
|
||||
// - Numerical vs lexical comparison
|
||||
|
||||
use std::{
|
||||
cmp::Ordering::{self, Equal, Greater, Less},
|
||||
collections::VecDeque,
|
||||
};
|
||||
|
||||
/// Type of version component for sorting purposes
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum SortingType {
|
||||
/// A numeric component with both i64 value and string representation
|
||||
Numerical(i64, String),
|
||||
|
||||
/// A lexical string component
|
||||
Lexical(String),
|
||||
|
||||
/// A semver pre-release component (starting with `-`)
|
||||
SemverPrerelease(String),
|
||||
}
|
||||
|
||||
impl SortingType {
|
||||
fn into_string(self) -> String {
|
||||
match self {
|
||||
Self::Numerical(_, s) | Self::Lexical(s) | Self::SemverPrerelease(s) => s,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_semver_prerelease(s: &str) -> bool {
|
||||
s.len() > 1 && s.starts_with('-')
|
||||
}
|
||||
|
||||
/// Decompose a version string into its component parts
|
||||
fn decompose(str_in: &str) -> VecDeque<SortingType> {
|
||||
use SortingType::{Lexical, Numerical, SemverPrerelease};
|
||||
|
||||
fn handle_split(
|
||||
current: &str,
|
||||
c: Option<&char>,
|
||||
currently_numeric: bool,
|
||||
) -> Option<SortingType> {
|
||||
let numeric = c.is_some_and(char::is_ascii_digit);
|
||||
|
||||
if currently_numeric {
|
||||
if numeric {
|
||||
return None;
|
||||
}
|
||||
return Some(current.parse::<i64>().map_or_else(
|
||||
|_| Lexical(current.to_owned()),
|
||||
|n| Numerical(n, current.to_owned()),
|
||||
));
|
||||
}
|
||||
|
||||
if !(numeric || c == Some(&'-') || c.is_none()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
if is_semver_prerelease(current) {
|
||||
if c == Some(&'-') {
|
||||
// Pre-releases can have multiple dashes
|
||||
None
|
||||
} else {
|
||||
Some(SemverPrerelease(current.to_owned()))
|
||||
}
|
||||
} else {
|
||||
Some(Lexical(current.to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
if str_in.is_empty() {
|
||||
return VecDeque::new();
|
||||
}
|
||||
|
||||
// Strip build metadata (after `+`)
|
||||
let s = if let Some((left, _)) = str_in.split_once('+') {
|
||||
left
|
||||
} else {
|
||||
str_in
|
||||
};
|
||||
|
||||
let mut out: VecDeque<SortingType> = VecDeque::new();
|
||||
let mut current = String::new();
|
||||
|
||||
let mut currently_numeric = s.starts_with(|c: char| c.is_ascii_digit());
|
||||
let mut skip = s.starts_with('-');
|
||||
|
||||
for c in s.chars() {
|
||||
if let Some(part) = handle_split(¤t, Some(&c), currently_numeric) {
|
||||
if skip {
|
||||
skip = false;
|
||||
} else {
|
||||
out.push_back(part);
|
||||
current.clear();
|
||||
currently_numeric = c.is_ascii_digit();
|
||||
}
|
||||
}
|
||||
current.push(c);
|
||||
}
|
||||
|
||||
if let Some(part) = handle_split(¤t, None, currently_numeric) {
|
||||
out.push_back(part);
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Compare two version strings using `FlexVer` rules.
|
||||
///
|
||||
/// Returns:
|
||||
/// - `Ordering::Less` if `a` < `b`
|
||||
/// - `Ordering::Equal` if `a` == `b`
|
||||
/// - `Ordering::Greater` if `a` > `b`
|
||||
///
|
||||
/// This matches the behavior of flexver-java:
|
||||
/// - "1.0.0" > "1.0.0-beta" (release > pre-release)
|
||||
/// - "1.0.0-beta" < "1.0.0+build123" (pre-release < build metadata)
|
||||
#[expect(
|
||||
clippy::unreachable,
|
||||
reason = "the VersionComparisonIterator never yields (None, None)"
|
||||
)]
|
||||
pub fn compare(left: &str, right: &str) -> Ordering {
|
||||
let iter = VersionComparisonIterator {
|
||||
left: decompose(left),
|
||||
right: decompose(right),
|
||||
};
|
||||
|
||||
for next in iter {
|
||||
use SortingType::{Numerical, SemverPrerelease};
|
||||
|
||||
let current = match next {
|
||||
// Left ran out first
|
||||
(Some(l), None) => {
|
||||
if let SemverPrerelease(_) = l {
|
||||
Less
|
||||
} else {
|
||||
Greater
|
||||
}
|
||||
},
|
||||
// Right ran out first
|
||||
(None, Some(r)) => {
|
||||
if let SemverPrerelease(_) = r {
|
||||
Greater
|
||||
} else {
|
||||
Less
|
||||
}
|
||||
},
|
||||
// Both have components
|
||||
(Some(l), Some(r)) => {
|
||||
match (l, r) {
|
||||
(Numerical(l, _), Numerical(r, _)) => l.cmp(&r),
|
||||
(l, r) => l.into_string().cmp(&r.into_string()),
|
||||
}
|
||||
},
|
||||
(None, None) => unreachable!(),
|
||||
};
|
||||
|
||||
if current != Equal {
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
Equal
|
||||
}
|
||||
|
||||
/// Version comparison iterator that yields pairs of components
|
||||
#[derive(Debug)]
|
||||
struct VersionComparisonIterator {
|
||||
left: VecDeque<SortingType>,
|
||||
right: VecDeque<SortingType>,
|
||||
}
|
||||
|
||||
impl Iterator for VersionComparisonIterator {
|
||||
type Item = (Option<SortingType>, Option<SortingType>);
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
let item = (self.left.pop_front(), self.right.pop_front());
|
||||
if item == (None, None) {
|
||||
None
|
||||
} else {
|
||||
Some(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `FlexVer` type for use with standard library traits
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct FlexVer<'a>(pub &'a str);
|
||||
|
||||
impl PartialEq for FlexVer<'_> {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
compare(self.0, other.0) == Equal
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for FlexVer<'_> {}
|
||||
|
||||
impl PartialOrd for FlexVer<'_> {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for FlexVer<'_> {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
compare(self.0, other.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::cmp::Ordering;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn cmp(a: &str, b: &str) -> Ordering {
|
||||
compare(a, b)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_basic_release_comparison() {
|
||||
assert_eq!(cmp("1.0.0", "1.0.0"), Ordering::Equal);
|
||||
assert_eq!(cmp("1.0.0", "1.0.1"), Ordering::Less);
|
||||
assert_eq!(cmp("1.0.1", "1.0.0"), Ordering::Greater);
|
||||
assert_eq!(cmp("1.0.0", "1.1.0"), Ordering::Less);
|
||||
assert_eq!(cmp("1.1.0", "1.0.0"), Ordering::Greater);
|
||||
assert_eq!(cmp("2.0.0", "1.9.9"), Ordering::Greater);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prerelease_comparison() {
|
||||
// Release > pre-release
|
||||
assert_eq!(cmp("1.0.0", "1.0.0-beta"), Ordering::Greater);
|
||||
assert_eq!(cmp("1.0.0-beta", "1.0.0"), Ordering::Less);
|
||||
|
||||
// Pre-release with tilde
|
||||
assert_eq!(cmp("1.0.0~1", "1.0.0~2"), Ordering::Less);
|
||||
assert_eq!(cmp("1.0.0~2", "1.0.0~1"), Ordering::Greater);
|
||||
assert_eq!(cmp("1.0.0~1", "1.0.0~1"), Ordering::Equal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prerelease_with_tilde_vs_alpha() {
|
||||
// In FlexVer, "~" is not treated as a pre-release marker
|
||||
// Only "-" followed by text marks a pre-release
|
||||
// So "1.0.0~1" is compared lexicographically vs "1.0.0-beta"
|
||||
// Since '~' (ASCII 126) > '-' (ASCII 45), "~1" > "-beta"
|
||||
assert_eq!(cmp("1.0.0~1", "1.0.0-beta"), Ordering::Greater);
|
||||
assert_eq!(cmp("1.0.0-beta", "1.0.0~1"), Ordering::Less);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_metadata() {
|
||||
// Build metadata with + is stripped for comparison
|
||||
assert_eq!(cmp("1.0.0+build", "1.0.0"), Ordering::Equal);
|
||||
assert_eq!(cmp("1.0.0+build", "1.0.0-alpha"), Ordering::Greater);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_file_extensions() {
|
||||
// File extensions should be handled by string comparison
|
||||
assert_eq!(cmp("mod-1.0.0.jar", "mod-1.0.0.jar"), Ordering::Equal);
|
||||
assert!(cmp("mod-1.0.0.jar", "mod-1.0.1.jar").is_lt());
|
||||
assert!(cmp("mod-1.0.1.jar", "mod-1.0.0.jar").is_gt());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_complex_versions() {
|
||||
// Simple version comparison
|
||||
assert!(cmp("sodium-1.0.0", "sodium-1.0.1").is_lt());
|
||||
assert!(cmp("sodium-1.0.1", "sodium-1.0.0").is_gt());
|
||||
|
||||
// File extensions are NOT stripped - they're part of the version string
|
||||
// "sodium-1.0.0.jar" < "sodium-1.0.0~1.jar" because '.' (46) < '~' (126)
|
||||
assert!(cmp("sodium-1.0.0.jar", "sodium-1.0.0~1.jar").is_lt());
|
||||
assert!(cmp("sodium-1.0.0~1.jar", "sodium-1.0.0.jar").is_gt());
|
||||
|
||||
assert!(cmp("fabric-0.15.0.1", "fabric-0.15.0.2").is_lt());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_min_max() {
|
||||
assert_eq!(FlexVer("1.0.0").min(FlexVer("1.0.0")), FlexVer("1.0.0"));
|
||||
assert_eq!(FlexVer("a1.2.6").min(FlexVer("b1.7.3")), FlexVer("a1.2.6"));
|
||||
assert_eq!(FlexVer("b1.7.3").max(FlexVer("a1.2.6")), FlexVer("b1.7.3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_commutative() {
|
||||
// If a > b, then b < a
|
||||
let pairs = vec![
|
||||
("1.0.0", "1.0.1"),
|
||||
("1.0.0-beta", "1.0.0"),
|
||||
("1.0.0~1", "1.0.0~2"),
|
||||
];
|
||||
|
||||
for (a, b) in pairs {
|
||||
let ordering = compare(a, b);
|
||||
let inverse = match ordering {
|
||||
Ordering::Less => Ordering::Greater,
|
||||
Ordering::Greater => Ordering::Less,
|
||||
Ordering::Equal => Ordering::Equal,
|
||||
};
|
||||
assert_eq!(
|
||||
compare(b, a),
|
||||
inverse,
|
||||
"Commutativity violation: {} vs {}",
|
||||
a,
|
||||
b
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,16 @@ use sha2::{Sha256, Sha512};
|
|||
|
||||
use crate::error::{PakkerError, Result};
|
||||
|
||||
pub fn hash_to_hex(hash: impl AsRef<[u8]>) -> String {
|
||||
use std::fmt::Write;
|
||||
let bytes = hash.as_ref();
|
||||
let mut hex = String::with_capacity(bytes.len() * 2);
|
||||
for byte in bytes {
|
||||
let _ = write!(hex, "{byte:02x}");
|
||||
}
|
||||
hex
|
||||
}
|
||||
|
||||
/// Compute SHA1 hash of a file
|
||||
pub fn compute_sha1<P: AsRef<Path>>(path: P) -> Result<String> {
|
||||
let file = File::open(path)?;
|
||||
|
|
@ -25,7 +35,7 @@ pub fn compute_sha1<P: AsRef<Path>>(path: P) -> Result<String> {
|
|||
hasher.update(&buffer[..n]);
|
||||
}
|
||||
|
||||
Ok(format!("{:x}", hasher.finalize()))
|
||||
Ok(hash_to_hex(hasher.finalize().as_slice()))
|
||||
}
|
||||
|
||||
/// Compute SHA256 hash of a file
|
||||
|
|
@ -43,14 +53,14 @@ pub fn compute_sha256<P: AsRef<Path>>(path: P) -> Result<String> {
|
|||
hasher.update(&buffer[..n]);
|
||||
}
|
||||
|
||||
Ok(format!("{:x}", hasher.finalize()))
|
||||
Ok(hash_to_hex(hasher.finalize().as_slice()))
|
||||
}
|
||||
|
||||
/// Compute SHA256 hash of byte data
|
||||
pub fn compute_sha256_bytes(data: &[u8]) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(data);
|
||||
format!("{:x}", hasher.finalize())
|
||||
hash_to_hex(hasher.finalize().as_slice())
|
||||
}
|
||||
|
||||
/// Compute SHA512 hash of a file
|
||||
|
|
@ -68,7 +78,7 @@ pub fn compute_sha512<P: AsRef<Path>>(path: P) -> Result<String> {
|
|||
hasher.update(&buffer[..n]);
|
||||
}
|
||||
|
||||
Ok(format!("{:x}", hasher.finalize()))
|
||||
Ok(hash_to_hex(hasher.finalize().as_slice()))
|
||||
}
|
||||
|
||||
/// Compute MD5 hash of a file
|
||||
|
|
@ -86,7 +96,12 @@ pub fn compute_md5<P: AsRef<Path>>(path: P) -> Result<String> {
|
|||
hasher.update(&buffer[..n]);
|
||||
}
|
||||
|
||||
Ok(format!("{:x}", hasher.finalize()))
|
||||
let hash = hasher.finalize();
|
||||
let mut hex = String::with_capacity(hash.len() * 2);
|
||||
for byte in hash {
|
||||
let _ = std::fmt::write(&mut hex, format_args!("{byte:02x}"));
|
||||
}
|
||||
Ok(hex)
|
||||
}
|
||||
|
||||
/// Verify a file's hash against expected value
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
pub mod flexver;
|
||||
pub mod hash;
|
||||
pub mod id;
|
||||
|
||||
pub use flexver::FlexVer;
|
||||
pub use hash::verify_hash;
|
||||
pub use id::generate_pakku_id;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue