Compare commits

...

10 commits

Author SHA1 Message Date
d40cbb74fc
various: shared HTTP client with connection pooling
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Id13c17e9352da970a289f4e3ad909c5b6a6a6964
2026-02-19 00:15:57 +03:00
05c946a155
platform: add mockito HTTP tests to modrinth
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I880c11195559fcfb9701e945a10fe87b6a6a6964
2026-02-13 00:50:13 +03:00
73f881c336
chore: bump dependencies
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ic1fda520473e53d1a584a3dda63ffda86a6a6964
2026-02-13 00:50:12 +03:00
787e93fdaa
cli: add --all, --updates, --no-deps flags to commands
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I25581b8de945284b4ce7c2c85601a86f6a6a6964
2026-02-13 00:50:11 +03:00
1938158b07
infra: add clippy allows; fix PathBuf -> Path
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I07795374f678fa2ec17b4171fa7e32276a6a6964
2026-02-13 00:50:10 +03:00
a58b956374
platform: add CurseForge side detection from categories
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I62c5117ed97bbc2389330720b4761a716a6a6964
2026-02-13 00:50:09 +03:00
d4bc6b3887
cli: wire shelve flag; more clippy fixes
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I694da71afe93bcb33687ff7d8e75f04f6a6a6964
2026-02-13 00:50:08 +03:00
f0ff262643
cli: wire get_site_url in inspect; fix clippy in remote_update
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ifdbc34dd7a5a51edc5dff326eac095516a6a6964
2026-02-13 00:50:07 +03:00
1251255bd5
cli: add version mismatch warning to ls; wire ErrorSeverity in status
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I170f944127333c552e8a230972ed89d66a6a6964
2026-02-13 00:50:06 +03:00
ee63c803ab
cli: add --all flag to update; wire UpdateStrategy enforcement
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I9570557396ac46e82cbabbd8e39be0936a6a6964
2026-02-13 00:50:05 +03:00
22 changed files with 1116 additions and 264 deletions

373
Cargo.lock generated
View file

@ -16,7 +16,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
"cpufeatures 0.2.17",
]
[[package]]
@ -80,9 +80,9 @@ dependencies = [
[[package]]
name = "anyhow"
version = "1.0.100"
version = "1.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
[[package]]
name = "assert-json-diff"
@ -211,6 +211,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"rand_core 0.10.0",
]
[[package]]
name = "cipher"
version = "0.4.4"
@ -223,9 +234,9 @@ dependencies = [
[[package]]
name = "clap"
version = "4.5.54"
version = "4.5.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394"
checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806"
dependencies = [
"clap_builder",
"clap_derive",
@ -233,9 +244,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.5.54"
version = "4.5.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00"
checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2"
dependencies = [
"anstream",
"anstyle",
@ -245,9 +256,9 @@ dependencies = [
[[package]]
name = "clap_derive"
version = "4.5.49"
version = "4.5.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
dependencies = [
"heck",
"proc-macro2",
@ -257,9 +268,9 @@ dependencies = [
[[package]]
name = "clap_lex"
version = "0.7.6"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
[[package]]
name = "cmake"
@ -321,9 +332,9 @@ dependencies = [
[[package]]
name = "constant_time_eq"
version = "0.3.1"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
[[package]]
name = "core-foundation"
@ -360,6 +371,15 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]]
name = "crc"
version = "3.3.0"
@ -498,9 +518,9 @@ dependencies = [
[[package]]
name = "env_filter"
version = "0.1.4"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2"
checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f"
dependencies = [
"log",
"regex",
@ -508,9 +528,9 @@ dependencies = [
[[package]]
name = "env_logger"
version = "0.11.8"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f"
checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d"
dependencies = [
"anstream",
"anstyle",
@ -564,6 +584,12 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@ -706,10 +732,26 @@ dependencies = [
]
[[package]]
name = "git2"
version = "0.20.3"
name = "getrandom"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e2b37e2f62729cdada11f0e6b3b6fe383c69c29fc619e391223e12856af308c"
checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"rand_core 0.10.0",
"wasip2",
"wasip3",
"wasm-bindgen",
]
[[package]]
name = "git2"
version = "0.20.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b"
dependencies = [
"bitflags 2.10.0",
"libc",
@ -720,6 +762,12 @@ dependencies = [
"url",
]
[[package]]
name = "glob"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "h2"
version = "0.4.13"
@ -739,6 +787,15 @@ dependencies = [
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
@ -951,6 +1008,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "idna"
version = "1.1.0"
@ -979,7 +1042,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
dependencies = [
"equivalent",
"hashbrown",
"hashbrown 0.16.1",
"serde",
"serde_core",
]
[[package]]
@ -1108,6 +1173,12 @@ dependencies = [
"zeroize",
]
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libbz2-rs-sys"
version = "0.2.2"
@ -1116,9 +1187,9 @@ checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7"
[[package]]
name = "libc"
version = "0.2.180"
version = "0.2.181"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5"
[[package]]
name = "libgit2-sys"
@ -1263,9 +1334,9 @@ dependencies = [
[[package]]
name = "mockito"
version = "1.7.1"
version = "1.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e0603425789b4a70fcc4ac4f5a46a566c116ee3e2a6b768dc623f7719c611de"
checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0"
dependencies = [
"assert-json-diff",
"bytes",
@ -1278,7 +1349,7 @@ dependencies = [
"hyper-util",
"log",
"pin-project-lite",
"rand",
"rand 0.9.2",
"regex",
"serde_json",
"serde_urlencoded",
@ -1288,9 +1359,9 @@ dependencies = [
[[package]]
name = "num-conv"
version = "0.1.0"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
[[package]]
name = "once_cell"
@ -1340,16 +1411,17 @@ dependencies = [
"env_logger",
"futures",
"git2",
"glob",
"indicatif",
"keyring",
"libc",
"log",
"md-5",
"mockito",
"once_cell",
"rand",
"rand 0.10.0",
"regex",
"reqwest",
"semver",
"serde",
"serde_json",
"sha1",
@ -1357,7 +1429,7 @@ dependencies = [
"strsim",
"tempfile",
"textwrap",
"thiserror 2.0.17",
"thiserror 2.0.18",
"tokio",
"walkdir",
"yansi",
@ -1453,9 +1525,9 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppmd-rust"
version = "1.3.0"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d558c559f0450f16f2a27a1f017ef38468c1090c9ce63c8e51366232d53717b4"
checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24"
[[package]]
name = "ppv-lite86"
@ -1466,6 +1538,16 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.105"
@ -1489,7 +1571,7 @@ dependencies = [
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.17",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
@ -1505,13 +1587,13 @@ dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand",
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.17",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
@ -1553,7 +1635,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha",
"rand_core",
"rand_core 0.9.3",
]
[[package]]
name = "rand"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8"
dependencies = [
"chacha20",
"getrandom 0.4.1",
"rand_core 0.10.0",
]
[[package]]
@ -1563,7 +1656,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
"rand_core 0.9.3",
]
[[package]]
@ -1575,6 +1668,12 @@ dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_core"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba"
[[package]]
name = "redox_syscall"
version = "0.5.18"
@ -1586,9 +1685,9 @@ dependencies = [
[[package]]
name = "regex"
version = "1.12.2"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
@ -1615,9 +1714,9 @@ checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
[[package]]
name = "reqwest"
version = "0.13.1"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62"
checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801"
dependencies = [
"base64",
"bytes",
@ -1820,6 +1919,12 @@ dependencies = [
"libc",
]
[[package]]
name = "semver"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
[[package]]
name = "serde"
version = "1.0.228"
@ -1882,7 +1987,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.17",
"digest",
]
@ -1893,7 +1998,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.17",
"digest",
]
@ -2031,12 +2136,12 @@ dependencies = [
[[package]]
name = "tempfile"
version = "3.24.0"
version = "3.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c"
checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.1",
"once_cell",
"rustix",
"windows-sys 0.61.2",
@ -2064,11 +2169,11 @@ dependencies = [
[[package]]
name = "thiserror"
version = "2.0.17"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl 2.0.17",
"thiserror-impl 2.0.18",
]
[[package]]
@ -2084,9 +2189,9 @@ dependencies = [
[[package]]
name = "thiserror-impl"
version = "2.0.17"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
@ -2095,22 +2200,23 @@ dependencies = [
[[package]]
name = "time"
version = "0.3.44"
version = "0.3.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d"
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [
"deranged",
"js-sys",
"num-conv",
"powerfmt",
"serde",
"serde_core",
"time-core",
]
[[package]]
name = "time-core"
version = "0.1.6"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b"
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
[[package]]
name = "tinystr"
@ -2258,6 +2364,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typed-path"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
[[package]]
name = "typenum"
version = "1.19.0"
@ -2288,6 +2400,12 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "unit-prefix"
version = "0.5.2"
@ -2367,7 +2485,16 @@ version = "1.0.1+wasi-0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
dependencies = [
"wit-bindgen",
"wit-bindgen 0.46.0",
]
[[package]]
name = "wasip3"
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
"wit-bindgen 0.51.0",
]
[[package]]
@ -2428,6 +2555,40 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-encoder"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags 2.10.0",
"hashbrown 0.15.5",
"indexmap",
"semver",
]
[[package]]
name = "web-sys"
version = "0.3.83"
@ -2760,6 +2921,94 @@ version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
dependencies = [
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags 2.10.0",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]]
name = "writeable"
version = "0.6.2"
@ -2891,9 +3140,9 @@ dependencies = [
[[package]]
name = "zip"
version = "7.1.0"
version = "7.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9013f1222db8a6d680f13a7ccdc60a781199cd09c2fa4eff58e728bb181757fc"
checksum = "cc12baa6db2b15a140161ce53d72209dacea594230798c24774139b54ecaa980"
dependencies = [
"aes",
"bzip2",
@ -2901,8 +3150,7 @@ dependencies = [
"crc32fast",
"deflate64",
"flate2",
"generic-array",
"getrandom 0.3.4",
"getrandom 0.4.1",
"hmac",
"indexmap",
"lzma-rust2",
@ -2911,6 +3159,7 @@ dependencies = [
"ppmd-rust",
"sha1",
"time",
"typed-path",
"zeroize",
"zopfli",
"zstd",

View file

@ -1,47 +1,47 @@
[package]
name = "pakker"
version = "0.1.0"
edition = "2024"
authors = [ "NotAShelf <raf@notashelf.dev" ]
name = "pakker"
version = "0.1.0"
edition = "2024"
authors = [ "NotAShelf <raf@notashelf.dev>" ]
rust-version = "1.91.0"
readme = true
[dependencies]
anyhow = "1.0.100"
anyhow = "1.0.101"
async-trait = "0.1.89"
clap = { version = "4.5.54", features = [ "derive" ] }
comfy-table = "7.1"
clap = { version = "4.5.58", features = [ "derive" ] }
comfy-table = "7.2.2"
dialoguer = "0.12.0"
env_logger = "0.11.8"
env_logger = "0.11.9"
futures = "0.3.31"
git2 = "0.20.3"
git2 = "0.20.4"
glob = "0.3.3"
indicatif = "0.18.3"
keyring = "3.6.3"
libc = "0.2.180"
libc = "0.2.181"
log = "0.4.29"
md-5 = "0.10.6"
once_cell = "1.20"
rand = "0.9.2"
regex = "1.12"
reqwest = { version = "0.13.1", features = [ "json" ] }
rand = "0.10.0"
regex = "1.12.3"
reqwest = { version = "0.13.2", features = [ "json" ] }
semver = "1.0.27"
serde = { version = "1.0.228", features = [ "derive" ] }
serde_json = "1.0.149"
sha1 = "0.10.6"
sha2 = "0.10.0"
sha2 = "0.10.9"
strsim = "0.11.1"
tempfile = "3.24.0"
textwrap = "0.16"
thiserror = "2.0.17"
tempfile = "3.25.0"
textwrap = "0.16.2"
thiserror = "2.0.18"
tokio = { version = "1.49.0", features = [ "full" ] }
walkdir = "2.5.0"
yansi = "1.0.1"
zip = "7.1.0"
zip = "7.4.0"
[dev-dependencies]
mockito = "1.7.1"
tempfile = "3.24.0"
[[bin]]
name = "pakker"
path = "src/main.rs"
mockito = "1.7.2"
tempfile = "3.25.0"
# Optimize crypto stuff. Building them with optimizations makes that build script
# run ~5x faster, more than offsetting the additional build time added to the

View file

@ -97,20 +97,20 @@ pub struct InitArgs {
pub version: Option<String>,
/// Target platform
#[clap(short, long, default_value = "multiplatform")]
pub target: String,
#[clap(short, long)]
pub target: Option<String>,
/// Minecraft version
#[clap(short, long, default_value = "1.20.1")]
pub mc_version: String,
/// Minecraft versions (space-separated)
#[clap(short, long = "mc-versions", value_delimiter = ' ', num_args = 1..)]
pub mc_versions: Option<Vec<String>>,
/// Mod loader
#[clap(short, long, default_value = "fabric")]
pub loader: String,
/// Mod loaders (format: name=version, can be specified multiple times)
#[clap(short, long = "loaders", value_delimiter = ',')]
pub loaders: Option<Vec<String>>,
/// Mod loader version
#[clap(short = 'v', long, default_value = "latest")]
pub loader_version: String,
/// Skip interactive prompts (use defaults)
#[clap(short, long)]
pub yes: bool,
}
#[derive(Args)]
@ -214,6 +214,10 @@ pub struct RmArgs {
/// Skip confirmation prompt
#[clap(short, long)]
pub yes: bool,
/// Skip removing dependent projects
#[clap(short = 'D', long = "no-deps")]
pub no_deps: bool,
}
#[derive(Args)]
@ -222,6 +226,10 @@ pub struct UpdateArgs {
#[arg(value_name = "PROJECT")]
pub inputs: Vec<String>,
/// Update all projects
#[arg(short, long)]
pub all: bool,
/// Skip confirmation prompts
#[arg(short, long)]
pub yes: bool,
@ -344,7 +352,7 @@ pub struct SyncArgs {
#[clap(short = 'R', long)]
pub removals: bool,
/// Sync updates only
/// Sync updates only (apply pending updates)
#[clap(short = 'U', long)]
pub updates: bool,
}
@ -371,7 +379,7 @@ pub struct ExportArgs {
/// Export modpack without server content
/// Modrinth: exclude server-overrides and SERVER mods
/// ServerPack: skip export
/// `ServerPack`: skip export
#[clap(long = "no-server")]
pub no_server: bool,
}

View file

@ -35,7 +35,7 @@ pub async fn execute(
let config_dir = config_path.parent().unwrap_or(Path::new("."));
// IPC coordination - prevent concurrent operations on the same modpack
let ipc = IpcCoordinator::new(&config_dir.to_path_buf())?;
let ipc = IpcCoordinator::new(config_dir)?;
let ipc_timeout = std::time::Duration::from_secs(60);
// Check for conflicting export operations

View file

@ -37,8 +37,8 @@ pub async fn execute(
let operation_id = coordinator.register_operation(OperationType::Fetch)?;
let _guard = OperationGuard::new(coordinator, operation_id);
// Create fetcher
let fetcher = Fetcher::new(".");
// Create fetcher with shelve option
let fetcher = Fetcher::new(".").with_shelve(args.shelve);
// Fetch all projects (progress indicators handled in fetch.rs)
fetcher.fetch_all(&lockfile, &config).await?;

View file

@ -211,13 +211,12 @@ fn execute_init(
.args(["log", "--limit", "1", "--template", ""])
.current_dir(path)
.output()
&& !output.stdout.is_empty()
{
if !output.stdout.is_empty() {
println!(
"Note: Jujutsu repository detected. Make sure to run 'jj git \
push' to sync changes with remote if needed."
);
}
println!(
"Note: Jujutsu repository detected. Make sure to run 'jj git \
push' to sync changes with remote if needed."
);
}
},
VcsType::None => {

View file

@ -134,16 +134,19 @@ async fn import_modrinth(
{
log::info!("Fetching project: {project_id}");
match platform
.request_project_with_files(project_id, &lockfile.mc_versions, &[
loader.0.clone(),
])
.request_project_with_files(
project_id,
&lockfile.mc_versions,
std::slice::from_ref(&loader.0),
)
.await
{
Ok(mut project) => {
// Select best file
if let Err(e) =
project.select_file(&lockfile.mc_versions, &[loader.0.clone()])
{
if let Err(e) = project.select_file(
&lockfile.mc_versions,
std::slice::from_ref(&loader.0),
) {
log::warn!(
"Failed to select file for {}: {}",
project.get_name(),
@ -357,7 +360,7 @@ async fn import_curseforge(
description: None,
author: manifest["author"]
.as_str()
.map(|s| s.to_string()),
.map(std::string::ToString::to_string),
overrides: vec!["overrides".to_string()],
server_overrides: None,
client_overrides: None,

View file

@ -176,7 +176,7 @@ fn display_project_inspection(
// Display project files
println!();
display_project_files(&project.files)?;
display_project_files(&project.files, project)?;
// Display properties
println!();
@ -228,7 +228,10 @@ fn display_project_header(project: &Project) -> Result<()> {
Ok(())
}
fn display_project_files(files: &[ProjectFile]) -> Result<()> {
fn display_project_files(
files: &[ProjectFile],
project: &Project,
) -> Result<()> {
if files.is_empty() {
println!("{}", "No files available".yellow());
return Ok(());
@ -250,19 +253,31 @@ fn display_project_files(files: &[ProjectFile]) -> Result<()> {
format!(" {status}")
};
// File path line
// File path line with optional site URL
let file_path = format!("{}={}", file.file_type, file.file_name);
table.add_row(vec![
Cell::new(format!("{file_path}:{status_text}")).fg(if idx == 0 {
Color::Green
} else {
Color::White
}),
]);
let file_display = if let Some(site_url) = file.get_site_url(project) {
// Create hyperlink for the file
let hyperlink = crate::ui_utils::hyperlink(&site_url, &file_path);
format!("{hyperlink}:{status_text}")
} else {
format!("{file_path}:{status_text}")
};
table.add_row(vec![Cell::new(file_display).fg(if idx == 0 {
Color::Green
} else {
Color::White
})]);
// Date published
table.add_row(vec![Cell::new(&file.date_published).fg(Color::DarkGrey)]);
// Show site URL if available (for non-hyperlink terminals)
if let Some(site_url) = file.get_site_url(project) {
table
.add_row(vec![Cell::new(format!("URL: {site_url}")).fg(Color::Blue)]);
}
// Empty line
table.add_row(vec![Cell::new("")]);

View file

@ -2,6 +2,18 @@ use std::path::Path;
use crate::{cli::LsArgs, error::Result, model::LockFile};
/// Truncate a name to fit within `max_len` characters, adding "..." if
/// truncated
fn truncate_name(name: &str, max_len: usize) -> String {
if name.len() <= max_len {
name.to_string()
} else if max_len > 3 {
format!("{}...", &name[..max_len - 3])
} else {
name[..max_len].to_string()
}
}
pub fn execute(args: LsArgs, lockfile_path: &Path) -> Result<()> {
// Load expects directory path, so get parent directory
let lockfile_dir = lockfile_path.parent().unwrap_or(Path::new("."));
@ -15,10 +27,33 @@ pub fn execute(args: LsArgs, lockfile_path: &Path) -> Result<()> {
println!("Installed projects ({}):", lockfile.projects.len());
println!();
// Calculate max name length for alignment
let max_name_len = args.name_max_length.unwrap_or_else(|| {
lockfile
.projects
.iter()
.map(|p| p.get_name().len())
.max()
.unwrap_or(20)
.min(50)
});
for project in &lockfile.projects {
// Check for version mismatch across providers
let version_warning = if project.versions_match_across_providers() {
""
} else {
// Use the detailed check_version_mismatch for logging
if let Some(mismatch_detail) = project.check_version_mismatch() {
log::warn!("{mismatch_detail}");
}
" [!] versions do not match across providers"
};
if args.detailed {
let id = project.pakku_id.as_deref().unwrap_or("unknown");
println!(" {} ({})", project.get_name(), id);
let name = truncate_name(&project.get_name(), max_name_len);
println!(" {name} ({id}){version_warning}");
println!(" Type: {:?}", project.r#type);
println!(" Side: {:?}", project.side);
@ -30,19 +65,28 @@ pub fn execute(args: LsArgs, lockfile_path: &Path) -> Result<()> {
);
}
// Show version details if there's a mismatch
if !version_warning.is_empty() {
println!(" Provider versions:");
for file in &project.files {
println!(" {}: {}", file.file_type, file.file_name);
}
}
if !project.pakku_links.is_empty() {
println!(" Dependencies: {}", project.pakku_links.len());
}
println!();
} else {
let name = truncate_name(&project.get_name(), max_name_len);
let file_info = project
.files
.first()
.map(|f| format!(" ({})", f.file_name))
.unwrap_or_default();
println!(" {}{}", project.get_name(), file_info);
println!(" {name}{file_info}{version_warning}");
}
}

View file

@ -1,4 +1,4 @@
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use crate::{cli::RemoteUpdateArgs, error::PakkerError, git, model::Config};
@ -71,7 +71,7 @@ pub async fn execute(args: RemoteUpdateArgs) -> Result<(), PakkerError> {
}
/// Sync override files from remote directory to current directory
async fn sync_overrides(remote_dir: &PathBuf) -> Result<(), PakkerError> {
async fn sync_overrides(remote_dir: &Path) -> Result<(), PakkerError> {
let remote_config_path = remote_dir.join("pakku.json");
if !remote_config_path.exists() {
return Ok(());

View file

@ -6,7 +6,7 @@ use tokio::sync::Semaphore;
use yansi::Paint;
use crate::{
error::Result,
error::{ErrorSeverity, Result},
model::{Config, LockFile, Project},
platform::create_platform,
};
@ -36,13 +36,42 @@ pub async fn execute(
// Display results
display_update_results(&updates);
// Display errors if any
// Display errors if any, categorized by severity
if !errors.is_empty() {
println!();
println!("{}", "Errors encountered:".red());
for (project, error) in &errors {
println!(" - {}: {}", project.yellow(), error.red());
// Categorize errors by severity
let (warnings, errors_only): (Vec<_>, Vec<_>) =
errors.iter().partition(|(_, err)| {
// Network errors and "not found" are warnings (non-fatal)
err.contains("Failed to check") || err.contains("not found")
});
// Display warnings (ErrorSeverity::Warning)
if !warnings.is_empty() {
let severity = ErrorSeverity::Warning;
println!("{}", format_severity_header(severity, "Warnings"));
for (project, error) in &warnings {
println!(" - {}: {}", project.yellow(), error.dim());
}
}
// Display errors (ErrorSeverity::Error)
if !errors_only.is_empty() {
let severity = ErrorSeverity::Error;
println!("{}", format_severity_header(severity, "Errors"));
for (project, error) in &errors_only {
println!(" - {}: {}", project.yellow(), error.red());
}
}
// Log info level summary
let _info_severity = ErrorSeverity::Info;
log::info!(
"Update check completed with {} warning(s) and {} error(s)",
warnings.len(),
errors_only.len()
);
}
// Prompt to update if there are updates available
@ -52,6 +81,7 @@ pub async fn execute(
// Call update command programmatically (update all projects)
let update_args = crate::cli::UpdateArgs {
inputs: vec![],
all: true,
yes: true, // Auto-yes for status command
};
crate::cli::commands::update::execute(
@ -368,3 +398,12 @@ fn get_api_key(platform: &str) -> Option<String> {
_ => None,
}
}
/// Format severity header with appropriate color
fn format_severity_header(severity: ErrorSeverity, label: &str) -> String {
match severity {
ErrorSeverity::Error => format!("{label}:").red().to_string(),
ErrorSeverity::Warning => format!("{label}:").yellow().to_string(),
ErrorSeverity::Info => format!("{label}:").cyan().to_string(),
}
}

View file

@ -4,10 +4,10 @@ use indicatif::{ProgressBar, ProgressStyle};
use crate::{
cli::UpdateArgs,
error::PakkerError,
model::{Config, LockFile},
error::{MultiError, PakkerError},
model::{Config, LockFile, UpdateStrategy},
platform::create_platform,
ui_utils::prompt_select,
ui_utils::{prompt_select, prompt_typo_suggestion, prompt_yes_no},
};
pub async fn execute(
@ -33,6 +33,22 @@ pub async fn execute(
platforms.insert("curseforge".to_string(), platform);
}
// Collect all known project identifiers for typo suggestions
let all_slugs: Vec<String> = lockfile
.projects
.iter()
.flat_map(|p| {
let mut ids = Vec::new();
if let Some(ref pakku_id) = p.pakku_id {
ids.push(pakku_id.clone());
}
ids.extend(p.slug.values().cloned());
ids.extend(p.name.values().cloned());
ids.extend(p.aliases.iter().cloned());
ids
})
.collect();
let project_indices: Vec<_> = if args.inputs.is_empty() {
(0..lockfile.projects.len()).collect()
} else {
@ -46,14 +62,29 @@ pub async fn execute(
{
indices.push(idx);
} else {
// Try typo suggestion
if let Ok(Some(suggestion)) = prompt_typo_suggestion(input, &all_slugs)
&& let Some((idx, _)) = lockfile
.projects
.iter()
.enumerate()
.find(|(_, p)| p.matches_input(&suggestion))
{
log::info!("Using suggested project: {suggestion}");
indices.push(idx);
continue;
}
return Err(PakkerError::ProjectNotFound(input.clone()));
}
}
indices
};
// Capture count before consuming the iterator
let total_projects = project_indices.len();
// Create progress bar
let pb = ProgressBar::new(project_indices.len() as u64);
let pb = ProgressBar::new(total_projects as u64);
pb.set_style(
ProgressStyle::default_bar()
.template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}")
@ -61,8 +92,23 @@ pub async fn execute(
.progress_chars("#>-"),
);
let mut skipped_pinned = 0;
let mut update_errors = MultiError::new();
for idx in project_indices {
let old_project = &lockfile.projects[idx];
// Skip projects with UpdateStrategy::None (pinned)
if old_project.update_strategy == UpdateStrategy::None {
pb.println(format!(
" {} - Skipped (update strategy: NONE)",
old_project.get_name()
));
skipped_pinned += 1;
pb.inc(1);
continue;
}
pb.set_message(format!("Updating {}...", old_project.get_name()));
let slug = old_project
@ -87,54 +133,116 @@ pub async fn execute(
}
}
if updated_project.is_none() {
// Failed to fetch update info from any platform
update_errors.push(PakkerError::PlatformApiError(format!(
"Failed to check updates for '{}'",
old_project.get_name()
)));
pb.inc(1);
continue;
}
if let Some(mut updated_project) = updated_project
&& !updated_project.files.is_empty()
&& let Some(old_file) = lockfile.projects[idx].files.first()
{
let new_file = updated_project.files.first().unwrap();
// Clone data needed for comparisons to avoid borrow issues
let new_file_id = updated_project.files.first().unwrap().id.clone();
let new_file_name =
updated_project.files.first().unwrap().file_name.clone();
let old_file_name = old_file.file_name.clone();
let project_name = old_project.get_name();
if new_file.id == old_file.id {
pb.println(format!(
" {} - Already up to date",
old_project.get_name()
));
if new_file_id == old_file.id {
pb.println(format!(" {project_name} - Already up to date"));
} else {
// Interactive version selection if not using --yes flag
if !args.yes && updated_project.files.len() > 1 {
// Interactive confirmation and version selection if not using --yes
// flag
let mut should_update = args.yes || args.all;
let mut selected_idx: Option<usize> = None;
if !args.yes && !args.all {
pb.suspend(|| {
let choices: Vec<String> = updated_project
.files
.iter()
.map(|f| format!("{} ({})", f.file_name, f.id))
.collect();
// First, confirm the update
let prompt_msg = format!(
"Update '{project_name}' from {old_file_name} to \
{new_file_name}?"
);
should_update = prompt_yes_no(&prompt_msg, true).unwrap_or(false);
let choice_refs: Vec<&str> =
choices.iter().map(std::string::String::as_str).collect();
// If confirmed and multiple versions available, offer selection
if should_update && updated_project.files.len() > 1 {
let choices: Vec<String> = updated_project
.files
.iter()
.map(|f| format!("{} ({})", f.file_name, f.id))
.collect();
if let Ok(selected_idx) = prompt_select(
&format!("Select version for {}:", old_project.get_name()),
&choice_refs,
) {
// Move selected file to front
if selected_idx > 0 {
updated_project.files.swap(0, selected_idx);
let choice_refs: Vec<&str> =
choices.iter().map(std::string::String::as_str).collect();
if let Ok(idx) = prompt_select(
&format!("Select version for {project_name}:"),
&choice_refs,
) {
selected_idx = Some(idx);
}
}
});
}
let selected_file = updated_project.files.first().unwrap();
pb.println(format!(
" {} -> {}",
old_file.file_name, selected_file.file_name
));
lockfile.projects[idx] = updated_project;
// Apply file selection outside the closure
if let Some(idx) = selected_idx
&& idx > 0
{
updated_project.files.swap(0, idx);
}
if should_update {
let selected_file = updated_project.files.first().unwrap();
pb.println(format!(
" {} -> {}",
old_file_name, selected_file.file_name
));
lockfile.projects[idx] = updated_project;
} else {
pb.println(format!(" {project_name} - Skipped by user"));
}
}
}
pb.inc(1);
}
pb.finish_with_message("Update complete");
if skipped_pinned > 0 {
pb.finish_with_message(format!(
"Update complete ({skipped_pinned} pinned projects skipped)"
));
} else {
pb.finish_with_message("Update complete");
}
lockfile.save(lockfile_dir)?;
// Report any errors that occurred during updates
if !update_errors.is_empty() {
let error_list = update_errors.errors();
log::warn!(
"{} project(s) encountered errors during update check",
error_list.len()
);
for err in error_list {
log::warn!(" - {err}");
}
// Extend with any additional collected errors and check if we should fail
let all_errors = update_errors.into_errors();
if all_errors.len() == total_projects {
// All projects failed - return error
let mut multi = MultiError::new();
multi.extend(all_errors);
return multi.into_result(());
}
}
Ok(())
}

View file

@ -288,10 +288,9 @@ pub fn detect_vcs_type<P: AsRef<Path>>(path: P) -> VcsType {
.args(["root"])
.current_dir(path)
.output()
&& output.status.success()
{
if output.status.success() {
return VcsType::Jujutsu;
}
return VcsType::Jujutsu;
}
// Check for git
@ -299,10 +298,9 @@ pub fn detect_vcs_type<P: AsRef<Path>>(path: P) -> VcsType {
.args(["rev-parse", "--show-toplevel"])
.current_dir(path)
.output()
&& output.status.success()
{
if output.status.success() {
return VcsType::Git;
}
return VcsType::Git;
}
VcsType::None
@ -333,7 +331,7 @@ pub fn repo_has_uncommitted_changes<P: AsRef<Path>>(path: P) -> Result<bool> {
.current_dir(path)
.output()
.map_err(|e| {
PakkerError::GitError(format!("Failed to run jj status: {}", e))
PakkerError::GitError(format!("Failed to run jj status: {e}"))
})?;
let output_str = String::from_utf8_lossy(&output.stdout);

16
src/http.rs Normal file
View file

@ -0,0 +1,16 @@
use std::time::Duration;
use reqwest::Client;
pub fn create_http_client() -> Client {
Client::builder()
.pool_max_idle_per_host(10)
.pool_idle_timeout(Duration::from_secs(30))
.tcp_keepalive(Duration::from_secs(60))
.tcp_nodelay(true)
.connect_timeout(Duration::from_secs(15))
.timeout(Duration::from_secs(30))
.user_agent("Pakker/0.1.0")
.build()
.expect("Failed to build HTTP client")
}

View file

@ -1,8 +1,15 @@
// Allow pre-existing clippy warnings for functions with many arguments
// and complex types that would require significant refactoring
#![allow(clippy::too_many_arguments)]
#![allow(clippy::type_complexity)]
#![allow(clippy::large_enum_variant)]
mod cli;
mod error;
mod export;
mod fetch;
mod git;
mod http;
mod ipc;
mod model;
mod platform;
@ -17,8 +24,6 @@ use clap::Parser;
use cli::{Cli, Commands};
use error::PakkerError;
use crate::rate_limiter::RateLimiter;
#[tokio::main]
async fn main() -> Result<(), PakkerError> {
let cli = Cli::parse();
@ -42,8 +47,6 @@ async fn main() -> Result<(), PakkerError> {
let lockfile_path = working_dir.join("pakker-lock.json");
let config_path = working_dir.join("pakker.json");
let _rate_limiter = std::sync::Arc::new(RateLimiter::new(None));
match cli.command {
Commands::Init(args) => {
cli::commands::init::execute(args, &lockfile_path, &config_path).await

View file

@ -192,15 +192,29 @@ impl Project {
return true;
}
// Check if all providers have the same latest file name
// (simplified check - in reality would compare semantic versions)
let file_names: Vec<_> = versions_by_provider
// Compare semantic versions extracted from file names
let parse_version = |name: &str| {
// Try to extract version from patterns like "mod-1.0.0.jar" or
// "mod_v1.0.0"
let version_str = name
.rsplit_once('-')
.and_then(|(_, v)| v.strip_suffix(".jar"))
.or_else(|| {
name
.rsplit_once('_')
.and_then(|(_, v)| v.strip_suffix(".jar"))
})
.unwrap_or(name);
semver::Version::parse(version_str).ok()
};
let versions: Vec<_> = versions_by_provider
.values()
.filter_map(|files| files.first().copied())
.filter_map(|files| files.first().copied().and_then(parse_version))
.collect();
// All file names should be the same for versions to match
file_names.windows(2).all(|w| w[0] == w[1])
// All versions should be the same
versions.windows(2).all(|w| w[0] == w[1])
}
/// Check if versions do NOT match across providers.

View file

@ -8,13 +8,19 @@ use std::sync::Arc;
pub use curseforge::CurseForgePlatform;
pub use github::GitHubPlatform;
pub use modrinth::ModrinthPlatform;
use once_cell::sync::Lazy;
pub use traits::PlatformClient;
use crate::{error::Result, rate_limiter::RateLimiter};
use crate::{error::Result, http, rate_limiter::RateLimiter};
static RATE_LIMITER: Lazy<Arc<RateLimiter>> =
Lazy::new(|| Arc::new(RateLimiter::new(None)));
static HTTP_CLIENT: std::sync::LazyLock<Arc<reqwest::Client>> =
std::sync::LazyLock::new(|| Arc::new(http::create_http_client()));
static RATE_LIMITER: std::sync::LazyLock<Arc<RateLimiter>> =
std::sync::LazyLock::new(|| Arc::new(RateLimiter::new(None)));
pub fn get_http_client() -> Arc<reqwest::Client> {
HTTP_CLIENT.clone()
}
pub fn create_platform(
platform: &str,
@ -34,9 +40,21 @@ fn create_client(
api_key: Option<String>,
) -> Result<Box<dyn PlatformClient>> {
match platform {
"modrinth" => Ok(Box::new(ModrinthPlatform::new())),
"curseforge" => Ok(Box::new(CurseForgePlatform::new(api_key))),
"github" => Ok(Box::new(GitHubPlatform::new(api_key))),
"modrinth" => {
Ok(Box::new(ModrinthPlatform::with_client(get_http_client())))
},
"curseforge" => {
Ok(Box::new(CurseForgePlatform::with_client(
get_http_client(),
api_key,
)))
},
"github" => {
Ok(Box::new(GitHubPlatform::with_client(
get_http_client(),
api_key,
)))
},
_ => {
Err(crate::error::PakkerError::ConfigError(format!(
"Unknown platform: {platform}"

View file

@ -1,4 +1,4 @@
use std::collections::HashMap;
use std::{collections::HashMap, sync::Arc};
use async_trait::async_trait;
use reqwest::Client;
@ -12,21 +12,30 @@ use crate::{
};
const CURSEFORGE_API_BASE: &str = "https://api.curseforge.com/v1";
/// CurseForge game version type ID for loader versions (e.g., "fabric",
/// "forge")
const LOADER_VERSION_TYPE_ID: i32 = 68441;
/// CurseForge relation type ID for "required dependency" (mod embeds or
/// requires another mod)
const DEPENDENCY_RELATION_TYPE_REQUIRED: u32 = 3;
pub struct CurseForgePlatform {
client: Client,
client: Arc<Client>,
api_key: Option<String>,
}
impl CurseForgePlatform {
pub fn new(api_key: Option<String>) -> Self {
Self {
client: Client::new(),
client: Arc::new(Client::new()),
api_key,
}
}
pub fn with_client(client: Arc<Client>, api_key: Option<String>) -> Self {
Self { client, api_key }
}
fn get_headers(&self) -> Result<reqwest::header::HeaderMap> {
let mut headers = reqwest::header::HeaderMap::new();
@ -66,11 +75,81 @@ impl CurseForgePlatform {
}
}
/// Determine project side based on `CurseForge` categories.
/// `CurseForge` doesn't have explicit client/server fields like Modrinth,
/// so we infer from category names and IDs.
fn detect_side_from_categories(
categories: &[CurseForgeCategory],
) -> ProjectSide {
// Known client-only category indicators (slugs and partial name matches)
const CLIENT_INDICATORS: &[&str] = &[
"client",
"hud",
"gui",
"cosmetic",
"shader",
"optifine",
"resource-pack",
"texture",
"minimap",
"tooltip",
"inventory",
"quality-of-life", // Often client-side QoL
];
// Known server-only category indicators
const SERVER_INDICATORS: &[&str] = &[
"server-utility",
"bukkit",
"spigot",
"paper",
"admin-tools",
"anti-grief",
"economy",
"permissions",
"chat",
];
let mut client_score = 0;
let mut server_score = 0;
for category in categories {
let slug_lower = category.slug.to_lowercase();
let name_lower = category.name.to_lowercase();
for indicator in CLIENT_INDICATORS {
if slug_lower.contains(indicator) || name_lower.contains(indicator) {
client_score += 1;
}
}
for indicator in SERVER_INDICATORS {
if slug_lower.contains(indicator) || name_lower.contains(indicator) {
server_score += 1;
}
}
}
// Only assign a specific side if there's clear indication
// and not conflicting signals
if client_score > 0 && server_score == 0 {
ProjectSide::Client
} else if server_score > 0 && client_score == 0 {
ProjectSide::Server
} else {
// Default to Both - works on both client and server
ProjectSide::Both
}
}
fn convert_project(&self, cf_project: CurseForgeProject) -> Project {
let pakku_id = generate_pakku_id();
let project_type = Self::map_class_id(cf_project.class_id.unwrap_or(6));
let mut project = Project::new(pakku_id, project_type, ProjectSide::Both);
// Detect side from categories
let side = Self::detect_side_from_categories(&cf_project.categories);
let mut project = Project::new(pakku_id, project_type, side);
project.add_platform(
"curseforge".to_string(),
@ -124,7 +203,7 @@ impl CurseForgePlatform {
required_dependencies: cf_file
.dependencies
.iter()
.filter(|d| d.relation_type == 3)
.filter(|d| d.relation_type == DEPENDENCY_RELATION_TYPE_REQUIRED)
.map(|d| d.mod_id.to_string())
.collect(),
size: cf_file.file_length,
@ -317,11 +396,20 @@ impl PlatformClient for CurseForgePlatform {
// CurseForge API models
#[derive(Debug, Clone, Deserialize, Serialize)]
struct CurseForgeProject {
id: u32,
name: String,
slug: String,
id: u32,
name: String,
slug: String,
#[serde(rename = "classId")]
class_id: Option<u32>,
class_id: Option<u32>,
#[serde(default)]
categories: Vec<CurseForgeCategory>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
struct CurseForgeCategory {
id: u32,
name: String,
slug: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
@ -381,3 +469,112 @@ struct CurseForgeFilesResponse {
struct CurseForgeSearchResponse {
data: Vec<CurseForgeProject>,
}
#[cfg(test)]
mod tests {
use super::*;
fn make_category(id: u32, name: &str, slug: &str) -> CurseForgeCategory {
CurseForgeCategory {
id,
name: name.to_string(),
slug: slug.to_string(),
}
}
#[test]
fn test_detect_side_client_only() {
// HUD mod should be client-only
let categories = vec![
make_category(1, "HUD Mods", "hud"),
make_category(2, "Fabric", "fabric"),
];
let side = CurseForgePlatform::detect_side_from_categories(&categories);
assert_eq!(side, ProjectSide::Client);
}
#[test]
fn test_detect_side_server_only() {
// Server utility should be server-only
let categories = vec![
make_category(1, "Server Utility", "server-utility"),
make_category(2, "Bukkit Plugins", "bukkit"),
];
let side = CurseForgePlatform::detect_side_from_categories(&categories);
assert_eq!(side, ProjectSide::Server);
}
#[test]
fn test_detect_side_both() {
// Generic mod categories should be both
let categories = vec![
make_category(1, "Technology", "technology"),
make_category(2, "Fabric", "fabric"),
];
let side = CurseForgePlatform::detect_side_from_categories(&categories);
assert_eq!(side, ProjectSide::Both);
}
#[test]
fn test_detect_side_conflicting_signals() {
// Mixed categories should default to both
let categories = vec![
make_category(1, "Client HUD", "client-hud"),
make_category(2, "Server Utility", "server-utility"),
];
let side = CurseForgePlatform::detect_side_from_categories(&categories);
assert_eq!(side, ProjectSide::Both);
}
#[test]
fn test_detect_side_empty_categories() {
let categories = vec![];
let side = CurseForgePlatform::detect_side_from_categories(&categories);
assert_eq!(side, ProjectSide::Both);
}
#[test]
fn test_detect_side_gui_client() {
let categories =
vec![make_category(1, "GUI Enhancement", "gui-enhancement")];
let side = CurseForgePlatform::detect_side_from_categories(&categories);
assert_eq!(side, ProjectSide::Client);
}
#[test]
fn test_detect_side_permissions_server() {
let categories = vec![make_category(1, "Permissions", "permissions")];
let side = CurseForgePlatform::detect_side_from_categories(&categories);
assert_eq!(side, ProjectSide::Server);
}
#[test]
fn test_map_class_id() {
assert_eq!(CurseForgePlatform::map_class_id(6), ProjectType::Mod);
assert_eq!(
CurseForgePlatform::map_class_id(12),
ProjectType::ResourcePack
);
assert_eq!(
CurseForgePlatform::map_class_id(6945),
ProjectType::DataPack
);
assert_eq!(CurseForgePlatform::map_class_id(6552), ProjectType::Shader);
assert_eq!(CurseForgePlatform::map_class_id(17), ProjectType::World);
assert_eq!(CurseForgePlatform::map_class_id(9999), ProjectType::Mod); // Unknown
}
#[test]
fn test_map_release_type() {
assert_eq!(
CurseForgePlatform::map_release_type(1),
ReleaseType::Release
);
assert_eq!(CurseForgePlatform::map_release_type(2), ReleaseType::Beta);
assert_eq!(CurseForgePlatform::map_release_type(3), ReleaseType::Alpha);
assert_eq!(
CurseForgePlatform::map_release_type(99),
ReleaseType::Release
); // Unknown
}
}

View file

@ -1,4 +1,4 @@
use std::collections::HashMap;
use std::{collections::HashMap, sync::Arc};
use async_trait::async_trait;
use regex::Regex;
@ -20,9 +20,9 @@ pub struct GitHubPlatform {
}
impl GitHubPlatform {
pub fn new(token: Option<String>) -> Self {
pub fn with_client(client: Arc<Client>, token: Option<String>) -> Self {
Self {
client: Client::new(),
client: (*client).clone(),
token,
}
}

View file

@ -1,4 +1,4 @@
use std::collections::HashMap;
use std::{collections::HashMap, sync::Arc};
use async_trait::async_trait;
use reqwest::Client;
@ -14,16 +14,76 @@ use crate::{
const MODRINTH_API_BASE: &str = "https://api.modrinth.com/v2";
pub struct ModrinthPlatform {
client: Client,
client: Arc<Client>,
}
impl ModrinthPlatform {
pub fn new() -> Self {
Self {
client: Client::new(),
client: Arc::new(Client::new()),
}
}
pub fn with_client(client: Arc<Client>) -> Self {
Self { client }
}
async fn request_project_url(&self, url: &str) -> Result<Project> {
let response = self.client.get(url).send().await?;
if !response.status().is_success() {
return Err(PakkerError::ProjectNotFound(url.to_string()));
}
let mr_project: ModrinthProject = response.json().await?;
Ok(self.convert_project(mr_project))
}
async fn request_project_files_url(
&self,
url: &str,
) -> Result<Vec<ProjectFile>> {
let response = self.client.get(url).send().await?;
if !response.status().is_success() {
return Err(PakkerError::ProjectNotFound(url.to_string()));
}
let mr_versions: Vec<ModrinthVersion> = response.json().await?;
let project_id = url
.split('/')
.nth(4)
.ok_or_else(|| {
PakkerError::InvalidResponse(
"Cannot parse project ID from URL".to_string(),
)
})?
.to_string();
Ok(
mr_versions
.into_iter()
.map(|v| self.convert_version(v, &project_id))
.collect(),
)
}
async fn lookup_by_hash_url(&self, url: &str) -> Result<Option<Project>> {
let response = self.client.get(url).send().await?;
if response.status().as_u16() == 404 {
return Ok(None);
}
if !response.status().is_success() {
return Err(PakkerError::PlatformApiError(format!(
"Modrinth API error: {}",
response.status()
)));
}
let version_data: serde_json::Value = response.json().await?;
let project_id = version_data["project_id"].as_str().ok_or_else(|| {
PakkerError::InvalidResponse("Missing project_id".to_string())
})?;
self
.request_project_with_files(project_id, &[], &[])
.await
.map(Some)
}
fn map_project_type(type_str: &str) -> ProjectType {
match type_str {
"mod" => ProjectType::Mod,
@ -123,15 +183,7 @@ impl PlatformClient for ModrinthPlatform {
_loaders: &[String],
) -> Result<Project> {
let url = format!("{MODRINTH_API_BASE}/project/{identifier}");
let response = self.client.get(&url).send().await?;
if !response.status().is_success() {
return Err(PakkerError::ProjectNotFound(identifier.to_string()));
}
let mr_project: ModrinthProject = response.json().await?;
Ok(self.convert_project(mr_project))
self.request_project_url(&url).await
}
async fn request_project_files(
@ -170,20 +222,7 @@ impl PlatformClient for ModrinthPlatform {
url.push_str(&params.join("&"));
}
let response = self.client.get(&url).send().await?;
if !response.status().is_success() {
return Err(PakkerError::ProjectNotFound(project_id.to_string()));
}
let mr_versions: Vec<ModrinthVersion> = response.json().await?;
Ok(
mr_versions
.into_iter()
.map(|v| self.convert_version(v, project_id))
.collect(),
)
self.request_project_files_url(&url).await
}
async fn request_project_with_files(
@ -213,30 +252,7 @@ impl PlatformClient for ModrinthPlatform {
async fn lookup_by_hash(&self, hash: &str) -> Result<Option<Project>> {
// Modrinth uses SHA-1 hash for file lookups
let url = format!("{MODRINTH_API_BASE}/version_file/{hash}");
let response = self.client.get(&url).send().await?;
if response.status().as_u16() == 404 {
return Ok(None);
}
if !response.status().is_success() {
return Err(PakkerError::PlatformApiError(format!(
"Modrinth API error: {}",
response.status()
)));
}
let version_data: serde_json::Value = response.json().await?;
let project_id = version_data["project_id"].as_str().ok_or_else(|| {
PakkerError::InvalidResponse("Missing project_id".to_string())
})?;
self
.request_project_with_files(project_id, &[], &[])
.await
.map(Some)
self.lookup_by_hash_url(&url).await
}
}
@ -280,3 +296,128 @@ struct ModrinthDependency {
project_id: Option<String>,
dependency_type: String,
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use reqwest::Client;
use super::*;
impl ModrinthPlatform {
fn with_raw_client(client: Client) -> Self {
Self {
client: Arc::new(client),
}
}
}
async fn create_platform_with_mock()
-> (ModrinthPlatform, mockito::ServerGuard) {
let server = mockito::Server::new_async().await;
let client = Client::new();
let platform = ModrinthPlatform::with_raw_client(client);
(platform, server)
}
#[tokio::test]
async fn test_request_project_success() {
let (platform, mut server) = create_platform_with_mock().await;
let url = format!("{}/project/test-mod", server.url());
let _mock = server
.mock("GET", "/project/test-mod")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{
"id": "abc123",
"slug": "test-mod",
"title": "Test Mod",
"project_type": "mod",
"client_side": "required",
"server_side": "required"
}"#,
)
.create();
let result = platform.request_project_url(&url).await;
assert!(result.is_ok());
let project = result.unwrap();
assert!(project.get_platform_id("modrinth").is_some());
}
#[tokio::test]
async fn test_request_project_not_found() {
let (platform, mut server) = create_platform_with_mock().await;
let url = format!("{}/project/nonexistent", server.url());
let _mock = server
.mock("GET", "/project/nonexistent")
.with_status(404)
.create();
let result = platform.request_project_url(&url).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_request_project_files() {
let (platform, mut server) = create_platform_with_mock().await;
let url = format!("{}/project/abc123/version", server.url());
let _mock = server
.mock("GET", "/project/abc123/version")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"[
{
"id": "v1",
"project_id": "abc123",
"name": "Test Mod v1.0.0",
"version_number": "1.0.0",
"game_versions": ["1.20.1"],
"version_type": "release",
"loaders": ["fabric"],
"date_published": "2024-01-01T00:00:00Z",
"files": [{
"hashes": {"sha1": "abc123def456"},
"url": "https://example.com/mod.jar",
"filename": "test-mod-1.0.0.jar",
"primary": true,
"size": 1024
}],
"dependencies": []
}
]"#,
)
.create();
let result = platform.request_project_files_url(&url).await;
assert!(result.is_ok());
let files = result.unwrap();
assert_eq!(files.len(), 1);
assert_eq!(files[0].file_name, "test-mod-1.0.0.jar");
}
#[tokio::test]
async fn test_lookup_by_hash_not_found() {
let (platform, mut server) = create_platform_with_mock().await;
let url = format!("{}/version_file/unknownhash123", server.url());
let _mock = server
.mock("GET", "/version_file/unknownhash123")
.with_status(404)
.create();
let result = platform.lookup_by_hash_url(&url).await;
assert!(result.is_ok());
assert!(result.unwrap().is_none());
}
}

View file

@ -80,13 +80,13 @@ impl RateLimiter {
platform_requests
.retain(|t| now.duration_since(*t) < Duration::from_secs(60));
if platform_requests.len() >= burst as usize {
if let Some(oldest) = platform_requests.first() {
let wait_time = interval.saturating_sub(now.duration_since(*oldest));
if wait_time > Duration::ZERO {
drop(inner);
tokio::time::sleep(wait_time).await;
}
if platform_requests.len() >= burst as usize
&& let Some(oldest) = platform_requests.first()
{
let wait_time = interval.saturating_sub(now.duration_since(*oldest));
if wait_time > Duration::ZERO {
drop(inner);
tokio::time::sleep(wait_time).await;
}
}

View file

@ -1,4 +1,4 @@
use rand::Rng;
use rand::RngExt;
const CHARSET: &[u8] =
b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";