Compare commits

...

18 commits

Author SHA1 Message Date
a642b976e9
chore: add missing manifest fields to Cargo manifest
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I31ce255cf7241f61600c0384bb703f966a6a6964
2026-04-21 19:27:37 +03:00
61ced09d25
treewide: fix clippy lints
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I411be69ff31f9cb39cd4cdebc8985b366a6a6964
2026-04-21 19:27:36 +03:00
b93b234fc2
build: enforce stricter Clippy lint rules; optimize release profile
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I9077be96783370a26902f46f62afa2826a6a6964
2026-04-21 19:27:35 +03:00
ace9bcac8a
various: fix auto-fixable clippy lints
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I523cd8163d3995efa2f1e8475bbf87316a6a6964
2026-04-21 19:27:34 +03:00
8b2140c057
build: bump all dependencies and set MSRV to 1.94; fix build failures
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I7d331410864358d30191781d1e6c23f46a6a6964
2026-04-21 19:27:33 +03:00
020514cd7a
nix: bump nixpkgs
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I7909d4e7d665517c5cebcc4f7906d1f76a6a6964
2026-04-21 19:27:32 +03:00
20ea3c680b
platform: add rustdoc to various methods
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ic4d2bd6f3baf97ce30dbf8709331f6f66a6a6964
2026-04-21 19:27:31 +03:00
e19df15ae5
flexver: fix ASCII value for '.' in comment
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ib48589583e34742da5ca7d173ac0f0756a6a6964
2026-04-21 19:27:30 +03:00
838ba82790
sync: batch file identification via hash lookup
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I85d3f1265cad1996340ac98ac9ee1f7e6a6a6964
2026-04-21 19:27:29 +03:00
0048a1cd73
fetch: add retry support for downloads
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I5920652b1f84cd8d03e3f8c9d17e5aa76a6a6964
2026-04-21 19:27:28 +03:00
c0c9d741c1
model/project: add Project::merged for pure combining
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Idf955432e57d87352dffa961e145fcb76a6a6964
2026-04-21 19:27:27 +03:00
5772200da9
platform/multiplatform: add multiplatform client with cross-ref
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ie2cf48136e5a9017265a3b0ef26619356a6a6964
2026-04-21 19:27:26 +03:00
a8bf8f9f3f
utils/flexver: handle i64 overflow gracefully
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I66386d97f92744a5c07c04b072bc1a626a6a6964
2026-04-21 19:27:25 +03:00
530ba8b581
build: drop redundant symlink script
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: If819a647c6c3ab15eb553967de9bd7fc6a6a6964
2026-04-21 19:27:24 +03:00
2c4058b54a
commands/update: use flexver for version sorting
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I4e1cd3247e74247cbde65391510bd3586a6a6964
2026-04-21 19:27:23 +03:00
af3cdbf343
fetch: use flexver for file selection
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ia01283a5665ac9497858821f13a7751d6a6a6964
2026-04-21 19:27:22 +03:00
66317d98de
model/enums: add flexver variant to UpdateStrategy
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I8c82af278d54ed4730e808087fa19e846a6a6964
2026-04-21 19:27:21 +03:00
1c08e00ccf
utils/flexver: add flexver comparator
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I79b8d3745a8754619f810de1bac8b66f6a6a6964
2026-04-21 19:27:17 +03:00
53 changed files with 1930 additions and 628 deletions

265
Cargo.lock generated
View file

@ -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",

View file

@ -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

View file

@ -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
View file

@ -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": {

View file

@ -84,7 +84,7 @@ pub enum Commands {
Credentials(CredentialsArgs),
/// Configure modpack properties
Cfg(CfgArgs),
Cfg(Box<CfgArgs>),
/// Manage fork configuration
Fork(ForkArgs),

View file

@ -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();

View file

@ -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)]

View file

@ -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());

View file

@ -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!(

View file

@ -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()

View file

@ -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 {

View file

@ -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');
}

View file

@ -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
}

View file

@ -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?;

View file

@ -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!();

View file

@ -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)?;

View file

@ -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!();

View file

@ -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<()> {

View file

@ -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

View file

@ -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() {

View file

@ -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<()> {

View file

@ -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(());

View file

@ -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

View file

@ -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:?}");
}

View file

@ -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
}
}

View file

@ -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(())
}

View file

@ -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

View file

@ -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

View file

@ -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
}

View file

@ -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)?;
}
}

View file

@ -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

View file

@ -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"
);
}

View file

@ -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)?;
}
}

View file

@ -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| {

View file

@ -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)

View file

@ -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",

View file

@ -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(),

View file

@ -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

View file

@ -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}"

View file

@ -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"),
}
}

View file

@ -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

View file

@ -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);
}
}

View file

@ -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
}
}

View file

@ -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

View file

@ -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

View file

@ -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

View 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)
}
}

View file

@ -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>>;
}

View file

@ -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 {

View file

@ -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
View 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(&current, 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(&current, 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
);
}
}
}

View file

@ -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

View file

@ -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;