Compare commits
27 commits
d40cbb74fc
...
7ee9ee1159
| Author | SHA1 | Date | |
|---|---|---|---|
|
7ee9ee1159 |
|||
|
0cc72e9916 |
|||
|
4fc05e71e7 |
|||
|
fa5befff3b |
|||
|
b71b2862c9 |
|||
|
27160a1eda |
|||
|
5385c0f4ed |
|||
|
1db1d4d6d2 |
|||
|
4079485179 |
|||
|
1ecf0fae00 |
|||
|
79a82d6ab8 |
|||
|
cce952698a |
|||
|
788bdb0f1b |
|||
|
977beccf01 |
|||
|
4814ad90bb |
|||
|
3e6f528056 |
|||
|
f5d735efb8 |
|||
|
4b353733ff |
|||
|
8464ad3786 |
|||
|
c8baf4a369 |
|||
|
3414b9f1a4 |
|||
|
92c3215e67 |
|||
|
7187b3289f |
|||
|
63f09b359d |
|||
|
6648de9971 |
|||
|
a50657bad5 |
|||
|
0eff42568e |
38 changed files with 3595 additions and 594 deletions
373
Cargo.lock
generated
373
Cargo.lock
generated
|
|
@ -16,7 +16,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cipher",
|
"cipher",
|
||||||
"cpufeatures",
|
"cpufeatures 0.2.17",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -80,9 +80,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.100"
|
version = "1.0.101"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "assert-json-diff"
|
name = "assert-json-diff"
|
||||||
|
|
@ -211,6 +211,17 @@ version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
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]]
|
[[package]]
|
||||||
name = "cipher"
|
name = "cipher"
|
||||||
version = "0.4.4"
|
version = "0.4.4"
|
||||||
|
|
@ -223,9 +234,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.5.54"
|
version = "4.5.58"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394"
|
checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap_builder",
|
"clap_builder",
|
||||||
"clap_derive",
|
"clap_derive",
|
||||||
|
|
@ -233,9 +244,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_builder"
|
name = "clap_builder"
|
||||||
version = "4.5.54"
|
version = "4.5.58"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00"
|
checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anstream",
|
"anstream",
|
||||||
"anstyle",
|
"anstyle",
|
||||||
|
|
@ -245,9 +256,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_derive"
|
name = "clap_derive"
|
||||||
version = "4.5.49"
|
version = "4.5.55"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
|
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"heck",
|
"heck",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
|
|
@ -257,9 +268,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_lex"
|
name = "clap_lex"
|
||||||
version = "0.7.6"
|
version = "1.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
|
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cmake"
|
name = "cmake"
|
||||||
|
|
@ -321,9 +332,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "constant_time_eq"
|
name = "constant_time_eq"
|
||||||
version = "0.3.1"
|
version = "0.4.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
|
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
|
|
@ -360,6 +371,15 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc"
|
name = "crc"
|
||||||
version = "3.3.0"
|
version = "3.3.0"
|
||||||
|
|
@ -498,9 +518,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "env_filter"
|
name = "env_filter"
|
||||||
version = "0.1.4"
|
version = "1.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2"
|
checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"regex",
|
"regex",
|
||||||
|
|
@ -508,9 +528,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "env_logger"
|
name = "env_logger"
|
||||||
version = "0.11.8"
|
version = "0.11.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f"
|
checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anstream",
|
"anstream",
|
||||||
"anstyle",
|
"anstyle",
|
||||||
|
|
@ -564,6 +584,12 @@ version = "1.0.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foldhash"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "form_urlencoded"
|
name = "form_urlencoded"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
|
|
@ -706,10 +732,26 @@ dependencies = [
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "git2"
|
name = "getrandom"
|
||||||
version = "0.20.3"
|
version = "0.4.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
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 = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
"libc",
|
"libc",
|
||||||
|
|
@ -720,6 +762,12 @@ dependencies = [
|
||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "glob"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "h2"
|
name = "h2"
|
||||||
version = "0.4.13"
|
version = "0.4.13"
|
||||||
|
|
@ -739,6 +787,15 @@ dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.15.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||||
|
dependencies = [
|
||||||
|
"foldhash",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.16.1"
|
version = "0.16.1"
|
||||||
|
|
@ -951,6 +1008,12 @@ dependencies = [
|
||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "id-arena"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
|
|
@ -979,7 +1042,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
|
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"equivalent",
|
"equivalent",
|
||||||
"hashbrown",
|
"hashbrown 0.16.1",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1108,6 +1173,12 @@ dependencies = [
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "leb128fmt"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libbz2-rs-sys"
|
name = "libbz2-rs-sys"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
|
|
@ -1116,9 +1187,9 @@ checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.180"
|
version = "0.2.181"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
|
checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libgit2-sys"
|
name = "libgit2-sys"
|
||||||
|
|
@ -1263,9 +1334,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mockito"
|
name = "mockito"
|
||||||
version = "1.7.1"
|
version = "1.7.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7e0603425789b4a70fcc4ac4f5a46a566c116ee3e2a6b768dc623f7719c611de"
|
checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"assert-json-diff",
|
"assert-json-diff",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
|
@ -1278,7 +1349,7 @@ dependencies = [
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"log",
|
"log",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"rand",
|
"rand 0.9.2",
|
||||||
"regex",
|
"regex",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
|
|
@ -1288,9 +1359,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
|
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
|
|
@ -1340,16 +1411,17 @@ dependencies = [
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"futures",
|
"futures",
|
||||||
"git2",
|
"git2",
|
||||||
|
"glob",
|
||||||
"indicatif",
|
"indicatif",
|
||||||
"keyring",
|
"keyring",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"md-5",
|
"md-5",
|
||||||
"mockito",
|
"mockito",
|
||||||
"once_cell",
|
"rand 0.10.0",
|
||||||
"rand",
|
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha1",
|
"sha1",
|
||||||
|
|
@ -1357,7 +1429,7 @@ dependencies = [
|
||||||
"strsim",
|
"strsim",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"textwrap",
|
"textwrap",
|
||||||
"thiserror 2.0.17",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
"yansi",
|
"yansi",
|
||||||
|
|
@ -1453,9 +1525,9 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ppmd-rust"
|
name = "ppmd-rust"
|
||||||
version = "1.3.0"
|
version = "1.4.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d558c559f0450f16f2a27a1f017ef38468c1090c9ce63c8e51366232d53717b4"
|
checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ppv-lite86"
|
name = "ppv-lite86"
|
||||||
|
|
@ -1466,6 +1538,16 @@ dependencies = [
|
||||||
"zerocopy",
|
"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]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.105"
|
version = "1.0.105"
|
||||||
|
|
@ -1489,7 +1571,7 @@ dependencies = [
|
||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
"rustls",
|
"rustls",
|
||||||
"socket2",
|
"socket2",
|
||||||
"thiserror 2.0.17",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"web-time",
|
"web-time",
|
||||||
|
|
@ -1505,13 +1587,13 @@ dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
"lru-slab",
|
"lru-slab",
|
||||||
"rand",
|
"rand 0.9.2",
|
||||||
"ring",
|
"ring",
|
||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
"rustls",
|
"rustls",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"slab",
|
"slab",
|
||||||
"thiserror 2.0.17",
|
"thiserror 2.0.18",
|
||||||
"tinyvec",
|
"tinyvec",
|
||||||
"tracing",
|
"tracing",
|
||||||
"web-time",
|
"web-time",
|
||||||
|
|
@ -1553,7 +1635,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rand_chacha",
|
"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]]
|
[[package]]
|
||||||
|
|
@ -1563,7 +1656,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ppv-lite86",
|
"ppv-lite86",
|
||||||
"rand_core",
|
"rand_core 0.9.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1575,6 +1668,12 @@ dependencies = [
|
||||||
"getrandom 0.3.4",
|
"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]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
|
|
@ -1586,9 +1685,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regex"
|
name = "regex"
|
||||||
version = "1.12.2"
|
version = "1.12.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4"
|
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aho-corasick",
|
"aho-corasick",
|
||||||
"memchr",
|
"memchr",
|
||||||
|
|
@ -1615,9 +1714,9 @@ checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.13.1"
|
version = "0.13.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62"
|
checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
|
@ -1820,6 +1919,12 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "semver"
|
||||||
|
version = "1.0.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
version = "1.0.228"
|
version = "1.0.228"
|
||||||
|
|
@ -1882,7 +1987,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures 0.2.17",
|
||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -1893,7 +1998,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures 0.2.17",
|
||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -2031,12 +2136,12 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tempfile"
|
name = "tempfile"
|
||||||
version = "3.24.0"
|
version = "3.25.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c"
|
checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.4.1",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
|
|
@ -2064,11 +2169,11 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.17"
|
version = "2.0.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
|
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror-impl 2.0.17",
|
"thiserror-impl 2.0.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2084,9 +2189,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror-impl"
|
name = "thiserror-impl"
|
||||||
version = "2.0.17"
|
version = "2.0.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
|
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
|
@ -2095,22 +2200,23 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "time"
|
name = "time"
|
||||||
version = "0.3.44"
|
version = "0.3.47"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d"
|
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deranged",
|
"deranged",
|
||||||
|
"js-sys",
|
||||||
"num-conv",
|
"num-conv",
|
||||||
"powerfmt",
|
"powerfmt",
|
||||||
"serde",
|
"serde_core",
|
||||||
"time-core",
|
"time-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "time-core"
|
name = "time-core"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b"
|
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinystr"
|
name = "tinystr"
|
||||||
|
|
@ -2258,6 +2364,12 @@ version = "0.2.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typed-path"
|
||||||
|
version = "0.12.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.19.0"
|
version = "1.19.0"
|
||||||
|
|
@ -2288,6 +2400,12 @@ version = "0.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-xid"
|
||||||
|
version = "0.2.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unit-prefix"
|
name = "unit-prefix"
|
||||||
version = "0.5.2"
|
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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
|
checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
|
||||||
dependencies = [
|
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]]
|
[[package]]
|
||||||
|
|
@ -2428,6 +2555,40 @@ dependencies = [
|
||||||
"unicode-ident",
|
"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]]
|
[[package]]
|
||||||
name = "web-sys"
|
name = "web-sys"
|
||||||
version = "0.3.83"
|
version = "0.3.83"
|
||||||
|
|
@ -2760,6 +2921,94 @@ version = "0.46.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
|
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]]
|
[[package]]
|
||||||
name = "writeable"
|
name = "writeable"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
|
|
@ -2891,9 +3140,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zip"
|
name = "zip"
|
||||||
version = "7.1.0"
|
version = "7.4.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9013f1222db8a6d680f13a7ccdc60a781199cd09c2fa4eff58e728bb181757fc"
|
checksum = "cc12baa6db2b15a140161ce53d72209dacea594230798c24774139b54ecaa980"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"bzip2",
|
"bzip2",
|
||||||
|
|
@ -2901,8 +3150,7 @@ dependencies = [
|
||||||
"crc32fast",
|
"crc32fast",
|
||||||
"deflate64",
|
"deflate64",
|
||||||
"flate2",
|
"flate2",
|
||||||
"generic-array",
|
"getrandom 0.4.1",
|
||||||
"getrandom 0.3.4",
|
|
||||||
"hmac",
|
"hmac",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"lzma-rust2",
|
"lzma-rust2",
|
||||||
|
|
@ -2911,6 +3159,7 @@ dependencies = [
|
||||||
"ppmd-rust",
|
"ppmd-rust",
|
||||||
"sha1",
|
"sha1",
|
||||||
"time",
|
"time",
|
||||||
|
"typed-path",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
"zopfli",
|
"zopfli",
|
||||||
"zstd",
|
"zstd",
|
||||||
|
|
|
||||||
50
Cargo.toml
50
Cargo.toml
|
|
@ -1,47 +1,47 @@
|
||||||
[package]
|
[package]
|
||||||
name = "pakker"
|
name = "pakker"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
authors = [ "NotAShelf <raf@notashelf.dev" ]
|
authors = [ "NotAShelf <raf@notashelf.dev>" ]
|
||||||
|
rust-version = "1.91.0"
|
||||||
|
readme = true
|
||||||
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1.0.100"
|
anyhow = "1.0.101"
|
||||||
async-trait = "0.1.89"
|
async-trait = "0.1.89"
|
||||||
clap = { version = "4.5.54", features = [ "derive" ] }
|
clap = { version = "4.5.58", features = [ "derive" ] }
|
||||||
comfy-table = "7.1"
|
comfy-table = "7.2.2"
|
||||||
dialoguer = "0.12.0"
|
dialoguer = "0.12.0"
|
||||||
env_logger = "0.11.8"
|
env_logger = "0.11.9"
|
||||||
futures = "0.3.31"
|
futures = "0.3.31"
|
||||||
git2 = "0.20.3"
|
git2 = "0.20.4"
|
||||||
|
glob = "0.3.3"
|
||||||
indicatif = "0.18.3"
|
indicatif = "0.18.3"
|
||||||
keyring = "3.6.3"
|
keyring = "3.6.3"
|
||||||
libc = "0.2.180"
|
libc = "0.2.181"
|
||||||
log = "0.4.29"
|
log = "0.4.29"
|
||||||
md-5 = "0.10.6"
|
md-5 = "0.10.6"
|
||||||
once_cell = "1.20"
|
rand = "0.10.0"
|
||||||
rand = "0.9.2"
|
regex = "1.12.3"
|
||||||
regex = "1.12"
|
reqwest = { version = "0.13.2", features = [ "json" ] }
|
||||||
reqwest = { version = "0.13.1", features = [ "json" ] }
|
semver = "1.0.27"
|
||||||
serde = { version = "1.0.228", features = [ "derive" ] }
|
serde = { version = "1.0.228", features = [ "derive" ] }
|
||||||
serde_json = "1.0.149"
|
serde_json = "1.0.149"
|
||||||
sha1 = "0.10.6"
|
sha1 = "0.10.6"
|
||||||
sha2 = "0.10.0"
|
sha2 = "0.10.9"
|
||||||
strsim = "0.11.1"
|
strsim = "0.11.1"
|
||||||
tempfile = "3.24.0"
|
tempfile = "3.25.0"
|
||||||
textwrap = "0.16"
|
textwrap = "0.16.2"
|
||||||
thiserror = "2.0.17"
|
thiserror = "2.0.18"
|
||||||
tokio = { version = "1.49.0", features = [ "full" ] }
|
tokio = { version = "1.49.0", features = [ "full" ] }
|
||||||
walkdir = "2.5.0"
|
walkdir = "2.5.0"
|
||||||
yansi = "1.0.1"
|
yansi = "1.0.1"
|
||||||
zip = "7.1.0"
|
zip = "7.4.0"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
mockito = "1.7.1"
|
mockito = "1.7.2"
|
||||||
tempfile = "3.24.0"
|
tempfile = "3.25.0"
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "pakker"
|
|
||||||
path = "src/main.rs"
|
|
||||||
|
|
||||||
# Optimize crypto stuff. Building them with optimizations makes that build script
|
# Optimize crypto stuff. Building them with optimizations makes that build script
|
||||||
# run ~5x faster, more than offsetting the additional build time added to the
|
# run ~5x faster, more than offsetting the additional build time added to the
|
||||||
|
|
|
||||||
16
docs/README.md
Normal file
16
docs/README.md
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
# Pakker
|
||||||
|
|
||||||
|
A fast, reliable multiplatform modpack manager for Minecraft, written in Rust.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Pakker is a command-line tool for managing Minecraft modpacks across multiple
|
||||||
|
platforms including CurseForge, Modrinth, and GitHub. It provides a streamlined
|
||||||
|
workflow for creating, maintaining, and distributing modpacks with support for
|
||||||
|
automated dependency resolution, version management, and multi-platform exports.
|
||||||
|
|
||||||
|
## Acknowledgments
|
||||||
|
|
||||||
|
Pakker is _greatly_ inspired by [Pakku](https://github.com/juraj-hrivnak/Pakku),
|
||||||
|
bringing similar functionality with improved performance and additional features
|
||||||
|
through Rust implementation.
|
||||||
6
flake.lock
generated
6
flake.lock
generated
|
|
@ -2,11 +2,11 @@
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1769461804,
|
"lastModified": 1770197578,
|
||||||
"narHash": "sha256-msG8SU5WsBUfVVa/9RPLaymvi5bI8edTavbIq3vRlhI=",
|
"narHash": "sha256-AYqlWrX09+HvGs8zM6ebZ1pwUqjkfpnv8mewYwAo+iM=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "bfc1b8a4574108ceef22f02bafcf6611380c100d",
|
"rev": "00c21e4c93d963c50d4c0c89bfa84ed6e0694df2",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
|
||||||
50
src/cli.rs
50
src/cli.rs
|
|
@ -97,20 +97,20 @@ pub struct InitArgs {
|
||||||
pub version: Option<String>,
|
pub version: Option<String>,
|
||||||
|
|
||||||
/// Target platform
|
/// Target platform
|
||||||
#[clap(short, long, default_value = "multiplatform")]
|
#[clap(short, long)]
|
||||||
pub target: String,
|
pub target: Option<String>,
|
||||||
|
|
||||||
/// Minecraft version
|
/// Minecraft versions (space-separated)
|
||||||
#[clap(short, long, default_value = "1.20.1")]
|
#[clap(short, long = "mc-versions", value_delimiter = ' ', num_args = 1..)]
|
||||||
pub mc_version: String,
|
pub mc_versions: Option<Vec<String>>,
|
||||||
|
|
||||||
/// Mod loader
|
/// Mod loaders (format: name=version, can be specified multiple times)
|
||||||
#[clap(short, long, default_value = "fabric")]
|
#[clap(short, long = "loaders", value_delimiter = ',')]
|
||||||
pub loader: String,
|
pub loaders: Option<Vec<String>>,
|
||||||
|
|
||||||
/// Mod loader version
|
/// Skip interactive prompts (use defaults)
|
||||||
#[clap(short = 'v', long, default_value = "latest")]
|
#[clap(short, long)]
|
||||||
pub loader_version: String,
|
pub yes: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Args)]
|
#[derive(Args)]
|
||||||
|
|
@ -118,6 +118,10 @@ pub struct ImportArgs {
|
||||||
/// Path to modpack file
|
/// Path to modpack file
|
||||||
pub file: String,
|
pub file: String,
|
||||||
|
|
||||||
|
/// Resolve dependencies
|
||||||
|
#[clap(short = 'D', long = "deps")]
|
||||||
|
pub deps: bool,
|
||||||
|
|
||||||
/// Skip confirmation prompts
|
/// Skip confirmation prompts
|
||||||
#[clap(short, long)]
|
#[clap(short, long)]
|
||||||
pub yes: bool,
|
pub yes: bool,
|
||||||
|
|
@ -203,9 +207,17 @@ pub struct RmArgs {
|
||||||
#[clap(required = true)]
|
#[clap(required = true)]
|
||||||
pub inputs: Vec<String>,
|
pub inputs: Vec<String>,
|
||||||
|
|
||||||
|
/// Remove all projects
|
||||||
|
#[clap(short = 'a', long)]
|
||||||
|
pub all: bool,
|
||||||
|
|
||||||
/// Skip confirmation prompt
|
/// Skip confirmation prompt
|
||||||
#[clap(short, long)]
|
#[clap(short, long)]
|
||||||
pub yes: bool,
|
pub yes: bool,
|
||||||
|
|
||||||
|
/// Skip removing dependent projects
|
||||||
|
#[clap(short = 'D', long = "no-deps")]
|
||||||
|
pub no_deps: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Args)]
|
#[derive(Args)]
|
||||||
|
|
@ -214,6 +226,10 @@ pub struct UpdateArgs {
|
||||||
#[arg(value_name = "PROJECT")]
|
#[arg(value_name = "PROJECT")]
|
||||||
pub inputs: Vec<String>,
|
pub inputs: Vec<String>,
|
||||||
|
|
||||||
|
/// Update all projects
|
||||||
|
#[arg(short, long)]
|
||||||
|
pub all: bool,
|
||||||
|
|
||||||
/// Skip confirmation prompts
|
/// Skip confirmation prompts
|
||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
pub yes: bool,
|
pub yes: bool,
|
||||||
|
|
@ -336,7 +352,7 @@ pub struct SyncArgs {
|
||||||
#[clap(short = 'R', long)]
|
#[clap(short = 'R', long)]
|
||||||
pub removals: bool,
|
pub removals: bool,
|
||||||
|
|
||||||
/// Sync updates only
|
/// Sync updates only (apply pending updates)
|
||||||
#[clap(short = 'U', long)]
|
#[clap(short = 'U', long)]
|
||||||
pub updates: bool,
|
pub updates: bool,
|
||||||
}
|
}
|
||||||
|
|
@ -356,6 +372,16 @@ pub struct ExportArgs {
|
||||||
/// Default is Pakker layout (exports/...)
|
/// Default is Pakker layout (exports/...)
|
||||||
#[clap(long)]
|
#[clap(long)]
|
||||||
pub pakker_layout: bool,
|
pub pakker_layout: bool,
|
||||||
|
|
||||||
|
/// Show file IO errors during export
|
||||||
|
#[clap(long = "show-io-errors")]
|
||||||
|
pub show_io_errors: bool,
|
||||||
|
|
||||||
|
/// Export modpack without server content
|
||||||
|
/// Modrinth: exclude server-overrides and SERVER mods
|
||||||
|
/// `ServerPack`: skip export
|
||||||
|
#[clap(long = "no-server")]
|
||||||
|
pub no_server: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Args)]
|
#[derive(Args)]
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
error::{PakkerError, Result},
|
error::{MultiError, PakkerError, Result},
|
||||||
model::{Config, LockFile, Project},
|
model::{Config, LockFile, Project},
|
||||||
platform::create_platform,
|
platform::create_platform,
|
||||||
resolver::DependencyResolver,
|
resolver::DependencyResolver,
|
||||||
|
|
@ -139,10 +139,19 @@ pub async fn execute(
|
||||||
let platforms = create_all_platforms()?;
|
let platforms = create_all_platforms()?;
|
||||||
|
|
||||||
let mut new_projects = Vec::new();
|
let mut new_projects = Vec::new();
|
||||||
|
let mut errors = MultiError::new();
|
||||||
|
|
||||||
// Resolve each input
|
// Resolve each input
|
||||||
for input in &args.inputs {
|
for input in &args.inputs {
|
||||||
let project = resolve_input(input, &platforms, &lockfile).await?;
|
let project = match resolve_input(input, &platforms, &lockfile).await {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
// Collect error but continue with other inputs
|
||||||
|
log::warn!("Failed to resolve '{input}': {e}");
|
||||||
|
errors.push(e);
|
||||||
|
continue;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// Check if already exists by matching platform IDs (not pakku_id which is
|
// Check if already exists by matching platform IDs (not pakku_id which is
|
||||||
// random)
|
// random)
|
||||||
|
|
@ -174,6 +183,15 @@ pub async fn execute(
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prompt for confirmation unless --yes flag is set
|
||||||
|
if !args.yes {
|
||||||
|
let prompt_msg = format!("Add project '{}'?", project.get_name());
|
||||||
|
if !crate::ui_utils::prompt_yes_no(&prompt_msg, true)? {
|
||||||
|
log::info!("Skipping project: {}", project.get_name());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
new_projects.push(project);
|
new_projects.push(project);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -213,6 +231,9 @@ pub async fn execute(
|
||||||
new_projects = all_new_projects;
|
new_projects = all_new_projects;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track count before moving
|
||||||
|
let added_count = new_projects.len();
|
||||||
|
|
||||||
// Add projects to lockfile (updates already handled above)
|
// Add projects to lockfile (updates already handled above)
|
||||||
for project in new_projects {
|
for project in new_projects {
|
||||||
lockfile.add_project(project);
|
lockfile.add_project(project);
|
||||||
|
|
@ -221,7 +242,20 @@ pub async fn execute(
|
||||||
// Save lockfile
|
// Save lockfile
|
||||||
lockfile.save(lockfile_dir)?;
|
lockfile.save(lockfile_dir)?;
|
||||||
|
|
||||||
log::info!("Successfully added {} project(s)", args.inputs.len());
|
log::info!("Successfully added {added_count} project(s)");
|
||||||
|
|
||||||
|
// Return aggregated errors if any occurred
|
||||||
|
if !errors.is_empty() {
|
||||||
|
let error_count = errors.len();
|
||||||
|
log::warn!(
|
||||||
|
"{error_count} project(s) failed to resolve (see warnings above)"
|
||||||
|
);
|
||||||
|
// Return success if at least some projects were added, otherwise return
|
||||||
|
// errors
|
||||||
|
if added_count == 0 && args.inputs.len() == error_count {
|
||||||
|
return errors.into_result(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,11 @@ use std::path::Path;
|
||||||
|
|
||||||
use yansi::Paint;
|
use yansi::Paint;
|
||||||
|
|
||||||
use crate::{error::Result, model::config::Config};
|
use crate::{
|
||||||
|
error::Result,
|
||||||
|
model::config::Config,
|
||||||
|
ui_utils::prompt_input_optional,
|
||||||
|
};
|
||||||
|
|
||||||
pub fn execute(
|
pub fn execute(
|
||||||
config_path: &Path,
|
config_path: &Path,
|
||||||
|
|
@ -85,11 +89,50 @@ pub fn execute(
|
||||||
}
|
}
|
||||||
|
|
||||||
if !changed {
|
if !changed {
|
||||||
eprintln!(
|
// Interactive mode: prompt for values if none were specified
|
||||||
|
println!(
|
||||||
"{}",
|
"{}",
|
||||||
"No changes specified. Use --help for options.".yellow()
|
"No changes specified. Enter values interactively (press Enter to skip):"
|
||||||
|
.yellow()
|
||||||
);
|
);
|
||||||
return Ok(());
|
println!();
|
||||||
|
|
||||||
|
// Prompt for each configurable field
|
||||||
|
if let Ok(Some(new_name)) = prompt_input_optional(" Name") {
|
||||||
|
config.name = new_name.clone();
|
||||||
|
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();
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
format!(" ✓ 'version' set to '{new_version}'").green()
|
||||||
|
);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(Some(new_description)) = prompt_input_optional(" Description") {
|
||||||
|
config.description = Some(new_description.clone());
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
format!(" ✓ 'description' set to '{new_description}'").green()
|
||||||
|
);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(Some(new_author)) = prompt_input_optional(" Author") {
|
||||||
|
config.author = Some(new_author.clone());
|
||||||
|
println!("{}", format!(" ✓ 'author' set to '{new_author}'").green());
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !changed {
|
||||||
|
println!();
|
||||||
|
println!("{}", "No changes made.".dim());
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config::save expects directory path, not file path
|
// Config::save expects directory path, not file path
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,22 @@ pub async fn execute(
|
||||||
log::info!("Exporting all profiles");
|
log::info!("Exporting all profiles");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle --no-server flag
|
||||||
|
if args.no_server {
|
||||||
|
log::info!("Server content will be excluded from export");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle --show-io-errors flag
|
||||||
|
let show_io_errors = args.show_io_errors;
|
||||||
|
if show_io_errors {
|
||||||
|
log::info!("IO errors will be shown during export");
|
||||||
|
}
|
||||||
|
|
||||||
let lockfile_dir = lockfile_path.parent().unwrap_or(Path::new("."));
|
let lockfile_dir = lockfile_path.parent().unwrap_or(Path::new("."));
|
||||||
let config_dir = config_path.parent().unwrap_or(Path::new("."));
|
let config_dir = config_path.parent().unwrap_or(Path::new("."));
|
||||||
|
|
||||||
// IPC coordination - prevent concurrent operations on the same modpack
|
// 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);
|
let ipc_timeout = std::time::Duration::from_secs(60);
|
||||||
|
|
||||||
// Check for conflicting export operations
|
// Check for conflicting export operations
|
||||||
|
|
|
||||||
|
|
@ -37,8 +37,8 @@ pub async fn execute(
|
||||||
let operation_id = coordinator.register_operation(OperationType::Fetch)?;
|
let operation_id = coordinator.register_operation(OperationType::Fetch)?;
|
||||||
let _guard = OperationGuard::new(coordinator, operation_id);
|
let _guard = OperationGuard::new(coordinator, operation_id);
|
||||||
|
|
||||||
// Create fetcher
|
// Create fetcher with shelve option
|
||||||
let fetcher = Fetcher::new(".");
|
let fetcher = Fetcher::new(".").with_shelve(args.shelve);
|
||||||
|
|
||||||
// Fetch all projects (progress indicators handled in fetch.rs)
|
// Fetch all projects (progress indicators handled in fetch.rs)
|
||||||
fetcher.fetch_all(&lockfile, &config).await?;
|
fetcher.fetch_all(&lockfile, &config).await?;
|
||||||
|
|
|
||||||
|
|
@ -211,13 +211,12 @@ fn execute_init(
|
||||||
.args(["log", "--limit", "1", "--template", ""])
|
.args(["log", "--limit", "1", "--template", ""])
|
||||||
.current_dir(path)
|
.current_dir(path)
|
||||||
.output()
|
.output()
|
||||||
|
&& !output.stdout.is_empty()
|
||||||
{
|
{
|
||||||
if !output.stdout.is_empty() {
|
println!(
|
||||||
println!(
|
"Note: Jujutsu repository detected. Make sure to run 'jj git \
|
||||||
"Note: Jujutsu repository detected. Make sure to run 'jj git \
|
push' to sync changes with remote if needed."
|
||||||
push' to sync changes with remote if needed."
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
VcsType::None => {
|
VcsType::None => {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,10 @@ pub async fn execute(
|
||||||
config_path: &Path,
|
config_path: &Path,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
log::info!("Importing modpack from {}", args.file);
|
log::info!("Importing modpack from {}", args.file);
|
||||||
|
log::info!(
|
||||||
|
"Dependency resolution: {}",
|
||||||
|
if args.deps { "enabled" } else { "disabled" }
|
||||||
|
);
|
||||||
|
|
||||||
let path = Path::new(&args.file);
|
let path = Path::new(&args.file);
|
||||||
|
|
||||||
|
|
@ -130,16 +134,19 @@ async fn import_modrinth(
|
||||||
{
|
{
|
||||||
log::info!("Fetching project: {project_id}");
|
log::info!("Fetching project: {project_id}");
|
||||||
match platform
|
match platform
|
||||||
.request_project_with_files(project_id, &lockfile.mc_versions, &[
|
.request_project_with_files(
|
||||||
loader.0.clone(),
|
project_id,
|
||||||
])
|
&lockfile.mc_versions,
|
||||||
|
std::slice::from_ref(&loader.0),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(mut project) => {
|
Ok(mut project) => {
|
||||||
// Select best file
|
// Select best file
|
||||||
if let Err(e) =
|
if let Err(e) = project.select_file(
|
||||||
project.select_file(&lockfile.mc_versions, &[loader.0.clone()])
|
&lockfile.mc_versions,
|
||||||
{
|
std::slice::from_ref(&loader.0),
|
||||||
|
) {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"Failed to select file for {}: {}",
|
"Failed to select file for {}: {}",
|
||||||
project.get_name(),
|
project.get_name(),
|
||||||
|
|
@ -159,24 +166,25 @@ async fn import_modrinth(
|
||||||
|
|
||||||
// Create config
|
// Create config
|
||||||
let config = Config {
|
let config = Config {
|
||||||
name: index["name"]
|
name: index["name"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.unwrap_or("Imported Pack")
|
.unwrap_or("Imported Pack")
|
||||||
.to_string(),
|
.to_string(),
|
||||||
version: index["versionId"]
|
version: index["versionId"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.unwrap_or("1.0.0")
|
.unwrap_or("1.0.0")
|
||||||
.to_string(),
|
.to_string(),
|
||||||
description: index["summary"]
|
description: index["summary"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.map(std::string::ToString::to_string),
|
.map(std::string::ToString::to_string),
|
||||||
author: None,
|
author: None,
|
||||||
overrides: vec!["overrides".to_string()],
|
overrides: vec!["overrides".to_string()],
|
||||||
server_overrides: None,
|
server_overrides: None,
|
||||||
client_overrides: None,
|
client_overrides: None,
|
||||||
paths: Default::default(),
|
paths: Default::default(),
|
||||||
projects: None,
|
projects: None,
|
||||||
export_profiles: None,
|
export_profiles: None,
|
||||||
|
export_server_side_projects_to_client: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Save files using provided paths
|
// Save files using provided paths
|
||||||
|
|
@ -341,24 +349,25 @@ async fn import_curseforge(
|
||||||
|
|
||||||
// Create config
|
// Create config
|
||||||
let config = Config {
|
let config = Config {
|
||||||
name: manifest["name"]
|
name: manifest["name"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.unwrap_or("Imported Pack")
|
.unwrap_or("Imported Pack")
|
||||||
.to_string(),
|
.to_string(),
|
||||||
version: manifest["version"]
|
version: manifest["version"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.unwrap_or("1.0.0")
|
.unwrap_or("1.0.0")
|
||||||
.to_string(),
|
.to_string(),
|
||||||
description: None,
|
description: None,
|
||||||
author: manifest["author"]
|
author: manifest["author"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.map(std::string::ToString::to_string),
|
.map(std::string::ToString::to_string),
|
||||||
overrides: vec!["overrides".to_string()],
|
overrides: vec!["overrides".to_string()],
|
||||||
server_overrides: None,
|
server_overrides: None,
|
||||||
client_overrides: None,
|
client_overrides: None,
|
||||||
paths: Default::default(),
|
paths: Default::default(),
|
||||||
projects: None,
|
projects: None,
|
||||||
export_profiles: None,
|
export_profiles: None,
|
||||||
|
export_server_side_projects_to_client: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Save files using provided paths
|
// Save files using provided paths
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,13 @@ use std::{collections::HashMap, path::Path};
|
||||||
use crate::{
|
use crate::{
|
||||||
cli::InitArgs,
|
cli::InitArgs,
|
||||||
error::PakkerError,
|
error::PakkerError,
|
||||||
model::{Config, LockFile, Target},
|
model::{Config, LockFile, ResolvedCredentials, Target},
|
||||||
|
ui_utils::{
|
||||||
|
prompt_curseforge_api_key,
|
||||||
|
prompt_input,
|
||||||
|
prompt_select,
|
||||||
|
prompt_yes_no,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub async fn execute(
|
pub async fn execute(
|
||||||
|
|
@ -17,8 +23,42 @@ pub async fn execute(
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let target = args.target.as_str();
|
// Interactive mode: prompt for values not provided via CLI and --yes not set
|
||||||
let target_enum = match target {
|
let is_interactive = !args.yes && args.name.is_none();
|
||||||
|
|
||||||
|
// Get modpack name
|
||||||
|
let name = if let Some(name) = args.name.clone() {
|
||||||
|
name
|
||||||
|
} else if is_interactive {
|
||||||
|
prompt_input("Modpack name", Some("My Modpack"))
|
||||||
|
.map_err(|e| PakkerError::InvalidInput(e.to_string()))?
|
||||||
|
} else {
|
||||||
|
"My Modpack".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get modpack version
|
||||||
|
let version = if let Some(version) = args.version.clone() {
|
||||||
|
version
|
||||||
|
} else if is_interactive {
|
||||||
|
prompt_input("Version", Some("1.0.0"))
|
||||||
|
.map_err(|e| PakkerError::InvalidInput(e.to_string()))?
|
||||||
|
} else {
|
||||||
|
"1.0.0".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get target platform
|
||||||
|
let target = if let Some(target) = args.target.clone() {
|
||||||
|
target
|
||||||
|
} else if is_interactive {
|
||||||
|
let targets = ["multiplatform", "curseforge", "modrinth"];
|
||||||
|
let idx = prompt_select("Target platform", &targets)
|
||||||
|
.map_err(|e| PakkerError::InvalidInput(e.to_string()))?;
|
||||||
|
targets[idx].to_string()
|
||||||
|
} else {
|
||||||
|
"multiplatform".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let target_enum = match target.as_str() {
|
||||||
"curseforge" => Target::CurseForge,
|
"curseforge" => Target::CurseForge,
|
||||||
"modrinth" => Target::Modrinth,
|
"modrinth" => Target::Modrinth,
|
||||||
"multiplatform" => Target::Multiplatform,
|
"multiplatform" => Target::Multiplatform,
|
||||||
|
|
@ -29,17 +69,56 @@ pub async fn execute(
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
let mc_versions = vec![args.mc_version];
|
// Get Minecraft versions (supports multiple)
|
||||||
|
let mc_versions = if let Some(versions) = args.mc_versions.clone() {
|
||||||
|
versions
|
||||||
|
} else if is_interactive {
|
||||||
|
let input =
|
||||||
|
prompt_input("Minecraft versions (space-separated)", Some("1.20.1"))
|
||||||
|
.map_err(|e| PakkerError::InvalidInput(e.to_string()))?;
|
||||||
|
input.split_whitespace().map(String::from).collect()
|
||||||
|
} else {
|
||||||
|
vec!["1.20.1".to_string()]
|
||||||
|
};
|
||||||
|
|
||||||
let mut loaders = HashMap::new();
|
// Get mod loaders (supports multiple in name=version format)
|
||||||
loaders.insert(args.loader, args.loader_version);
|
let loaders: HashMap<String, String> = if let Some(loader_strs) = args.loaders
|
||||||
|
{
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
for loader_str in loader_strs {
|
||||||
|
let parts: Vec<&str> = loader_str.splitn(2, '=').collect();
|
||||||
|
if parts.len() == 2 {
|
||||||
|
map.insert(parts[0].to_string(), parts[1].to_string());
|
||||||
|
} else {
|
||||||
|
// If no version specified, use "latest"
|
||||||
|
map.insert(loader_str, "latest".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
map
|
||||||
|
} else if is_interactive {
|
||||||
|
let loader_options = ["fabric", "forge", "neoforge", "quilt"];
|
||||||
|
let idx = prompt_select("Mod loader", &loader_options)
|
||||||
|
.map_err(|e| PakkerError::InvalidInput(e.to_string()))?;
|
||||||
|
let loader = loader_options[idx].to_string();
|
||||||
|
|
||||||
|
let loader_version = prompt_input("Loader version", Some("latest"))
|
||||||
|
.map_err(|e| PakkerError::InvalidInput(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
map.insert(loader, loader_version);
|
||||||
|
map
|
||||||
|
} else {
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
map.insert("fabric".to_string(), "latest".to_string());
|
||||||
|
map
|
||||||
|
};
|
||||||
|
|
||||||
let lockfile = LockFile {
|
let lockfile = LockFile {
|
||||||
target: Some(target_enum),
|
target: Some(target_enum),
|
||||||
mc_versions,
|
mc_versions,
|
||||||
loaders,
|
loaders,
|
||||||
projects: Vec::new(),
|
projects: Vec::new(),
|
||||||
lockfile_version: 1,
|
lockfile_version: 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Save expects directory path, so get parent directory
|
// Save expects directory path, so get parent directory
|
||||||
|
|
@ -47,21 +126,65 @@ pub async fn execute(
|
||||||
lockfile.save(lockfile_dir)?;
|
lockfile.save(lockfile_dir)?;
|
||||||
|
|
||||||
let config = Config {
|
let config = Config {
|
||||||
name: args.name.unwrap_or_else(|| "My Modpack".to_string()),
|
name: name.clone(),
|
||||||
version: args.version.unwrap_or_else(|| "1.0.0".to_string()),
|
version: version.clone(),
|
||||||
description: None,
|
description: None,
|
||||||
author: None,
|
author: None,
|
||||||
overrides: vec!["overrides".to_string()],
|
overrides: vec!["overrides".to_string()],
|
||||||
server_overrides: None,
|
server_overrides: None,
|
||||||
client_overrides: None,
|
client_overrides: None,
|
||||||
paths: HashMap::new(),
|
paths: HashMap::new(),
|
||||||
projects: None,
|
projects: None,
|
||||||
export_profiles: None,
|
export_profiles: None,
|
||||||
|
export_server_side_projects_to_client: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let config_dir = config_path.parent().unwrap_or(Path::new("."));
|
let config_dir = config_path.parent().unwrap_or(Path::new("."));
|
||||||
config.save(config_dir)?;
|
config.save(config_dir)?;
|
||||||
|
|
||||||
println!("Initialized new modpack with target: {target}");
|
println!("Initialized new modpack '{name}' v{version}");
|
||||||
|
println!(" Target: {target}");
|
||||||
|
println!(" Minecraft: {}", lockfile.mc_versions.join(", "));
|
||||||
|
println!(
|
||||||
|
" Loaders: {}",
|
||||||
|
lockfile
|
||||||
|
.loaders
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| format!("{k}={v}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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());
|
||||||
|
|
||||||
|
if !has_cf_key {
|
||||||
|
println!();
|
||||||
|
if prompt_yes_no("Would you like to set up CurseForge API key now?", true)
|
||||||
|
.map_err(|e| PakkerError::InvalidInput(e.to_string()))?
|
||||||
|
&& let Ok(Some(api_key)) = prompt_curseforge_api_key()
|
||||||
|
{
|
||||||
|
// Save to credentials file
|
||||||
|
let creds_path = std::env::var("HOME").map_or_else(
|
||||||
|
|_| Path::new(".pakku").to_path_buf(),
|
||||||
|
|h| Path::new(&h).join(".pakku"),
|
||||||
|
);
|
||||||
|
|
||||||
|
std::fs::create_dir_all(&creds_path).ok();
|
||||||
|
|
||||||
|
let creds_file = creds_path.join("credentials");
|
||||||
|
let content =
|
||||||
|
format!("# Pakku/Pakker credentials\nCURSEFORGE_API_KEY={api_key}\n");
|
||||||
|
if std::fs::write(&creds_file, content).is_ok() {
|
||||||
|
println!("CurseForge API key saved to ~/.pakku/credentials");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -176,7 +176,7 @@ fn display_project_inspection(
|
||||||
|
|
||||||
// Display project files
|
// Display project files
|
||||||
println!();
|
println!();
|
||||||
display_project_files(&project.files)?;
|
display_project_files(&project.files, project)?;
|
||||||
|
|
||||||
// Display properties
|
// Display properties
|
||||||
println!();
|
println!();
|
||||||
|
|
@ -228,7 +228,10 @@ fn display_project_header(project: &Project) -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn display_project_files(files: &[ProjectFile]) -> Result<()> {
|
fn display_project_files(
|
||||||
|
files: &[ProjectFile],
|
||||||
|
project: &Project,
|
||||||
|
) -> Result<()> {
|
||||||
if files.is_empty() {
|
if files.is_empty() {
|
||||||
println!("{}", "No files available".yellow());
|
println!("{}", "No files available".yellow());
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|
@ -250,19 +253,31 @@ fn display_project_files(files: &[ProjectFile]) -> Result<()> {
|
||||||
format!(" {status}")
|
format!(" {status}")
|
||||||
};
|
};
|
||||||
|
|
||||||
// File path line
|
// File path line with optional site URL
|
||||||
let file_path = format!("{}={}", file.file_type, file.file_name);
|
let file_path = format!("{}={}", file.file_type, file.file_name);
|
||||||
table.add_row(vec![
|
let file_display = if let Some(site_url) = file.get_site_url(project) {
|
||||||
Cell::new(format!("{file_path}:{status_text}")).fg(if idx == 0 {
|
// Create hyperlink for the file
|
||||||
Color::Green
|
let hyperlink = crate::ui_utils::hyperlink(&site_url, &file_path);
|
||||||
} else {
|
format!("{hyperlink}:{status_text}")
|
||||||
Color::White
|
} 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
|
// Date published
|
||||||
table.add_row(vec![Cell::new(&file.date_published).fg(Color::DarkGrey)]);
|
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
|
// Empty line
|
||||||
table.add_row(vec![Cell::new("")]);
|
table.add_row(vec![Cell::new("")]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,18 @@ use std::path::Path;
|
||||||
|
|
||||||
use crate::{cli::LsArgs, error::Result, model::LockFile};
|
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<()> {
|
pub fn execute(args: LsArgs, lockfile_path: &Path) -> Result<()> {
|
||||||
// Load expects directory path, so get parent directory
|
// 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(Path::new("."));
|
||||||
|
|
@ -15,10 +27,33 @@ pub fn execute(args: LsArgs, lockfile_path: &Path) -> Result<()> {
|
||||||
println!("Installed projects ({}):", lockfile.projects.len());
|
println!("Installed projects ({}):", lockfile.projects.len());
|
||||||
println!();
|
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 {
|
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 {
|
if args.detailed {
|
||||||
let id = project.pakku_id.as_deref().unwrap_or("unknown");
|
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!(" Type: {:?}", project.r#type);
|
||||||
println!(" Side: {:?}", project.side);
|
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() {
|
if !project.pakku_links.is_empty() {
|
||||||
println!(" Dependencies: {}", project.pakku_links.len());
|
println!(" Dependencies: {}", project.pakku_links.len());
|
||||||
}
|
}
|
||||||
|
|
||||||
println!();
|
println!();
|
||||||
} else {
|
} else {
|
||||||
|
let name = truncate_name(&project.get_name(), max_name_len);
|
||||||
let file_info = project
|
let file_info = project
|
||||||
.files
|
.files
|
||||||
.first()
|
.first()
|
||||||
.map(|f| format!(" ({})", f.file_name))
|
.map(|f| format!(" ({})", f.file_name))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
println!(" {}{}", project.get_name(), file_info);
|
println!(" {name}{file_info}{version_warning}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use crate::{cli::RemoteUpdateArgs, error::PakkerError, git, model::Config};
|
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
|
/// 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");
|
let remote_config_path = remote_dir.join("pakku.json");
|
||||||
if !remote_config_path.exists() {
|
if !remote_config_path.exists() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ use crate::{
|
||||||
cli::RmArgs,
|
cli::RmArgs,
|
||||||
error::{PakkerError, Result},
|
error::{PakkerError, Result},
|
||||||
model::LockFile,
|
model::LockFile,
|
||||||
ui_utils::prompt_yes_no,
|
ui_utils::{prompt_typo_suggestion, prompt_yes_no},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub async fn execute(
|
pub async fn execute(
|
||||||
|
|
@ -12,19 +12,84 @@ pub async fn execute(
|
||||||
lockfile_path: &Path,
|
lockfile_path: &Path,
|
||||||
_config_path: &Path,
|
_config_path: &Path,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
log::info!("Removing projects: {:?}", args.inputs);
|
|
||||||
|
|
||||||
// Load expects directory path, so get parent directory
|
// 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(Path::new("."));
|
||||||
let mut lockfile = LockFile::load(lockfile_dir)?;
|
let mut lockfile = LockFile::load(lockfile_dir)?;
|
||||||
|
|
||||||
|
// Determine which projects to remove
|
||||||
|
let inputs: Vec<String> = if args.all {
|
||||||
|
log::info!("Removing all projects from lockfile");
|
||||||
|
lockfile
|
||||||
|
.projects
|
||||||
|
.iter()
|
||||||
|
.filter_map(|p| {
|
||||||
|
p.pakku_id
|
||||||
|
.clone()
|
||||||
|
.or_else(|| p.slug.values().next().cloned())
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
args.inputs.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
if inputs.is_empty() {
|
||||||
|
return if args.all {
|
||||||
|
Err(PakkerError::ProjectNotFound(
|
||||||
|
"No projects found in lockfile".to_string(),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Err(PakkerError::ProjectNotFound(
|
||||||
|
"No projects specified".to_string(),
|
||||||
|
))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("Removing projects: {inputs:?}");
|
||||||
|
|
||||||
let mut removed_count = 0;
|
let mut removed_count = 0;
|
||||||
let mut removed_ids = Vec::new();
|
let mut removed_ids = Vec::new();
|
||||||
let mut projects_to_remove = Vec::new();
|
let mut projects_to_remove = Vec::new();
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
|
||||||
// First, identify all projects to remove
|
// First, identify all projects to remove
|
||||||
for input in &args.inputs {
|
let mut resolved_inputs = Vec::new();
|
||||||
|
for input in &inputs {
|
||||||
// Find project by various identifiers
|
// Find project by various identifiers
|
||||||
|
if lockfile.projects.iter().any(|p| {
|
||||||
|
p.pakku_id.as_deref() == Some(input)
|
||||||
|
|| p.slug.values().any(|s| s == input)
|
||||||
|
|| p.name.values().any(|n| n.eq_ignore_ascii_case(input))
|
||||||
|
|| p.aliases.contains(input)
|
||||||
|
}) {
|
||||||
|
resolved_inputs.push(input.clone());
|
||||||
|
} else if !args.all {
|
||||||
|
// Try typo suggestion
|
||||||
|
if let Ok(Some(suggestion)) = prompt_typo_suggestion(input, &all_slugs) {
|
||||||
|
log::info!("Using suggested project: {suggestion}");
|
||||||
|
resolved_inputs.push(suggestion);
|
||||||
|
} else {
|
||||||
|
log::warn!("Project not found: {input}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now find the actual projects from resolved inputs
|
||||||
|
for input in &resolved_inputs {
|
||||||
if let Some(project) = lockfile.projects.iter().find(|p| {
|
if let Some(project) = lockfile.projects.iter().find(|p| {
|
||||||
p.pakku_id.as_deref() == Some(input)
|
p.pakku_id.as_deref() == Some(input)
|
||||||
|| p.slug.values().any(|s| s == input)
|
|| p.slug.values().any(|s| s == input)
|
||||||
|
|
@ -32,18 +97,20 @@ pub async fn execute(
|
||||||
|| p.aliases.contains(input)
|
|| p.aliases.contains(input)
|
||||||
}) {
|
}) {
|
||||||
projects_to_remove.push(project.get_name());
|
projects_to_remove.push(project.get_name());
|
||||||
} else {
|
|
||||||
log::warn!("Project not found: {input}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Replace inputs with resolved_inputs for actual removal
|
||||||
|
let inputs = resolved_inputs;
|
||||||
|
|
||||||
if projects_to_remove.is_empty() {
|
if projects_to_remove.is_empty() {
|
||||||
return Err(PakkerError::ProjectNotFound(
|
return Err(PakkerError::ProjectNotFound(
|
||||||
"None of the specified projects found".to_string(),
|
"None of the specified projects found".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ask for confirmation unless --yes flag is provided
|
// Ask for confirmation unless --yes flag is provided or --all with no
|
||||||
|
// projects
|
||||||
if !args.yes {
|
if !args.yes {
|
||||||
println!("The following projects will be removed:");
|
println!("The following projects will be removed:");
|
||||||
for name in &projects_to_remove {
|
for name in &projects_to_remove {
|
||||||
|
|
@ -57,7 +124,7 @@ pub async fn execute(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now actually remove the projects
|
// Now actually remove the projects
|
||||||
for input in &args.inputs {
|
for input in &inputs {
|
||||||
if let Some(pos) = lockfile.projects.iter().position(|p| {
|
if let Some(pos) = lockfile.projects.iter().position(|p| {
|
||||||
p.pakku_id.as_deref() == Some(input)
|
p.pakku_id.as_deref() == Some(input)
|
||||||
|| p.slug.values().any(|s| s == input)
|
|| p.slug.values().any(|s| s == input)
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ use tokio::sync::Semaphore;
|
||||||
use yansi::Paint;
|
use yansi::Paint;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
error::Result,
|
error::{ErrorSeverity, Result},
|
||||||
model::{Config, LockFile, Project},
|
model::{Config, LockFile, Project},
|
||||||
platform::create_platform,
|
platform::create_platform,
|
||||||
};
|
};
|
||||||
|
|
@ -36,13 +36,42 @@ pub async fn execute(
|
||||||
// Display results
|
// Display results
|
||||||
display_update_results(&updates);
|
display_update_results(&updates);
|
||||||
|
|
||||||
// Display errors if any
|
// Display errors if any, categorized by severity
|
||||||
if !errors.is_empty() {
|
if !errors.is_empty() {
|
||||||
println!();
|
println!();
|
||||||
println!("{}", "Errors encountered:".red());
|
|
||||||
for (project, error) in &errors {
|
// Categorize errors by severity
|
||||||
println!(" - {}: {}", project.yellow(), error.red());
|
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
|
// Prompt to update if there are updates available
|
||||||
|
|
@ -52,6 +81,7 @@ pub async fn execute(
|
||||||
// Call update command programmatically (update all projects)
|
// Call update command programmatically (update all projects)
|
||||||
let update_args = crate::cli::UpdateArgs {
|
let update_args = crate::cli::UpdateArgs {
|
||||||
inputs: vec![],
|
inputs: vec![],
|
||||||
|
all: true,
|
||||||
yes: true, // Auto-yes for status command
|
yes: true, // Auto-yes for status command
|
||||||
};
|
};
|
||||||
crate::cli::commands::update::execute(
|
crate::cli::commands::update::execute(
|
||||||
|
|
@ -368,3 +398,12 @@ fn get_api_key(platform: &str) -> Option<String> {
|
||||||
_ => None,
|
_ => 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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,10 @@ use indicatif::{ProgressBar, ProgressStyle};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
cli::UpdateArgs,
|
cli::UpdateArgs,
|
||||||
error::PakkerError,
|
error::{MultiError, PakkerError},
|
||||||
model::{Config, LockFile},
|
model::{Config, LockFile, UpdateStrategy},
|
||||||
platform::create_platform,
|
platform::create_platform,
|
||||||
ui_utils::prompt_select,
|
ui_utils::{prompt_select, prompt_typo_suggestion, prompt_yes_no},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub async fn execute(
|
pub async fn execute(
|
||||||
|
|
@ -33,6 +33,22 @@ pub async fn execute(
|
||||||
platforms.insert("curseforge".to_string(), platform);
|
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() {
|
let project_indices: Vec<_> = if args.inputs.is_empty() {
|
||||||
(0..lockfile.projects.len()).collect()
|
(0..lockfile.projects.len()).collect()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -46,14 +62,29 @@ pub async fn execute(
|
||||||
{
|
{
|
||||||
indices.push(idx);
|
indices.push(idx);
|
||||||
} else {
|
} 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()));
|
return Err(PakkerError::ProjectNotFound(input.clone()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
indices
|
indices
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Capture count before consuming the iterator
|
||||||
|
let total_projects = project_indices.len();
|
||||||
|
|
||||||
// Create progress bar
|
// Create progress bar
|
||||||
let pb = ProgressBar::new(project_indices.len() as u64);
|
let pb = ProgressBar::new(total_projects as u64);
|
||||||
pb.set_style(
|
pb.set_style(
|
||||||
ProgressStyle::default_bar()
|
ProgressStyle::default_bar()
|
||||||
.template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}")
|
.template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}")
|
||||||
|
|
@ -61,8 +92,23 @@ pub async fn execute(
|
||||||
.progress_chars("#>-"),
|
.progress_chars("#>-"),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let mut skipped_pinned = 0;
|
||||||
|
let mut update_errors = MultiError::new();
|
||||||
|
|
||||||
for idx in project_indices {
|
for idx in project_indices {
|
||||||
let old_project = &lockfile.projects[idx];
|
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()));
|
pb.set_message(format!("Updating {}...", old_project.get_name()));
|
||||||
|
|
||||||
let slug = old_project
|
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
|
if let Some(mut updated_project) = updated_project
|
||||||
&& !updated_project.files.is_empty()
|
&& !updated_project.files.is_empty()
|
||||||
&& let Some(old_file) = lockfile.projects[idx].files.first()
|
&& 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 {
|
if new_file_id == old_file.id {
|
||||||
pb.println(format!(
|
pb.println(format!(" {project_name} - Already up to date"));
|
||||||
" {} - Already up to date",
|
|
||||||
old_project.get_name()
|
|
||||||
));
|
|
||||||
} else {
|
} else {
|
||||||
// Interactive version selection if not using --yes flag
|
// Interactive confirmation and version selection if not using --yes
|
||||||
if !args.yes && updated_project.files.len() > 1 {
|
// flag
|
||||||
|
let mut should_update = args.yes || args.all;
|
||||||
|
let mut selected_idx: Option<usize> = None;
|
||||||
|
|
||||||
|
if !args.yes && !args.all {
|
||||||
pb.suspend(|| {
|
pb.suspend(|| {
|
||||||
let choices: Vec<String> = updated_project
|
// First, confirm the update
|
||||||
.files
|
let prompt_msg = format!(
|
||||||
.iter()
|
"Update '{project_name}' from {old_file_name} to \
|
||||||
.map(|f| format!("{} ({})", f.file_name, f.id))
|
{new_file_name}?"
|
||||||
.collect();
|
);
|
||||||
|
should_update = prompt_yes_no(&prompt_msg, true).unwrap_or(false);
|
||||||
|
|
||||||
let choice_refs: Vec<&str> =
|
// If confirmed and multiple versions available, offer selection
|
||||||
choices.iter().map(std::string::String::as_str).collect();
|
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(
|
let choice_refs: Vec<&str> =
|
||||||
&format!("Select version for {}:", old_project.get_name()),
|
choices.iter().map(std::string::String::as_str).collect();
|
||||||
&choice_refs,
|
|
||||||
) {
|
if let Ok(idx) = prompt_select(
|
||||||
// Move selected file to front
|
&format!("Select version for {project_name}:"),
|
||||||
if selected_idx > 0 {
|
&choice_refs,
|
||||||
updated_project.files.swap(0, selected_idx);
|
) {
|
||||||
|
selected_idx = Some(idx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let selected_file = updated_project.files.first().unwrap();
|
// Apply file selection outside the closure
|
||||||
pb.println(format!(
|
if let Some(idx) = selected_idx
|
||||||
" {} -> {}",
|
&& idx > 0
|
||||||
old_file.file_name, selected_file.file_name
|
{
|
||||||
));
|
updated_project.files.swap(0, idx);
|
||||||
lockfile.projects[idx] = updated_project;
|
}
|
||||||
|
|
||||||
|
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.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)?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
257
src/cli/tests.rs
Normal file
257
src/cli/tests.rs
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::{fs, path::PathBuf};
|
||||||
|
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
cli::{ExportArgs, ImportArgs, RmArgs},
|
||||||
|
model::config::Config,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rm_args_parsing_all_flag() {
|
||||||
|
let args = RmArgs::parse_from(&["pakker", "rm", "--all"]);
|
||||||
|
assert!(args.all);
|
||||||
|
assert!(args.inputs.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rm_args_parsing_multiple_inputs() {
|
||||||
|
let args = RmArgs::parse_from(&["pakker", "rm", "mod1", "mod2", "mod3"]);
|
||||||
|
assert!(!args.all);
|
||||||
|
assert_eq!(args.inputs, vec!["mod1", "mod2", "mod3"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rm_args_parsing_all_with_yes() {
|
||||||
|
let args = RmArgs::parse_from(&["pakker", "rm", "--all", "--yes"]);
|
||||||
|
assert!(args.all);
|
||||||
|
assert!(args.yes);
|
||||||
|
assert!(args.inputs.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rm_args_parsing_with_inputs_and_yes() {
|
||||||
|
let args = RmArgs::parse_from(&["pakker", "rm", "mod1", "--yes"]);
|
||||||
|
assert!(!args.all);
|
||||||
|
assert!(args.yes);
|
||||||
|
assert_eq!(args.inputs, vec!["mod1"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_import_args_parsing_deps_flag() {
|
||||||
|
let args =
|
||||||
|
ImportArgs::parse_from(&["pakker", "import", "--deps", "pack.zip"]);
|
||||||
|
assert!(args.deps);
|
||||||
|
assert_eq!(args.file, "pack.zip");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_import_args_parsing_no_deps_default() {
|
||||||
|
let args = ImportArgs::parse_from(&["pakker", "import", "pack.zip"]);
|
||||||
|
assert!(!args.deps);
|
||||||
|
assert_eq!(args.file, "pack.zip");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_import_args_parsing_deps_with_yes() {
|
||||||
|
let args = ImportArgs::parse_from(&[
|
||||||
|
"pakker", "import", "--deps", "--yes", "pack.zip",
|
||||||
|
]);
|
||||||
|
assert!(args.deps);
|
||||||
|
assert!(args.yes);
|
||||||
|
assert_eq!(args.file, "pack.zip");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_import_args_parsing_short_deps_flag() {
|
||||||
|
let args = ImportArgs::parse_from(&["pakker", "import", "-D", "pack.zip"]);
|
||||||
|
assert!(args.deps);
|
||||||
|
assert_eq!(args.file, "pack.zip");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_export_args_parsing_show_io_errors() {
|
||||||
|
let args =
|
||||||
|
ExportArgs::parse_from(&["pakker", "export", "--show-io-errors"]);
|
||||||
|
assert!(args.show_io_errors);
|
||||||
|
assert!(!args.no_server);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_export_args_parsing_no_server() {
|
||||||
|
let args = ExportArgs::parse_from(&["pakker", "export", "--no-server"]);
|
||||||
|
assert!(args.no_server);
|
||||||
|
assert!(!args.show_io_errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_export_args_parsing_both_flags() {
|
||||||
|
let args = ExportArgs::parse_from(&[
|
||||||
|
"pakker",
|
||||||
|
"export",
|
||||||
|
"--show-io-errors",
|
||||||
|
"--no-server",
|
||||||
|
"--profile",
|
||||||
|
"modrinth",
|
||||||
|
]);
|
||||||
|
assert!(args.show_io_errors);
|
||||||
|
assert!(args.no_server);
|
||||||
|
assert_eq!(args.profile, Some("modrinth".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_export_args_parsing_with_output() {
|
||||||
|
let args = ExportArgs::parse_from(&[
|
||||||
|
"pakker",
|
||||||
|
"export",
|
||||||
|
"--output",
|
||||||
|
"/tmp/export",
|
||||||
|
"--profile",
|
||||||
|
"curseforge",
|
||||||
|
]);
|
||||||
|
assert_eq!(args.output, Some("/tmp/export".to_string()));
|
||||||
|
assert_eq!(args.profile, Some("curseforge".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_export_args_parsing_pakker_layout() {
|
||||||
|
let args = ExportArgs::parse_from(&["pakker", "export", "--pakker-layout"]);
|
||||||
|
assert!(args.pakker_layout);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_with_export_server_side_projects_to_client_true() {
|
||||||
|
let config = Config {
|
||||||
|
name: "test-pack".to_string(),
|
||||||
|
version: "1.0.0".to_string(),
|
||||||
|
description: None,
|
||||||
|
author: None,
|
||||||
|
overrides: vec!["overrides".to_string()],
|
||||||
|
server_overrides: None,
|
||||||
|
client_overrides: None,
|
||||||
|
paths: std::collections::HashMap::new(),
|
||||||
|
projects: None,
|
||||||
|
export_profiles: None,
|
||||||
|
export_server_side_projects_to_client: Some(true),
|
||||||
|
};
|
||||||
|
assert_eq!(config.export_server_side_projects_to_client, Some(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_with_export_server_side_projects_to_client_false() {
|
||||||
|
let config = Config {
|
||||||
|
name: "test-pack".to_string(),
|
||||||
|
version: "1.0.0".to_string(),
|
||||||
|
description: None,
|
||||||
|
author: None,
|
||||||
|
overrides: vec!["overrides".to_string()],
|
||||||
|
server_overrides: None,
|
||||||
|
client_overrides: None,
|
||||||
|
paths: std::collections::HashMap::new(),
|
||||||
|
projects: None,
|
||||||
|
export_profiles: None,
|
||||||
|
export_server_side_projects_to_client: Some(false),
|
||||||
|
};
|
||||||
|
assert_eq!(config.export_server_side_projects_to_client, Some(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_without_export_server_side_projects_to_client() {
|
||||||
|
let config = Config {
|
||||||
|
name: "test-pack".to_string(),
|
||||||
|
version: "1.0.0".to_string(),
|
||||||
|
description: None,
|
||||||
|
author: None,
|
||||||
|
overrides: vec!["overrides".to_string()],
|
||||||
|
server_overrides: None,
|
||||||
|
client_overrides: None,
|
||||||
|
paths: std::collections::HashMap::new(),
|
||||||
|
projects: None,
|
||||||
|
export_profiles: None,
|
||||||
|
export_server_side_projects_to_client: None,
|
||||||
|
};
|
||||||
|
assert!(config.export_server_side_projects_to_client.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_serialization_with_export_server_side() {
|
||||||
|
let config = Config {
|
||||||
|
name: "test-pack".to_string(),
|
||||||
|
version: "1.0.0".to_string(),
|
||||||
|
description: Some("A test modpack".to_string()),
|
||||||
|
author: Some("Test Author".to_string()),
|
||||||
|
overrides: vec!["overrides".to_string()],
|
||||||
|
server_overrides: Some(vec![
|
||||||
|
"server-overrides".to_string(),
|
||||||
|
]),
|
||||||
|
client_overrides: Some(vec![
|
||||||
|
"client-overrides".to_string(),
|
||||||
|
]),
|
||||||
|
paths: std::collections::HashMap::new(),
|
||||||
|
projects: None,
|
||||||
|
export_profiles: None,
|
||||||
|
export_server_side_projects_to_client: Some(true),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string_pretty(&config).unwrap();
|
||||||
|
assert!(json.contains("exportServerSideProjectsToClient"));
|
||||||
|
assert!(json.contains("true"));
|
||||||
|
|
||||||
|
let deserialized: Config = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
deserialized.export_server_side_projects_to_client,
|
||||||
|
Some(true)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_serialization_without_export_server_side() {
|
||||||
|
let config = Config {
|
||||||
|
name: "test-pack".to_string(),
|
||||||
|
version: "1.0.0".to_string(),
|
||||||
|
description: None,
|
||||||
|
author: None,
|
||||||
|
overrides: vec!["overrides".to_string()],
|
||||||
|
server_overrides: None,
|
||||||
|
client_overrides: None,
|
||||||
|
paths: std::collections::HashMap::new(),
|
||||||
|
projects: None,
|
||||||
|
export_profiles: None,
|
||||||
|
export_server_side_projects_to_client: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string_pretty(&config).unwrap();
|
||||||
|
assert!(!json.contains("exportServerSideProjectsToClient"));
|
||||||
|
|
||||||
|
let deserialized: Config = serde_json::from_str(&json).unwrap();
|
||||||
|
assert!(deserialized.export_server_side_projects_to_client.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_default_has_no_export_server_side() {
|
||||||
|
let config = Config::default();
|
||||||
|
assert!(config.export_server_side_projects_to_client.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_export_args_all_flags_together() {
|
||||||
|
let args = ExportArgs::parse_from(&[
|
||||||
|
"pakker",
|
||||||
|
"export",
|
||||||
|
"--profile",
|
||||||
|
"modrinth",
|
||||||
|
"--output",
|
||||||
|
"/tmp/out",
|
||||||
|
"--pakker-layout",
|
||||||
|
"--show-io-errors",
|
||||||
|
"--no-server",
|
||||||
|
]);
|
||||||
|
assert_eq!(args.profile, Some("modrinth".to_string()));
|
||||||
|
assert_eq!(args.output, Some("/tmp/out".to_string()));
|
||||||
|
assert!(args.pakker_layout);
|
||||||
|
assert!(args.show_io_errors);
|
||||||
|
assert!(args.no_server);
|
||||||
|
}
|
||||||
|
}
|
||||||
178
src/error.rs
178
src/error.rs
|
|
@ -2,6 +2,76 @@ use thiserror::Error;
|
||||||
|
|
||||||
pub type Result<T> = std::result::Result<T, PakkerError>;
|
pub type Result<T> = std::result::Result<T, PakkerError>;
|
||||||
|
|
||||||
|
/// Severity level for errors
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum ErrorSeverity {
|
||||||
|
/// Fatal error - operation cannot continue
|
||||||
|
#[default]
|
||||||
|
Error,
|
||||||
|
/// Warning - operation can continue but may have issues
|
||||||
|
Warning,
|
||||||
|
/// Info - informational message
|
||||||
|
Info,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Container for multiple errors that occurred during an operation
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct MultiError {
|
||||||
|
errors: Vec<PakkerError>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MultiError {
|
||||||
|
pub const fn new() -> Self {
|
||||||
|
Self { errors: Vec::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push(&mut self, error: PakkerError) {
|
||||||
|
self.errors.push(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn extend(&mut self, errors: impl IntoIterator<Item = PakkerError>) {
|
||||||
|
self.errors.extend(errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn is_empty(&self) -> bool {
|
||||||
|
self.errors.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn len(&self) -> usize {
|
||||||
|
self.errors.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_result<T>(self, success_value: T) -> Result<T> {
|
||||||
|
if self.is_empty() {
|
||||||
|
Ok(success_value)
|
||||||
|
} else {
|
||||||
|
Err(PakkerError::Multiple(self.errors))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn errors(&self) -> &[PakkerError] {
|
||||||
|
&self.errors
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_errors(self) -> Vec<PakkerError> {
|
||||||
|
self.errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MultiError {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromIterator<PakkerError> for MultiError {
|
||||||
|
fn from_iter<I: IntoIterator<Item = PakkerError>>(iter: I) -> Self {
|
||||||
|
Self {
|
||||||
|
errors: iter.into_iter().collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum PakkerError {
|
pub enum PakkerError {
|
||||||
// Network errors
|
// Network errors
|
||||||
|
|
@ -95,6 +165,21 @@ pub enum PakkerError {
|
||||||
|
|
||||||
#[error("IPC error: {0}")]
|
#[error("IPC error: {0}")]
|
||||||
IpcError(String),
|
IpcError(String),
|
||||||
|
|
||||||
|
#[error("{}", format_multiple_errors(.0))]
|
||||||
|
Multiple(Vec<Self>),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_multiple_errors(errors: &[PakkerError]) -> String {
|
||||||
|
if errors.len() == 1 {
|
||||||
|
return errors[0].to_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));
|
||||||
|
}
|
||||||
|
msg
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<git2::Error> for PakkerError {
|
impl From<git2::Error> for PakkerError {
|
||||||
|
|
@ -108,3 +193,96 @@ impl From<crate::ipc::IpcError> for PakkerError {
|
||||||
Self::IpcError(err.to_string())
|
Self::IpcError(err.to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_multi_error_empty() {
|
||||||
|
let multi = MultiError::new();
|
||||||
|
assert!(multi.is_empty());
|
||||||
|
assert_eq!(multi.len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_multi_error_push() {
|
||||||
|
let mut multi = MultiError::new();
|
||||||
|
multi.push(PakkerError::ProjectNotFound("mod1".to_string()));
|
||||||
|
multi.push(PakkerError::ProjectNotFound("mod2".to_string()));
|
||||||
|
|
||||||
|
assert!(!multi.is_empty());
|
||||||
|
assert_eq!(multi.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_multi_error_into_result_empty() {
|
||||||
|
let multi = MultiError::new();
|
||||||
|
let result: Result<i32> = multi.into_result(42);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert_eq!(result.unwrap(), 42);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_multi_error_into_result_with_errors() {
|
||||||
|
let mut multi = MultiError::new();
|
||||||
|
multi.push(PakkerError::ProjectNotFound("mod1".to_string()));
|
||||||
|
|
||||||
|
let result: Result<i32> = multi.into_result(42);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_multi_error_from_iterator() {
|
||||||
|
let errors = vec![
|
||||||
|
PakkerError::ProjectNotFound("mod1".to_string()),
|
||||||
|
PakkerError::ProjectNotFound("mod2".to_string()),
|
||||||
|
];
|
||||||
|
let multi: MultiError = errors.into_iter().collect();
|
||||||
|
assert_eq!(multi.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_multi_error_extend() {
|
||||||
|
let mut multi = MultiError::new();
|
||||||
|
multi.push(PakkerError::ProjectNotFound("mod1".to_string()));
|
||||||
|
|
||||||
|
let more_errors = vec![
|
||||||
|
PakkerError::ProjectNotFound("mod2".to_string()),
|
||||||
|
PakkerError::ProjectNotFound("mod3".to_string()),
|
||||||
|
];
|
||||||
|
multi.extend(more_errors);
|
||||||
|
|
||||||
|
assert_eq!(multi.len(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_multiple_errors_formatting() {
|
||||||
|
let errors = vec![
|
||||||
|
PakkerError::ProjectNotFound("mod1".to_string()),
|
||||||
|
PakkerError::ProjectNotFound("mod2".to_string()),
|
||||||
|
];
|
||||||
|
let error = PakkerError::Multiple(errors);
|
||||||
|
let msg = error.to_string();
|
||||||
|
|
||||||
|
assert!(msg.contains("2 errors occurred"));
|
||||||
|
assert!(msg.contains("mod1"));
|
||||||
|
assert!(msg.contains("mod2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_single_multiple_error_formatting() {
|
||||||
|
let errors = vec![PakkerError::ProjectNotFound("mod1".to_string())];
|
||||||
|
let error = PakkerError::Multiple(errors);
|
||||||
|
let msg = error.to_string();
|
||||||
|
|
||||||
|
// Single error should just display the error itself
|
||||||
|
assert!(msg.contains("mod1"));
|
||||||
|
assert!(!msg.contains("errors occurred"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_error_severity_default() {
|
||||||
|
assert_eq!(ErrorSeverity::default(), ErrorSeverity::Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,17 @@ impl ProfileConfig {
|
||||||
.or(global_server_overrides.map(std::vec::Vec::as_slice))
|
.or(global_server_overrides.map(std::vec::Vec::as_slice))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get effective client override paths, falling back to global config
|
||||||
|
pub fn get_client_overrides<'a>(
|
||||||
|
&'a self,
|
||||||
|
global_client_overrides: Option<&'a Vec<String>>,
|
||||||
|
) -> Option<&'a [String]> {
|
||||||
|
self
|
||||||
|
.client_overrides
|
||||||
|
.as_deref()
|
||||||
|
.or(global_client_overrides.map(std::vec::Vec::as_slice))
|
||||||
|
}
|
||||||
|
|
||||||
/// Get default config for `CurseForge` profile
|
/// Get default config for `CurseForge` profile
|
||||||
pub fn curseforge_default() -> Self {
|
pub fn curseforge_default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,15 @@ impl ExportProfile for CurseForgeProfile {
|
||||||
vec![
|
vec![
|
||||||
Box::new(super::rules::CopyProjectFilesRule),
|
Box::new(super::rules::CopyProjectFilesRule),
|
||||||
Box::new(super::rules::FilterByPlatformRule),
|
Box::new(super::rules::FilterByPlatformRule),
|
||||||
|
Box::new(super::rules::MissingProjectsAsOverridesRule::new(
|
||||||
|
"curseforge",
|
||||||
|
)),
|
||||||
Box::new(super::rules::CopyOverridesRule),
|
Box::new(super::rules::CopyOverridesRule),
|
||||||
|
Box::new(super::rules::CopyClientOverridesRule),
|
||||||
|
Box::new(super::rules::FilterServerOnlyRule),
|
||||||
Box::new(super::rules::GenerateManifestRule::curseforge()),
|
Box::new(super::rules::GenerateManifestRule::curseforge()),
|
||||||
Box::new(super::rules::FilterNonRedistributableRule),
|
Box::new(super::rules::FilterNonRedistributableRule),
|
||||||
|
Box::new(super::rules::TextReplacementRule),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -35,8 +41,14 @@ impl ExportProfile for ModrinthProfile {
|
||||||
vec![
|
vec![
|
||||||
Box::new(super::rules::CopyProjectFilesRule),
|
Box::new(super::rules::CopyProjectFilesRule),
|
||||||
Box::new(super::rules::FilterByPlatformRule),
|
Box::new(super::rules::FilterByPlatformRule),
|
||||||
|
Box::new(super::rules::MissingProjectsAsOverridesRule::new(
|
||||||
|
"modrinth",
|
||||||
|
)),
|
||||||
Box::new(super::rules::CopyOverridesRule),
|
Box::new(super::rules::CopyOverridesRule),
|
||||||
|
Box::new(super::rules::CopyClientOverridesRule),
|
||||||
|
Box::new(super::rules::FilterServerOnlyRule),
|
||||||
Box::new(super::rules::GenerateManifestRule::modrinth()),
|
Box::new(super::rules::GenerateManifestRule::modrinth()),
|
||||||
|
Box::new(super::rules::TextReplacementRule),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -51,6 +63,7 @@ impl ExportProfile for ServerPackProfile {
|
||||||
Box::new(super::rules::CopyProjectFilesRule),
|
Box::new(super::rules::CopyProjectFilesRule),
|
||||||
Box::new(super::rules::CopyServerOverridesRule),
|
Box::new(super::rules::CopyServerOverridesRule),
|
||||||
Box::new(super::rules::FilterClientOnlyRule),
|
Box::new(super::rules::FilterClientOnlyRule),
|
||||||
|
Box::new(super::rules::TextReplacementRule),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
use std::{fs, path::PathBuf};
|
use std::{fs, path::PathBuf};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use glob::glob;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
error::Result,
|
error::Result,
|
||||||
model::{Config, LockFile, ProjectSide},
|
model::{Config, LockFile, ProjectSide, ProjectType},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
|
@ -46,7 +47,7 @@ pub struct CopyProjectFilesEffect;
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Effect for CopyProjectFilesEffect {
|
impl Effect for CopyProjectFilesEffect {
|
||||||
fn name(&self) -> &'static str {
|
fn name(&self) -> &'static str {
|
||||||
"Downloading and copying mod files"
|
"Downloading and copying project files"
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute(&self, context: &RuleContext) -> Result<()> {
|
async fn execute(&self, context: &RuleContext) -> Result<()> {
|
||||||
|
|
@ -58,17 +59,27 @@ impl Effect for CopyProjectFilesEffect {
|
||||||
credentials.curseforge_api_key().map(ToOwned::to_owned);
|
credentials.curseforge_api_key().map(ToOwned::to_owned);
|
||||||
let modrinth_token = credentials.modrinth_token().map(ToOwned::to_owned);
|
let modrinth_token = credentials.modrinth_token().map(ToOwned::to_owned);
|
||||||
|
|
||||||
let mods_dir = context.export_path.join("mods");
|
|
||||||
fs::create_dir_all(&mods_dir)?;
|
|
||||||
|
|
||||||
for project in &context.lockfile.projects {
|
for project in &context.lockfile.projects {
|
||||||
if !project.export {
|
if !project.export {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(file) = project.files.first() {
|
if let Some(file) = project.files.first() {
|
||||||
let source = context.base_path.join("mods").join(&file.file_name);
|
// Get the target directory based on project type and paths config
|
||||||
let dest = mods_dir.join(&file.file_name);
|
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 export_dir = context.export_path.join(&target_subdir);
|
||||||
|
fs::create_dir_all(&export_dir)?;
|
||||||
|
|
||||||
|
let source = context.base_path.join(&type_dir).join(&file.file_name);
|
||||||
|
let dest = export_dir.join(&file.file_name);
|
||||||
|
|
||||||
if source.exists() {
|
if source.exists() {
|
||||||
fs::copy(&source, &dest)?;
|
fs::copy(&source, &dest)?;
|
||||||
|
|
@ -79,6 +90,7 @@ impl Effect for CopyProjectFilesEffect {
|
||||||
} else if !file.url.is_empty() {
|
} else if !file.url.is_empty() {
|
||||||
download_file(
|
download_file(
|
||||||
&context.base_path,
|
&context.base_path,
|
||||||
|
&type_dir,
|
||||||
&file.file_name,
|
&file.file_name,
|
||||||
&file.url,
|
&file.url,
|
||||||
curseforge_key.as_deref(),
|
curseforge_key.as_deref(),
|
||||||
|
|
@ -86,8 +98,9 @@ impl Effect for CopyProjectFilesEffect {
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Copy into export mods/ after ensuring it is present in base mods/
|
// Copy into export dir after ensuring it is present in base dir
|
||||||
let downloaded = context.base_path.join("mods").join(&file.file_name);
|
let downloaded =
|
||||||
|
context.base_path.join(&type_dir).join(&file.file_name);
|
||||||
if downloaded.exists() {
|
if downloaded.exists() {
|
||||||
fs::copy(&downloaded, &dest)?;
|
fs::copy(&downloaded, &dest)?;
|
||||||
if let Some(ui) = &context.ui {
|
if let Some(ui) = &context.ui {
|
||||||
|
|
@ -102,7 +115,7 @@ impl Effect for CopyProjectFilesEffect {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return Err(crate::error::PakkerError::InternalError(format!(
|
return Err(crate::error::PakkerError::InternalError(format!(
|
||||||
"missing mod file and no download url: {}",
|
"missing project file and no download url: {}",
|
||||||
file.file_name
|
file.file_name
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
@ -157,6 +170,7 @@ fn classify_reqwest_error(err: &reqwest::Error) -> DownloadFailure {
|
||||||
|
|
||||||
async fn download_file(
|
async fn download_file(
|
||||||
base_path: &std::path::Path,
|
base_path: &std::path::Path,
|
||||||
|
type_dir: &str,
|
||||||
file_name: &str,
|
file_name: &str,
|
||||||
url: &str,
|
url: &str,
|
||||||
curseforge_key: Option<&str>,
|
curseforge_key: Option<&str>,
|
||||||
|
|
@ -195,9 +209,9 @@ async fn download_file(
|
||||||
match response {
|
match response {
|
||||||
Ok(resp) if resp.status().is_success() => {
|
Ok(resp) if resp.status().is_success() => {
|
||||||
let bytes = resp.bytes().await?;
|
let bytes = resp.bytes().await?;
|
||||||
let mods_dir = base_path.join("mods");
|
let target_dir = base_path.join(type_dir);
|
||||||
fs::create_dir_all(&mods_dir)?;
|
fs::create_dir_all(&target_dir)?;
|
||||||
let dest = mods_dir.join(file_name);
|
let dest = target_dir.join(file_name);
|
||||||
std::fs::write(&dest, &bytes)?;
|
std::fs::write(&dest, &bytes)?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
},
|
},
|
||||||
|
|
@ -287,13 +301,16 @@ impl Effect for CopyOverridesEffect {
|
||||||
&context.config.overrides
|
&context.config.overrides
|
||||||
};
|
};
|
||||||
|
|
||||||
for override_path in overrides {
|
// Expand any glob patterns in override paths
|
||||||
let source = context.base_path.join(override_path);
|
let expanded_paths = expand_override_globs(&context.base_path, overrides);
|
||||||
|
|
||||||
|
for override_path in expanded_paths {
|
||||||
|
let source = context.base_path.join(&override_path);
|
||||||
if !source.exists() {
|
if !source.exists() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let dest = context.export_path.join(override_path);
|
let dest = context.export_path.join(&override_path);
|
||||||
copy_recursive(&source, &dest)?;
|
copy_recursive(&source, &dest)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -334,13 +351,16 @@ impl Effect for CopyServerOverridesEffect {
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(overrides) = server_overrides {
|
if let Some(overrides) = server_overrides {
|
||||||
for override_path in overrides {
|
// Expand any glob patterns in override paths
|
||||||
let source = context.base_path.join(override_path);
|
let expanded_paths = expand_override_globs(&context.base_path, overrides);
|
||||||
|
|
||||||
|
for override_path in expanded_paths {
|
||||||
|
let source = context.base_path.join(&override_path);
|
||||||
if !source.exists() {
|
if !source.exists() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let dest = context.export_path.join(override_path);
|
let dest = context.export_path.join(&override_path);
|
||||||
copy_recursive(&source, &dest)?;
|
copy_recursive(&source, &dest)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -349,7 +369,58 @@ impl Effect for CopyServerOverridesEffect {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rule: Filter client-only projects
|
// Rule: Copy client overrides
|
||||||
|
pub struct CopyClientOverridesRule;
|
||||||
|
|
||||||
|
impl Rule for CopyClientOverridesRule {
|
||||||
|
fn matches(&self, context: &RuleContext) -> bool {
|
||||||
|
context.config.client_overrides.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn effects(&self) -> Vec<Box<dyn Effect>> {
|
||||||
|
vec![Box::new(CopyClientOverridesEffect)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CopyClientOverridesEffect;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Effect for CopyClientOverridesEffect {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"Copying client override files"
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(overrides) = client_overrides {
|
||||||
|
// Expand any glob patterns in override paths
|
||||||
|
let expanded_paths = expand_override_globs(&context.base_path, overrides);
|
||||||
|
|
||||||
|
for override_path in expanded_paths {
|
||||||
|
let source = context.base_path.join(&override_path);
|
||||||
|
if !source.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dest = context.export_path.join(&override_path);
|
||||||
|
copy_recursive(&source, &dest)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule: Filter client-only projects (for server packs)
|
||||||
pub struct FilterClientOnlyRule;
|
pub struct FilterClientOnlyRule;
|
||||||
|
|
||||||
impl Rule for FilterClientOnlyRule {
|
impl Rule for FilterClientOnlyRule {
|
||||||
|
|
@ -367,7 +438,7 @@ pub struct FilterClientOnlyEffect;
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Effect for FilterClientOnlyEffect {
|
impl Effect for FilterClientOnlyEffect {
|
||||||
fn name(&self) -> &'static str {
|
fn name(&self) -> &'static str {
|
||||||
"Filtering client-only mods"
|
"Filtering client-only projects"
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute(&self, context: &RuleContext) -> Result<()> {
|
async fn execute(&self, context: &RuleContext) -> Result<()> {
|
||||||
|
|
@ -383,15 +454,77 @@ impl Effect for FilterClientOnlyEffect {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mods_dir = context.export_path.join("mods");
|
|
||||||
|
|
||||||
for project in &context.lockfile.projects {
|
for project in &context.lockfile.projects {
|
||||||
if project.side == ProjectSide::Client
|
if project.side == ProjectSide::Client
|
||||||
&& let Some(file) = project.files.first()
|
&& let Some(file) = project.files.first()
|
||||||
{
|
{
|
||||||
let file_path = mods_dir.join(&file.file_name);
|
// Get the target directory based on project type and paths 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);
|
||||||
|
|
||||||
if file_path.exists() {
|
if file_path.exists() {
|
||||||
fs::remove_file(file_path)?;
|
fs::remove_file(&file_path)?;
|
||||||
|
log::info!("Filtered client-only project: {}", file.file_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule: Filter server-only projects (for client packs)
|
||||||
|
// This rule respects the `export_server_side_projects_to_client` config option
|
||||||
|
pub struct FilterServerOnlyRule;
|
||||||
|
|
||||||
|
impl Rule for FilterServerOnlyRule {
|
||||||
|
fn matches(&self, _context: &RuleContext) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn effects(&self) -> Vec<Box<dyn Effect>> {
|
||||||
|
vec![Box::new(FilterServerOnlyEffect)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct FilterServerOnlyEffect;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Effect for FilterServerOnlyEffect {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"Filtering server-only projects"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute(&self, context: &RuleContext) -> Result<()> {
|
||||||
|
// Check config option: if true, include server-side projects in client
|
||||||
|
// exports
|
||||||
|
let export_server_to_client = context
|
||||||
|
.config
|
||||||
|
.export_server_side_projects_to_client
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if export_server_to_client {
|
||||||
|
// Don't filter server-only mods - include them in client pack
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
for project in &context.lockfile.projects {
|
||||||
|
if project.side == ProjectSide::Server
|
||||||
|
&& 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 project_dir = context.export_path.join(&type_dir);
|
||||||
|
let file_path = project_dir.join(&file.file_name);
|
||||||
|
|
||||||
|
if file_path.exists() {
|
||||||
|
fs::remove_file(&file_path)?;
|
||||||
|
log::info!(
|
||||||
|
"Filtered server-only project: {} \
|
||||||
|
(export_server_side_projects_to_client=false)",
|
||||||
|
file.file_name
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -418,7 +551,7 @@ pub struct FilterNonRedistributableEffect;
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Effect for FilterNonRedistributableEffect {
|
impl Effect for FilterNonRedistributableEffect {
|
||||||
fn name(&self) -> &'static str {
|
fn name(&self) -> &'static str {
|
||||||
"Filtering non-redistributable mods"
|
"Filtering non-redistributable projects"
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute(&self, context: &RuleContext) -> Result<()> {
|
async fn execute(&self, context: &RuleContext) -> Result<()> {
|
||||||
|
|
@ -435,15 +568,17 @@ impl Effect for FilterNonRedistributableEffect {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mods_dir = context.export_path.join("mods");
|
|
||||||
|
|
||||||
for project in &context.lockfile.projects {
|
for project in &context.lockfile.projects {
|
||||||
if !project.redistributable
|
if !project.redistributable
|
||||||
&& let Some(file) = project.files.first()
|
&& let Some(file) = project.files.first()
|
||||||
{
|
{
|
||||||
let file_path = mods_dir.join(&file.file_name);
|
// Get the target directory based on project type and paths 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);
|
||||||
|
|
||||||
if file_path.exists() {
|
if file_path.exists() {
|
||||||
fs::remove_file(file_path)?;
|
fs::remove_file(&file_path)?;
|
||||||
log::info!("Filtered non-redistributable: {}", file.file_name);
|
log::info!("Filtered non-redistributable: {}", file.file_name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -644,6 +779,69 @@ fn copy_recursive(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
// 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) {
|
||||||
|
return custom_path.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to default paths
|
||||||
|
match project_type {
|
||||||
|
ProjectType::Mod => "mods".to_string(),
|
||||||
|
ProjectType::ResourcePack => "resourcepacks".to_string(),
|
||||||
|
ProjectType::DataPack => "datapacks".to_string(),
|
||||||
|
ProjectType::Shader => "shaderpacks".to_string(),
|
||||||
|
ProjectType::World => "saves".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expand glob patterns in override paths and return all matching paths.
|
||||||
|
/// If a path contains no glob characters, it's returned as-is (if it exists).
|
||||||
|
/// Glob patterns are relative to the `base_path`.
|
||||||
|
fn expand_override_globs(
|
||||||
|
base_path: &std::path::Path,
|
||||||
|
override_paths: &[String],
|
||||||
|
) -> Vec<PathBuf> {
|
||||||
|
let mut results = Vec::new();
|
||||||
|
|
||||||
|
for override_path in override_paths {
|
||||||
|
// Check if the path contains glob characters
|
||||||
|
let has_glob = override_path.contains('*')
|
||||||
|
|| override_path.contains('?')
|
||||||
|
|| override_path.contains('[');
|
||||||
|
|
||||||
|
if has_glob {
|
||||||
|
// Expand the glob pattern relative to base_path
|
||||||
|
let pattern = base_path.join(override_path);
|
||||||
|
let pattern_str = pattern.to_string_lossy();
|
||||||
|
|
||||||
|
match glob(&pattern_str) {
|
||||||
|
Ok(paths) => {
|
||||||
|
for entry in paths.flatten() {
|
||||||
|
// Store the path relative to base_path for consistent handling
|
||||||
|
if let Ok(relative) = entry.strip_prefix(base_path) {
|
||||||
|
results.push(relative.to_path_buf());
|
||||||
|
} else {
|
||||||
|
results.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Invalid glob pattern '{override_path}': {e}");
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Not a glob pattern - use as-is
|
||||||
|
results.push(PathBuf::from(override_path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results
|
||||||
|
}
|
||||||
|
|
||||||
// Rule: Filter projects by platform
|
// Rule: Filter projects by platform
|
||||||
pub struct FilterByPlatformRule;
|
pub struct FilterByPlatformRule;
|
||||||
|
|
||||||
|
|
@ -674,8 +872,6 @@ impl Effect for FilterByPlatformEffect {
|
||||||
if let Some(profile_config) = &context.profile_config
|
if let Some(profile_config) = &context.profile_config
|
||||||
&& let Some(platform) = &profile_config.filter_platform
|
&& let Some(platform) = &profile_config.filter_platform
|
||||||
{
|
{
|
||||||
let mods_dir = context.export_path.join("mods");
|
|
||||||
|
|
||||||
for project in &context.lockfile.projects {
|
for project in &context.lockfile.projects {
|
||||||
// Check if project is available on the target platform
|
// Check if project is available on the target platform
|
||||||
let has_platform = project.get_platform_id(platform).is_some();
|
let has_platform = project.get_platform_id(platform).is_some();
|
||||||
|
|
@ -683,9 +879,14 @@ impl Effect for FilterByPlatformEffect {
|
||||||
if !has_platform {
|
if !has_platform {
|
||||||
// Remove the file if it was copied
|
// Remove the file if it was copied
|
||||||
if let Some(file) = project.files.first() {
|
if let Some(file) = project.files.first() {
|
||||||
let file_path = mods_dir.join(&file.file_name);
|
// Get the target directory based on project type and paths 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);
|
||||||
|
|
||||||
if file_path.exists() {
|
if file_path.exists() {
|
||||||
fs::remove_file(file_path)?;
|
fs::remove_file(&file_path)?;
|
||||||
log::info!(
|
log::info!(
|
||||||
"Filtered {} (not available on {})",
|
"Filtered {} (not available on {})",
|
||||||
file.file_name,
|
file.file_name,
|
||||||
|
|
@ -701,6 +902,301 @@ impl Effect for FilterByPlatformEffect {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rule: Export missing projects as overrides
|
||||||
|
// When a project is not available on the target platform, download it and
|
||||||
|
// include as an override file instead
|
||||||
|
pub struct MissingProjectsAsOverridesRule {
|
||||||
|
target_platform: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MissingProjectsAsOverridesRule {
|
||||||
|
pub fn new(target_platform: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
target_platform: target_platform.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Rule for MissingProjectsAsOverridesRule {
|
||||||
|
fn matches(&self, _context: &RuleContext) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn effects(&self) -> Vec<Box<dyn Effect>> {
|
||||||
|
vec![Box::new(MissingProjectsAsOverridesEffect {
|
||||||
|
target_platform: self.target_platform.clone(),
|
||||||
|
})]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct MissingProjectsAsOverridesEffect {
|
||||||
|
target_platform: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Effect for MissingProjectsAsOverridesEffect {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"Exporting missing projects as overrides"
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
|
||||||
|
for project in &context.lockfile.projects {
|
||||||
|
if !project.export {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if project is available on target platform
|
||||||
|
let has_target_platform =
|
||||||
|
project.get_platform_id(&self.target_platform).is_some();
|
||||||
|
|
||||||
|
if has_target_platform {
|
||||||
|
// Project is available on target platform, skip
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project is missing on target platform - export as override
|
||||||
|
if let Some(file) = project.files.first() {
|
||||||
|
// Find a download URL from any available platform
|
||||||
|
if file.url.is_empty() {
|
||||||
|
log::warn!(
|
||||||
|
"Missing project '{}' has no download URL, skipping",
|
||||||
|
project.get_name()
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 target_dir = overrides_dir.join(&type_dir);
|
||||||
|
fs::create_dir_all(&target_dir)?;
|
||||||
|
|
||||||
|
let dest = target_dir.join(&file.file_name);
|
||||||
|
|
||||||
|
// Download the file
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let mut request = client.get(&file.url);
|
||||||
|
|
||||||
|
// Add auth headers if needed
|
||||||
|
if file.url.contains("curseforge") {
|
||||||
|
if let Some(ref key) = curseforge_key {
|
||||||
|
request = request.header("x-api-key", key);
|
||||||
|
}
|
||||||
|
} else if file.url.contains("modrinth")
|
||||||
|
&& let Some(ref token) = modrinth_token
|
||||||
|
{
|
||||||
|
request = request.header("Authorization", token);
|
||||||
|
}
|
||||||
|
|
||||||
|
match request.send().await {
|
||||||
|
Ok(resp) if resp.status().is_success() => {
|
||||||
|
let bytes = resp.bytes().await?;
|
||||||
|
fs::write(&dest, &bytes)?;
|
||||||
|
log::info!(
|
||||||
|
"Exported missing project '{}' as override (not on {})",
|
||||||
|
project.get_name(),
|
||||||
|
self.target_platform
|
||||||
|
);
|
||||||
|
},
|
||||||
|
Ok(resp) => {
|
||||||
|
log::warn!(
|
||||||
|
"Failed to download missing project '{}': HTTP {}",
|
||||||
|
project.get_name(),
|
||||||
|
resp.status()
|
||||||
|
);
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(
|
||||||
|
"Failed to download missing project '{}': {}",
|
||||||
|
project.get_name(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule: Text replacement in exported files
|
||||||
|
// Replaces template variables like ${MC_VERSION}, ${PACK_NAME}, etc.
|
||||||
|
pub struct TextReplacementRule;
|
||||||
|
|
||||||
|
impl Rule for TextReplacementRule {
|
||||||
|
fn matches(&self, _context: &RuleContext) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn effects(&self) -> Vec<Box<dyn Effect>> {
|
||||||
|
vec![Box::new(TextReplacementEffect)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TextReplacementEffect;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Effect for TextReplacementEffect {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"Applying text replacements"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute(&self, context: &RuleContext) -> Result<()> {
|
||||||
|
// Build replacement map from context
|
||||||
|
let mut replacements: std::collections::HashMap<&str, String> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
|
||||||
|
// Pack metadata
|
||||||
|
replacements.insert("${PACK_NAME}", context.config.name.clone());
|
||||||
|
replacements.insert("${PACK_VERSION}", context.config.version.clone());
|
||||||
|
replacements.insert(
|
||||||
|
"${PACK_AUTHOR}",
|
||||||
|
context.config.author.clone().unwrap_or_default(),
|
||||||
|
);
|
||||||
|
replacements.insert(
|
||||||
|
"${PACK_DESCRIPTION}",
|
||||||
|
context.config.description.clone().unwrap_or_default(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Minecraft version
|
||||||
|
replacements.insert(
|
||||||
|
"${MC_VERSION}",
|
||||||
|
context
|
||||||
|
.lockfile
|
||||||
|
.mc_versions
|
||||||
|
.first()
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default(),
|
||||||
|
);
|
||||||
|
replacements
|
||||||
|
.insert("${MC_VERSIONS}", context.lockfile.mc_versions.join(", "));
|
||||||
|
|
||||||
|
// Loader info
|
||||||
|
if let Some((name, version)) = context.lockfile.loaders.iter().next() {
|
||||||
|
replacements.insert("${LOADER}", name.clone());
|
||||||
|
replacements.insert("${LOADER_VERSION}", version.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// All loaders
|
||||||
|
replacements.insert(
|
||||||
|
"${LOADERS}",
|
||||||
|
context
|
||||||
|
.lockfile
|
||||||
|
.loaders
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| format!("{k}={v}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", "),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Project count
|
||||||
|
replacements.insert(
|
||||||
|
"${PROJECT_COUNT}",
|
||||||
|
context.lockfile.projects.len().to_string(),
|
||||||
|
);
|
||||||
|
replacements.insert(
|
||||||
|
"${MOD_COUNT}",
|
||||||
|
context
|
||||||
|
.lockfile
|
||||||
|
.projects
|
||||||
|
.iter()
|
||||||
|
.filter(|p| p.r#type == ProjectType::Mod)
|
||||||
|
.count()
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Process text files in the export directory
|
||||||
|
process_text_files(&context.export_path, &replacements)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process text files in a directory, applying replacements
|
||||||
|
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",
|
||||||
|
"json",
|
||||||
|
"toml",
|
||||||
|
"yaml",
|
||||||
|
"yml",
|
||||||
|
"cfg",
|
||||||
|
"conf",
|
||||||
|
"properties",
|
||||||
|
"lang",
|
||||||
|
"mcmeta",
|
||||||
|
"html",
|
||||||
|
"htm",
|
||||||
|
"xml",
|
||||||
|
];
|
||||||
|
|
||||||
|
for entry in walkdir::WalkDir::new(dir)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(std::result::Result::ok)
|
||||||
|
.filter(|e| e.file_type().is_file())
|
||||||
|
{
|
||||||
|
let path = entry.path();
|
||||||
|
|
||||||
|
// Check if file extension is in our list
|
||||||
|
let should_process = path
|
||||||
|
.extension()
|
||||||
|
.and_then(|ext| ext.to_str())
|
||||||
|
.is_some_and(|ext| {
|
||||||
|
TEXT_EXTENSIONS.contains(&ext.to_lowercase().as_str())
|
||||||
|
});
|
||||||
|
|
||||||
|
if !should_process {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read file content
|
||||||
|
let content = match fs::read_to_string(path) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => continue, // Skip binary files or unreadable files
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if any replacements are needed
|
||||||
|
let needs_replacement =
|
||||||
|
replacements.keys().any(|key| content.contains(*key));
|
||||||
|
|
||||||
|
if !needs_replacement {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply replacements
|
||||||
|
let mut new_content = content;
|
||||||
|
for (pattern, replacement) in replacements {
|
||||||
|
new_content = new_content.replace(*pattern, replacement);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write back
|
||||||
|
fs::write(path, new_content)?;
|
||||||
|
log::debug!("Applied text replacements to: {}", path.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
@ -721,16 +1217,21 @@ mod tests {
|
||||||
lockfile_version: 1,
|
lockfile_version: 1,
|
||||||
},
|
},
|
||||||
config: Config {
|
config: Config {
|
||||||
name: "Test Pack".to_string(),
|
name: "Test Pack".to_string(),
|
||||||
version: "1.0.0".to_string(),
|
version: "1.0.0".to_string(),
|
||||||
description: None,
|
description: None,
|
||||||
author: None,
|
author: None,
|
||||||
overrides: vec!["overrides".to_string()],
|
overrides: vec!["overrides".to_string()],
|
||||||
server_overrides: Some(vec!["server-overrides".to_string()]),
|
server_overrides: Some(vec![
|
||||||
client_overrides: Some(vec!["client-overrides".to_string()]),
|
"server-overrides".to_string(),
|
||||||
paths: HashMap::new(),
|
]),
|
||||||
projects: None,
|
client_overrides: Some(vec![
|
||||||
export_profiles: None,
|
"client-overrides".to_string(),
|
||||||
|
]),
|
||||||
|
paths: HashMap::new(),
|
||||||
|
projects: None,
|
||||||
|
export_profiles: None,
|
||||||
|
export_server_side_projects_to_client: None,
|
||||||
},
|
},
|
||||||
profile_config,
|
profile_config,
|
||||||
export_path: PathBuf::from("/tmp/export"),
|
export_path: PathBuf::from("/tmp/export"),
|
||||||
|
|
@ -846,4 +1347,183 @@ mod tests {
|
||||||
assert!(context.profile_config.is_none());
|
assert!(context.profile_config.is_none());
|
||||||
assert_eq!(context.config.overrides, vec!["overrides"]);
|
assert_eq!(context.config.overrides, vec!["overrides"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_project_type_dir_default_paths() {
|
||||||
|
let config = Config {
|
||||||
|
name: "Test".to_string(),
|
||||||
|
version: "1.0.0".to_string(),
|
||||||
|
description: None,
|
||||||
|
author: None,
|
||||||
|
overrides: vec![],
|
||||||
|
server_overrides: None,
|
||||||
|
client_overrides: None,
|
||||||
|
paths: HashMap::new(),
|
||||||
|
projects: None,
|
||||||
|
export_profiles: None,
|
||||||
|
export_server_side_projects_to_client: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(get_project_type_dir(&ProjectType::Mod, &config), "mods");
|
||||||
|
assert_eq!(
|
||||||
|
get_project_type_dir(&ProjectType::ResourcePack, &config),
|
||||||
|
"resourcepacks"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
get_project_type_dir(&ProjectType::DataPack, &config),
|
||||||
|
"datapacks"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
get_project_type_dir(&ProjectType::Shader, &config),
|
||||||
|
"shaderpacks"
|
||||||
|
);
|
||||||
|
assert_eq!(get_project_type_dir(&ProjectType::World, &config), "saves");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_project_type_dir_custom_paths() {
|
||||||
|
let mut paths = HashMap::new();
|
||||||
|
paths.insert("mod".to_string(), "custom-mods".to_string());
|
||||||
|
paths.insert("resource-pack".to_string(), "custom-rp".to_string());
|
||||||
|
|
||||||
|
let config = Config {
|
||||||
|
name: "Test".to_string(),
|
||||||
|
version: "1.0.0".to_string(),
|
||||||
|
description: None,
|
||||||
|
author: None,
|
||||||
|
overrides: vec![],
|
||||||
|
server_overrides: None,
|
||||||
|
client_overrides: None,
|
||||||
|
paths,
|
||||||
|
projects: None,
|
||||||
|
export_profiles: None,
|
||||||
|
export_server_side_projects_to_client: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
get_project_type_dir(&ProjectType::Mod, &config),
|
||||||
|
"custom-mods"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
get_project_type_dir(&ProjectType::ResourcePack, &config),
|
||||||
|
"custom-rp"
|
||||||
|
);
|
||||||
|
// Non-customized type should use default
|
||||||
|
assert_eq!(
|
||||||
|
get_project_type_dir(&ProjectType::Shader, &config),
|
||||||
|
"shaderpacks"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_expand_override_globs_no_globs() {
|
||||||
|
let base_path = PathBuf::from("/tmp/test");
|
||||||
|
let overrides = vec!["overrides".to_string(), "config".to_string()];
|
||||||
|
|
||||||
|
let result = expand_override_globs(&base_path, &overrides);
|
||||||
|
|
||||||
|
assert_eq!(result.len(), 2);
|
||||||
|
assert_eq!(result[0], PathBuf::from("overrides"));
|
||||||
|
assert_eq!(result[1], PathBuf::from("config"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_expand_override_globs_detects_glob_characters() {
|
||||||
|
// Just test that glob characters are detected - actual expansion
|
||||||
|
// requires the files to exist
|
||||||
|
let base_path = PathBuf::from("/nonexistent");
|
||||||
|
let overrides = vec![
|
||||||
|
"overrides/*.txt".to_string(),
|
||||||
|
"config/**/*.json".to_string(),
|
||||||
|
"data/[abc].txt".to_string(),
|
||||||
|
"simple".to_string(),
|
||||||
|
];
|
||||||
|
|
||||||
|
let result = expand_override_globs(&base_path, &overrides);
|
||||||
|
|
||||||
|
// Glob patterns that don't match anything return empty
|
||||||
|
// Only the non-glob path should be returned as-is
|
||||||
|
assert!(result.contains(&PathBuf::from("simple")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_client_overrides_rule_matches() {
|
||||||
|
let mut config = Config {
|
||||||
|
name: "Test".to_string(),
|
||||||
|
version: "1.0.0".to_string(),
|
||||||
|
description: None,
|
||||||
|
author: None,
|
||||||
|
overrides: vec![],
|
||||||
|
server_overrides: None,
|
||||||
|
client_overrides: Some(vec![
|
||||||
|
"client-data".to_string(),
|
||||||
|
]),
|
||||||
|
paths: HashMap::new(),
|
||||||
|
projects: None,
|
||||||
|
export_profiles: None,
|
||||||
|
export_server_side_projects_to_client: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut context = create_test_context(None);
|
||||||
|
context.config = config.clone();
|
||||||
|
|
||||||
|
let rule = CopyClientOverridesRule;
|
||||||
|
assert!(rule.matches(&context));
|
||||||
|
|
||||||
|
// Without client_overrides, should not match
|
||||||
|
config.client_overrides = None;
|
||||||
|
context.config = config;
|
||||||
|
assert!(!rule.matches(&context));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_server_overrides_rule_matches() {
|
||||||
|
let mut config = Config {
|
||||||
|
name: "Test".to_string(),
|
||||||
|
version: "1.0.0".to_string(),
|
||||||
|
description: None,
|
||||||
|
author: None,
|
||||||
|
overrides: vec![],
|
||||||
|
server_overrides: Some(vec![
|
||||||
|
"server-data".to_string(),
|
||||||
|
]),
|
||||||
|
client_overrides: None,
|
||||||
|
paths: HashMap::new(),
|
||||||
|
projects: None,
|
||||||
|
export_profiles: None,
|
||||||
|
export_server_side_projects_to_client: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut context = create_test_context(None);
|
||||||
|
context.config = config.clone();
|
||||||
|
|
||||||
|
let rule = CopyServerOverridesRule;
|
||||||
|
assert!(rule.matches(&context));
|
||||||
|
|
||||||
|
// Without server_overrides, should not match
|
||||||
|
config.server_overrides = None;
|
||||||
|
context.config = config;
|
||||||
|
assert!(!rule.matches(&context));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filter_server_only_rule_always_matches() {
|
||||||
|
let context = create_test_context(None);
|
||||||
|
let rule = FilterServerOnlyRule;
|
||||||
|
assert!(rule.matches(&context));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_text_replacement_rule_always_matches() {
|
||||||
|
let context = create_test_context(None);
|
||||||
|
let rule = TextReplacementRule;
|
||||||
|
assert!(rule.matches(&context));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_missing_projects_rule_always_matches() {
|
||||||
|
let context = create_test_context(None);
|
||||||
|
let rule = MissingProjectsAsOverridesRule::new("modrinth");
|
||||||
|
assert!(rule.matches(&context));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
241
src/fetch.rs
241
src/fetch.rs
|
|
@ -1,10 +1,13 @@
|
||||||
use std::{
|
use std::{
|
||||||
fs,
|
fs,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
|
sync::Arc,
|
||||||
};
|
};
|
||||||
|
|
||||||
use indicatif::{ProgressBar, ProgressStyle};
|
use futures::future::join_all;
|
||||||
|
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
|
use tokio::sync::Semaphore;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
error::{PakkerError, Result},
|
error::{PakkerError, Result},
|
||||||
|
|
@ -12,14 +15,19 @@ use crate::{
|
||||||
utils::verify_hash,
|
utils::verify_hash,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Maximum number of concurrent downloads
|
||||||
|
const MAX_CONCURRENT_DOWNLOADS: usize = 8;
|
||||||
|
|
||||||
pub struct Fetcher {
|
pub struct Fetcher {
|
||||||
client: Client,
|
client: Client,
|
||||||
base_path: PathBuf,
|
base_path: PathBuf,
|
||||||
|
shelve: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct FileFetcher {
|
pub struct FileFetcher {
|
||||||
client: Client,
|
client: Client,
|
||||||
base_path: PathBuf,
|
base_path: PathBuf,
|
||||||
|
shelve: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Fetcher {
|
impl Fetcher {
|
||||||
|
|
@ -27,9 +35,15 @@ impl Fetcher {
|
||||||
Self {
|
Self {
|
||||||
client: Client::new(),
|
client: Client::new(),
|
||||||
base_path: base_path.as_ref().to_path_buf(),
|
base_path: base_path.as_ref().to_path_buf(),
|
||||||
|
shelve: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub const fn with_shelve(mut self, shelve: bool) -> Self {
|
||||||
|
self.shelve = shelve;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn fetch_all(
|
pub async fn fetch_all(
|
||||||
&self,
|
&self,
|
||||||
lockfile: &LockFile,
|
lockfile: &LockFile,
|
||||||
|
|
@ -38,6 +52,7 @@ impl Fetcher {
|
||||||
let fetcher = FileFetcher {
|
let fetcher = FileFetcher {
|
||||||
client: self.client.clone(),
|
client: self.client.clone(),
|
||||||
base_path: self.base_path.clone(),
|
base_path: self.base_path.clone(),
|
||||||
|
shelve: self.shelve,
|
||||||
};
|
};
|
||||||
fetcher.fetch_all(lockfile, config).await
|
fetcher.fetch_all(lockfile, config).await
|
||||||
}
|
}
|
||||||
|
|
@ -48,7 +63,7 @@ impl Fetcher {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FileFetcher {
|
impl FileFetcher {
|
||||||
/// Fetch all project files according to lockfile
|
/// Fetch all project files according to lockfile with parallel downloads
|
||||||
pub async fn fetch_all(
|
pub async fn fetch_all(
|
||||||
&self,
|
&self,
|
||||||
lockfile: &LockFile,
|
lockfile: &LockFile,
|
||||||
|
|
@ -58,25 +73,104 @@ impl FileFetcher {
|
||||||
lockfile.projects.iter().filter(|p| p.export).collect();
|
lockfile.projects.iter().filter(|p| p.export).collect();
|
||||||
|
|
||||||
let total = exportable_projects.len();
|
let total = exportable_projects.len();
|
||||||
let spinner = ProgressBar::new(total as u64);
|
|
||||||
spinner.set_style(
|
|
||||||
ProgressStyle::default_spinner()
|
|
||||||
.template("{spinner:.green} {msg}")
|
|
||||||
.unwrap(),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (idx, project) in exportable_projects.iter().enumerate() {
|
if total == 0 {
|
||||||
let name = project
|
log::info!("No projects to fetch");
|
||||||
.name
|
return Ok(());
|
||||||
.values()
|
|
||||||
.next()
|
|
||||||
.map_or("unknown", std::string::String::as_str);
|
|
||||||
|
|
||||||
spinner.set_message(format!("Fetching {} ({}/{})", name, idx + 1, total));
|
|
||||||
self.fetch_project(project, lockfile, config).await?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
spinner.finish_with_message("All projects fetched");
|
// Set up multi-progress for parallel download tracking
|
||||||
|
let multi_progress = MultiProgress::new();
|
||||||
|
let overall_bar = multi_progress.add(ProgressBar::new(total as u64));
|
||||||
|
overall_bar.set_style(
|
||||||
|
ProgressStyle::default_bar()
|
||||||
|
.template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}")
|
||||||
|
.unwrap()
|
||||||
|
.progress_chars("#>-"),
|
||||||
|
);
|
||||||
|
overall_bar.set_message("Fetching projects...");
|
||||||
|
|
||||||
|
// Use a semaphore to limit concurrent downloads
|
||||||
|
let semaphore = Arc::new(Semaphore::new(MAX_CONCURRENT_DOWNLOADS));
|
||||||
|
|
||||||
|
// Prepare download tasks
|
||||||
|
let download_tasks: Vec<_> = exportable_projects
|
||||||
|
.iter()
|
||||||
|
.map(|project| {
|
||||||
|
let semaphore = Arc::clone(&semaphore);
|
||||||
|
let client = self.client.clone();
|
||||||
|
let base_path = self.base_path.clone();
|
||||||
|
let lockfile = lockfile.clone();
|
||||||
|
let config = config.clone();
|
||||||
|
let project = (*project).clone();
|
||||||
|
let overall_bar = overall_bar.clone();
|
||||||
|
|
||||||
|
async move {
|
||||||
|
// Acquire semaphore permit to limit concurrency
|
||||||
|
let _permit = semaphore.acquire().await.map_err(|_| {
|
||||||
|
PakkerError::InternalError("Semaphore acquisition failed".into())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let name = project
|
||||||
|
.name
|
||||||
|
.values()
|
||||||
|
.next()
|
||||||
|
.map_or("unknown".to_string(), std::clone::Clone::clone);
|
||||||
|
|
||||||
|
let fetcher = Self {
|
||||||
|
client,
|
||||||
|
base_path,
|
||||||
|
shelve: false, // Shelving happens at sync level, not per-project
|
||||||
|
};
|
||||||
|
|
||||||
|
let result =
|
||||||
|
fetcher.fetch_project(&project, &lockfile, &config).await;
|
||||||
|
|
||||||
|
// Update progress bar
|
||||||
|
overall_bar.inc(1);
|
||||||
|
|
||||||
|
match &result {
|
||||||
|
Ok(()) => {
|
||||||
|
log::debug!("Successfully fetched: {name}");
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to fetch {name}: {e}");
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result.map(|()| name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Execute all downloads in parallel (limited by semaphore)
|
||||||
|
let results = join_all(download_tasks).await;
|
||||||
|
|
||||||
|
overall_bar.finish_with_message("All projects fetched");
|
||||||
|
|
||||||
|
// Collect and report errors
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
let mut success_count = 0;
|
||||||
|
|
||||||
|
for result in results {
|
||||||
|
match result {
|
||||||
|
Ok(_) => success_count += 1,
|
||||||
|
Err(e) => errors.push(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("Fetch complete: {success_count}/{total} successful");
|
||||||
|
|
||||||
|
if !errors.is_empty() {
|
||||||
|
// Return the first error, but log all of them
|
||||||
|
for (idx, error) in errors.iter().enumerate() {
|
||||||
|
log::error!("Download error {}: {}", idx + 1, error);
|
||||||
|
}
|
||||||
|
return Err(errors.remove(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle unknown files (shelve or delete)
|
||||||
|
self.handle_unknown_files(lockfile, config)?;
|
||||||
|
|
||||||
// Sync overrides
|
// Sync overrides
|
||||||
self.sync_overrides(config)?;
|
self.sync_overrides(config)?;
|
||||||
|
|
@ -84,6 +178,117 @@ impl FileFetcher {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handle unknown project files that aren't in the lockfile.
|
||||||
|
/// If shelve is true, moves them to a shelf directory.
|
||||||
|
/// Otherwise, deletes them.
|
||||||
|
fn handle_unknown_files(
|
||||||
|
&self,
|
||||||
|
lockfile: &LockFile,
|
||||||
|
config: &Config,
|
||||||
|
) -> Result<()> {
|
||||||
|
// Collect all expected file names from lockfile
|
||||||
|
let expected_files: std::collections::HashSet<String> = lockfile
|
||||||
|
.projects
|
||||||
|
.iter()
|
||||||
|
.filter(|p| p.export)
|
||||||
|
.filter_map(|p| p.files.first().map(|f| f.file_name.clone()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Check each project type directory
|
||||||
|
let project_dirs = [
|
||||||
|
(
|
||||||
|
"mod",
|
||||||
|
self.get_default_path(&crate::model::ProjectType::Mod),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"resource-pack",
|
||||||
|
self.get_default_path(&crate::model::ProjectType::ResourcePack),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"shader",
|
||||||
|
self.get_default_path(&crate::model::ProjectType::Shader),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"data-pack",
|
||||||
|
self.get_default_path(&crate::model::ProjectType::DataPack),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"world",
|
||||||
|
self.get_default_path(&crate::model::ProjectType::World),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Also check custom paths from config
|
||||||
|
let mut dirs_to_check: Vec<PathBuf> = project_dirs
|
||||||
|
.iter()
|
||||||
|
.map(|(_, dir)| self.base_path.join(dir))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for custom_path in config.paths.values() {
|
||||||
|
dirs_to_check.push(self.base_path.join(custom_path));
|
||||||
|
}
|
||||||
|
|
||||||
|
let shelf_dir = self.base_path.join(".pakker-shelf");
|
||||||
|
let mut shelved_count = 0;
|
||||||
|
let mut deleted_count = 0;
|
||||||
|
|
||||||
|
for dir in dirs_to_check {
|
||||||
|
if !dir.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let entries = match fs::read_dir(&dir) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if !path.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_name = match path.file_name().and_then(|n| n.to_str()) {
|
||||||
|
Some(name) => name.to_string(),
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Skip if file is expected
|
||||||
|
if expected_files.contains(&file_name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip non-jar files (might be configs, etc.)
|
||||||
|
if !file_name.ends_with(".jar") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.shelve {
|
||||||
|
// Move to shelf
|
||||||
|
fs::create_dir_all(&shelf_dir)?;
|
||||||
|
let shelf_path = shelf_dir.join(&file_name);
|
||||||
|
fs::rename(&path, &shelf_path)?;
|
||||||
|
log::info!("Shelved unknown file: {file_name} -> .pakker-shelf/");
|
||||||
|
shelved_count += 1;
|
||||||
|
} else {
|
||||||
|
// Delete unknown file
|
||||||
|
fs::remove_file(&path)?;
|
||||||
|
log::info!("Deleted unknown file: {file_name}");
|
||||||
|
deleted_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if shelved_count > 0 {
|
||||||
|
log::info!("Shelved {shelved_count} unknown file(s) to .pakker-shelf/");
|
||||||
|
}
|
||||||
|
if deleted_count > 0 {
|
||||||
|
log::info!("Deleted {deleted_count} unknown file(s)");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Fetch files for a single project
|
/// Fetch files for a single project
|
||||||
pub async fn fetch_project(
|
pub async fn fetch_project(
|
||||||
&self,
|
&self,
|
||||||
|
|
|
||||||
|
|
@ -288,10 +288,9 @@ pub fn detect_vcs_type<P: AsRef<Path>>(path: P) -> VcsType {
|
||||||
.args(["root"])
|
.args(["root"])
|
||||||
.current_dir(path)
|
.current_dir(path)
|
||||||
.output()
|
.output()
|
||||||
|
&& output.status.success()
|
||||||
{
|
{
|
||||||
if output.status.success() {
|
return VcsType::Jujutsu;
|
||||||
return VcsType::Jujutsu;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for git
|
// Check for git
|
||||||
|
|
@ -299,10 +298,9 @@ pub fn detect_vcs_type<P: AsRef<Path>>(path: P) -> VcsType {
|
||||||
.args(["rev-parse", "--show-toplevel"])
|
.args(["rev-parse", "--show-toplevel"])
|
||||||
.current_dir(path)
|
.current_dir(path)
|
||||||
.output()
|
.output()
|
||||||
|
&& output.status.success()
|
||||||
{
|
{
|
||||||
if output.status.success() {
|
return VcsType::Git;
|
||||||
return VcsType::Git;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
VcsType::None
|
VcsType::None
|
||||||
|
|
@ -333,7 +331,7 @@ pub fn repo_has_uncommitted_changes<P: AsRef<Path>>(path: P) -> Result<bool> {
|
||||||
.current_dir(path)
|
.current_dir(path)
|
||||||
.output()
|
.output()
|
||||||
.map_err(|e| {
|
.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);
|
let output_str = String::from_utf8_lossy(&output.stdout);
|
||||||
|
|
|
||||||
16
src/http.rs
Normal file
16
src/http.rs
Normal 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")
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,7 @@ use std::{
|
||||||
fs::{self, File, OpenOptions},
|
fs::{self, File, OpenOptions},
|
||||||
io::Write,
|
io::Write,
|
||||||
os::unix::{fs::PermissionsExt, io::AsRawFd},
|
os::unix::{fs::PermissionsExt, io::AsRawFd},
|
||||||
path::PathBuf,
|
path::{Path, PathBuf},
|
||||||
time::{Duration, SystemTime},
|
time::{Duration, SystemTime},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -108,7 +108,7 @@ impl IpcCoordinator {
|
||||||
|
|
||||||
/// Extract modpack hash from pakku.json's parentLockHash field.
|
/// Extract modpack hash from pakku.json's parentLockHash field.
|
||||||
/// This is the authoritative content hash for the modpack (Nix-style).
|
/// This is the authoritative content hash for the modpack (Nix-style).
|
||||||
fn get_modpack_hash(working_dir: &PathBuf) -> Result<String, IpcError> {
|
fn get_modpack_hash(working_dir: &Path) -> Result<String, IpcError> {
|
||||||
let pakku_path = working_dir.join("pakku.json");
|
let pakku_path = working_dir.join("pakku.json");
|
||||||
|
|
||||||
if !pakku_path.exists() {
|
if !pakku_path.exists() {
|
||||||
|
|
@ -147,7 +147,7 @@ impl IpcCoordinator {
|
||||||
|
|
||||||
/// Create a new IPC coordinator for the given modpack directory.
|
/// Create a new IPC coordinator for the given modpack directory.
|
||||||
/// Uses parentLockHash from pakku.json to identify the modpack.
|
/// Uses parentLockHash from pakku.json to identify the modpack.
|
||||||
pub fn new(working_dir: &PathBuf) -> Result<Self, IpcError> {
|
pub fn new(working_dir: &Path) -> Result<Self, IpcError> {
|
||||||
let modpack_hash = Self::get_modpack_hash(working_dir)?;
|
let modpack_hash = Self::get_modpack_hash(working_dir)?;
|
||||||
let ipc_base = Self::get_ipc_base_dir();
|
let ipc_base = Self::get_ipc_base_dir();
|
||||||
let ipc_dir = ipc_base.join(&modpack_hash);
|
let ipc_dir = ipc_base.join(&modpack_hash);
|
||||||
|
|
@ -187,6 +187,7 @@ impl IpcCoordinator {
|
||||||
.read(true)
|
.read(true)
|
||||||
.write(true)
|
.write(true)
|
||||||
.create(true)
|
.create(true)
|
||||||
|
.truncate(false)
|
||||||
.open(&self.ops_file)
|
.open(&self.ops_file)
|
||||||
.map_err(|e| IpcError::InvalidFormat(e.to_string()))?;
|
.map_err(|e| IpcError::InvalidFormat(e.to_string()))?;
|
||||||
|
|
||||||
|
|
|
||||||
11
src/main.rs
11
src/main.rs
|
|
@ -1,8 +1,15 @@
|
||||||
|
// Allow pre-existing clippy warnings for functions with many arguments
|
||||||
|
// and complex types that would require significant refactoring
|
||||||
|
#![allow(clippy::too_many_arguments)]
|
||||||
|
#![allow(clippy::type_complexity)]
|
||||||
|
#![allow(clippy::large_enum_variant)]
|
||||||
|
|
||||||
mod cli;
|
mod cli;
|
||||||
mod error;
|
mod error;
|
||||||
mod export;
|
mod export;
|
||||||
mod fetch;
|
mod fetch;
|
||||||
mod git;
|
mod git;
|
||||||
|
mod http;
|
||||||
mod ipc;
|
mod ipc;
|
||||||
mod model;
|
mod model;
|
||||||
mod platform;
|
mod platform;
|
||||||
|
|
@ -17,8 +24,6 @@ use clap::Parser;
|
||||||
use cli::{Cli, Commands};
|
use cli::{Cli, Commands};
|
||||||
use error::PakkerError;
|
use error::PakkerError;
|
||||||
|
|
||||||
use crate::rate_limiter::RateLimiter;
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), PakkerError> {
|
async fn main() -> Result<(), PakkerError> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
|
@ -42,8 +47,6 @@ async fn main() -> Result<(), PakkerError> {
|
||||||
let lockfile_path = working_dir.join("pakker-lock.json");
|
let lockfile_path = working_dir.join("pakker-lock.json");
|
||||||
let config_path = working_dir.join("pakker.json");
|
let config_path = working_dir.join("pakker.json");
|
||||||
|
|
||||||
let _rate_limiter = std::sync::Arc::new(RateLimiter::new(None));
|
|
||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Commands::Init(args) => {
|
Commands::Init(args) => {
|
||||||
cli::commands::init::execute(args, &lockfile_path, &config_path).await
|
cli::commands::init::execute(args, &lockfile_path, &config_path).await
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,6 @@ use crate::error::{PakkerError, Result};
|
||||||
|
|
||||||
const CONFIG_NAME: &str = "pakker.json";
|
const CONFIG_NAME: &str = "pakker.json";
|
||||||
|
|
||||||
// Pakker config wrapper - supports both Pakker (direct) and Pakku (wrapped)
|
|
||||||
// formats
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(untagged)]
|
#[serde(untagged)]
|
||||||
enum ConfigWrapper {
|
enum ConfigWrapper {
|
||||||
|
|
@ -43,39 +41,45 @@ pub struct ParentConfig {
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub version: String,
|
pub version: String,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub author: Option<String>,
|
pub author: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub overrides: Vec<String>,
|
pub overrides: Vec<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub server_overrides: Option<Vec<String>>,
|
pub server_overrides: Option<Vec<String>>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub client_overrides: Option<Vec<String>>,
|
pub client_overrides: Option<Vec<String>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub paths: HashMap<String, String>,
|
pub paths: HashMap<String, String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub projects: Option<HashMap<String, ProjectConfig>>,
|
pub projects: Option<HashMap<String, ProjectConfig>>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub export_profiles: Option<HashMap<String, crate::export::ProfileConfig>>,
|
pub export_profiles: Option<HashMap<String, crate::export::ProfileConfig>>,
|
||||||
|
#[serde(
|
||||||
|
skip_serializing_if = "Option::is_none",
|
||||||
|
rename = "exportServerSideProjectsToClient"
|
||||||
|
)]
|
||||||
|
pub export_server_side_projects_to_client: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Config {
|
impl Default for Config {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
name: String::new(),
|
name: String::new(),
|
||||||
version: String::new(),
|
version: String::new(),
|
||||||
description: None,
|
description: None,
|
||||||
author: None,
|
author: None,
|
||||||
overrides: vec!["overrides".to_string()],
|
overrides: vec!["overrides".to_string()],
|
||||||
server_overrides: None,
|
server_overrides: None,
|
||||||
client_overrides: None,
|
client_overrides: None,
|
||||||
paths: HashMap::new(),
|
paths: HashMap::new(),
|
||||||
projects: Some(HashMap::new()),
|
projects: Some(HashMap::new()),
|
||||||
export_profiles: None,
|
export_profiles: None,
|
||||||
|
export_server_side_projects_to_client: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -105,21 +109,16 @@ impl Config {
|
||||||
let content =
|
let content =
|
||||||
std::fs::read_to_string(&path).map_err(PakkerError::IoError)?;
|
std::fs::read_to_string(&path).map_err(PakkerError::IoError)?;
|
||||||
|
|
||||||
// Try to parse as ConfigWrapper (supports both Pakker and Pakku formats)
|
|
||||||
match serde_json::from_str::<ConfigWrapper>(&content) {
|
match serde_json::from_str::<ConfigWrapper>(&content) {
|
||||||
Ok(ConfigWrapper::Pakker(config)) => {
|
Ok(ConfigWrapper::Pakker(config)) => {
|
||||||
config.validate()?;
|
config.validate()?;
|
||||||
Ok(config)
|
Ok(config)
|
||||||
},
|
},
|
||||||
Ok(ConfigWrapper::Pakku { pakku }) => {
|
Ok(ConfigWrapper::Pakku { pakku }) => {
|
||||||
// Convert Pakku format to Pakker format
|
|
||||||
// Pakku format doesn't have name/version, use parent repo info as
|
|
||||||
// fallback
|
|
||||||
let name = pakku
|
let name = pakku
|
||||||
.parent
|
.parent
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|p| {
|
.map(|p| {
|
||||||
// Extract repo name from URL
|
|
||||||
p.id
|
p.id
|
||||||
.split('/')
|
.split('/')
|
||||||
.next_back()
|
.next_back()
|
||||||
|
|
@ -145,6 +144,7 @@ impl Config {
|
||||||
paths: HashMap::new(),
|
paths: HashMap::new(),
|
||||||
projects: Some(pakku.projects),
|
projects: Some(pakku.projects),
|
||||||
export_profiles: None,
|
export_profiles: None,
|
||||||
|
export_server_side_projects_to_client: None,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
Err(e) => Err(PakkerError::InvalidConfigFile(e.to_string())),
|
Err(e) => Err(PakkerError::InvalidConfigFile(e.to_string())),
|
||||||
|
|
@ -153,17 +153,12 @@ impl Config {
|
||||||
|
|
||||||
pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<()> {
|
pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<()> {
|
||||||
self.validate()?;
|
self.validate()?;
|
||||||
|
|
||||||
let path = path.as_ref().join(CONFIG_NAME);
|
let path = path.as_ref().join(CONFIG_NAME);
|
||||||
|
|
||||||
// Write to temporary file first (atomic write)
|
|
||||||
let temp_path = path.with_extension("tmp");
|
let temp_path = path.with_extension("tmp");
|
||||||
let content = serde_json::to_string_pretty(self)
|
let content = serde_json::to_string_pretty(self)
|
||||||
.map_err(PakkerError::SerializationError)?;
|
.map_err(PakkerError::SerializationError)?;
|
||||||
|
|
||||||
std::fs::write(&temp_path, content)?;
|
std::fs::write(&temp_path, content)?;
|
||||||
std::fs::rename(temp_path, path)?;
|
std::fs::rename(temp_path, path)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -175,27 +170,39 @@ impl Config {
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_project_config(&self, project_id: &str) -> Option<&ProjectConfig> {
|
||||||
|
self.projects.as_ref()?.get(project_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_project_config(
|
||||||
|
&mut self,
|
||||||
|
project_id: String,
|
||||||
|
project_config: ProjectConfig,
|
||||||
|
) {
|
||||||
|
let projects = self.projects.get_or_insert_with(HashMap::new);
|
||||||
|
projects.insert(project_id, project_config);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use tempfile::TempDir;
|
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_config_new() {
|
fn test_config_new() {
|
||||||
let config = Config {
|
let config = Config {
|
||||||
name: "test-pack".to_string(),
|
name: "test-pack".to_string(),
|
||||||
version: "1.0.0".to_string(),
|
version: "1.0.0".to_string(),
|
||||||
description: None,
|
description: None,
|
||||||
author: None,
|
author: None,
|
||||||
overrides: vec!["overrides".to_string()],
|
overrides: vec!["overrides".to_string()],
|
||||||
server_overrides: None,
|
server_overrides: None,
|
||||||
client_overrides: None,
|
client_overrides: None,
|
||||||
paths: HashMap::new(),
|
paths: HashMap::new(),
|
||||||
projects: None,
|
projects: None,
|
||||||
export_profiles: None,
|
export_profiles: None,
|
||||||
|
export_server_side_projects_to_client: None,
|
||||||
};
|
};
|
||||||
assert_eq!(config.name, "test-pack");
|
assert_eq!(config.name, "test-pack");
|
||||||
assert_eq!(config.version, "1.0.0");
|
assert_eq!(config.version, "1.0.0");
|
||||||
|
|
@ -206,178 +213,26 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn test_config_serialization() {
|
fn test_config_serialization() {
|
||||||
let mut config = Config {
|
let mut config = Config {
|
||||||
name: "test-pack".to_string(),
|
name: "test-pack".to_string(),
|
||||||
version: "1.0.0".to_string(),
|
version: "1.0.0".to_string(),
|
||||||
description: None,
|
description: None,
|
||||||
author: None,
|
author: None,
|
||||||
overrides: vec!["overrides".to_string()],
|
overrides: vec!["overrides".to_string()],
|
||||||
server_overrides: None,
|
server_overrides: None,
|
||||||
client_overrides: None,
|
client_overrides: None,
|
||||||
paths: HashMap::new(),
|
paths: HashMap::new(),
|
||||||
projects: None,
|
projects: None,
|
||||||
export_profiles: None,
|
export_profiles: None,
|
||||||
|
export_server_side_projects_to_client: None,
|
||||||
};
|
};
|
||||||
config.description = Some("A test modpack".to_string());
|
config.description = Some("A test modpack".to_string());
|
||||||
config.author = Some("Test Author".to_string());
|
config.author = Some("Test Author".to_string());
|
||||||
|
|
||||||
let json = serde_json::to_string(&config).unwrap();
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
let deserialized: Config = serde_json::from_str(&json).unwrap();
|
let deserialized: Config = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(deserialized.name, "test-pack");
|
||||||
assert_eq!(deserialized.name, config.name);
|
assert_eq!(deserialized.version, "1.0.0");
|
||||||
assert_eq!(deserialized.version, config.version);
|
assert_eq!(deserialized.description, Some("A test modpack".to_string()));
|
||||||
assert_eq!(deserialized.description, config.description);
|
assert_eq!(deserialized.author, Some("Test Author".to_string()));
|
||||||
assert_eq!(deserialized.author, config.author);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_config_save_and_load() {
|
|
||||||
let temp_dir = TempDir::new().unwrap();
|
|
||||||
let mut config = Config {
|
|
||||||
name: "test-pack".to_string(),
|
|
||||||
version: "1.0.0".to_string(),
|
|
||||||
description: None,
|
|
||||||
author: None,
|
|
||||||
overrides: vec!["overrides".to_string()],
|
|
||||||
server_overrides: None,
|
|
||||||
client_overrides: None,
|
|
||||||
paths: HashMap::new(),
|
|
||||||
projects: None,
|
|
||||||
export_profiles: None,
|
|
||||||
};
|
|
||||||
config.description = Some("Test description".to_string());
|
|
||||||
|
|
||||||
config.save(temp_dir.path()).unwrap();
|
|
||||||
|
|
||||||
let loaded = Config::load(temp_dir.path()).unwrap();
|
|
||||||
assert_eq!(loaded.name, config.name);
|
|
||||||
assert_eq!(loaded.version, config.version);
|
|
||||||
assert_eq!(loaded.description, config.description);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_config_compatibility_with_pakku() {
|
|
||||||
// Test basic config loading with projects
|
|
||||||
let config = Config {
|
|
||||||
name: "test-modpack".to_string(),
|
|
||||||
version: "1.0.0".to_string(),
|
|
||||||
description: None,
|
|
||||||
author: None,
|
|
||||||
overrides: vec!["overrides".to_string()],
|
|
||||||
server_overrides: None,
|
|
||||||
client_overrides: None,
|
|
||||||
paths: HashMap::new(),
|
|
||||||
projects: None,
|
|
||||||
export_profiles: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
assert_eq!(config.name, "test-modpack");
|
|
||||||
assert_eq!(config.version, "1.0.0");
|
|
||||||
assert!(config.projects.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_config_wrapped_format() {
|
|
||||||
let mut projects = HashMap::new();
|
|
||||||
projects.insert("sodium".to_string(), ProjectConfig {
|
|
||||||
r#type: Some(ProjectType::Mod),
|
|
||||||
side: Some(ProjectSide::Client),
|
|
||||||
update_strategy: None,
|
|
||||||
redistributable: None,
|
|
||||||
subpath: None,
|
|
||||||
aliases: None,
|
|
||||||
export: None,
|
|
||||||
});
|
|
||||||
|
|
||||||
let wrapped = PakkerWrappedConfig {
|
|
||||||
parent: None,
|
|
||||||
parent_lock_hash: String::new(),
|
|
||||||
patches: vec![],
|
|
||||||
projects,
|
|
||||||
};
|
|
||||||
|
|
||||||
let json = serde_json::to_string(&wrapped).unwrap();
|
|
||||||
assert!(json.contains("\"projects\""));
|
|
||||||
|
|
||||||
let deserialized: PakkerWrappedConfig =
|
|
||||||
serde_json::from_str(&json).unwrap();
|
|
||||||
assert_eq!(deserialized.projects.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_config_wrapped_format_old() {
|
|
||||||
use crate::model::fork::{LocalConfig, LocalProjectConfig};
|
|
||||||
|
|
||||||
let mut projects = HashMap::new();
|
|
||||||
projects.insert("sodium".to_string(), LocalProjectConfig {
|
|
||||||
version: None,
|
|
||||||
r#type: Some(ProjectType::Mod),
|
|
||||||
side: Some(ProjectSide::Client),
|
|
||||||
update_strategy: None,
|
|
||||||
redistributable: None,
|
|
||||||
subpath: None,
|
|
||||||
aliases: None,
|
|
||||||
export: None,
|
|
||||||
});
|
|
||||||
|
|
||||||
let wrapped_inner = LocalConfig {
|
|
||||||
parent: None,
|
|
||||||
projects,
|
|
||||||
parent_lock_hash: None,
|
|
||||||
parent_config_hash: None,
|
|
||||||
patches: vec![],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Just verify we can create the struct
|
|
||||||
assert_eq!(wrapped_inner.projects.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_config_validate() {
|
|
||||||
let config = Config {
|
|
||||||
name: "test".to_string(),
|
|
||||||
version: "1.0.0".to_string(),
|
|
||||||
description: None,
|
|
||||||
author: None,
|
|
||||||
overrides: vec!["overrides".to_string()],
|
|
||||||
server_overrides: None,
|
|
||||||
client_overrides: None,
|
|
||||||
paths: HashMap::new(),
|
|
||||||
projects: None,
|
|
||||||
export_profiles: None,
|
|
||||||
};
|
|
||||||
assert!(config.validate().is_ok());
|
|
||||||
|
|
||||||
let invalid = Config {
|
|
||||||
name: "".to_string(),
|
|
||||||
version: "1.0.0".to_string(),
|
|
||||||
description: None,
|
|
||||||
author: None,
|
|
||||||
overrides: vec![],
|
|
||||||
server_overrides: None,
|
|
||||||
client_overrides: None,
|
|
||||||
paths: HashMap::new(),
|
|
||||||
projects: None,
|
|
||||||
export_profiles: None,
|
|
||||||
};
|
|
||||||
assert!(invalid.validate().is_err());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Config {
|
|
||||||
pub fn get_project_config(&self, identifier: &str) -> Option<&ProjectConfig> {
|
|
||||||
self.projects.as_ref()?.get(identifier)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_project_config(
|
|
||||||
&mut self,
|
|
||||||
identifier: String,
|
|
||||||
config: ProjectConfig,
|
|
||||||
) {
|
|
||||||
if self.projects.is_none() {
|
|
||||||
self.projects = Some(HashMap::new());
|
|
||||||
}
|
|
||||||
if let Some(ref mut projects) = self.projects {
|
|
||||||
projects.insert(identifier, config);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -384,7 +384,8 @@ mod tests {
|
||||||
assert_eq!(loaded.mc_versions, mc_versions);
|
assert_eq!(loaded.mc_versions, mc_versions);
|
||||||
assert_eq!(loaded.loaders, loaders);
|
assert_eq!(loaded.loaders, loaders);
|
||||||
assert_eq!(loaded.projects.len(), 2);
|
assert_eq!(loaded.projects.len(), 2);
|
||||||
assert_eq!(loaded.lockfile_version, 1);
|
// Lockfile should be migrated from v1 to v2 on load
|
||||||
|
assert_eq!(loaded.lockfile_version, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -423,6 +424,95 @@ mod tests {
|
||||||
assert!(lockfile.validate().is_ok());
|
assert!(lockfile.validate().is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_lockfile_migration_v1_to_v2() {
|
||||||
|
// Test that v1 lockfiles are migrated to v2
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
let mut loaders = HashMap::new();
|
||||||
|
loaders.insert("fabric".to_string(), "0.15.0".to_string());
|
||||||
|
|
||||||
|
// Create a v1 lockfile manually
|
||||||
|
let v1_content = r#"{
|
||||||
|
"target": "modrinth",
|
||||||
|
"mc_versions": ["1.20.1"],
|
||||||
|
"loaders": {"fabric": "0.15.0"},
|
||||||
|
"projects": [],
|
||||||
|
"lockfile_version": 1
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
let lockfile_path = temp_dir.path().join("pakku-lock.json");
|
||||||
|
std::fs::write(&lockfile_path, v1_content).unwrap();
|
||||||
|
|
||||||
|
// Load should trigger migration
|
||||||
|
let loaded = LockFile::load(temp_dir.path()).unwrap();
|
||||||
|
assert_eq!(loaded.lockfile_version, 2);
|
||||||
|
|
||||||
|
// Verify the migrated file was saved
|
||||||
|
let reloaded = LockFile::load(temp_dir.path()).unwrap();
|
||||||
|
assert_eq!(reloaded.lockfile_version, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_lockfile_migration_preserves_projects() {
|
||||||
|
// Test that migration preserves all project data
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
// Create a v1 lockfile with projects (using correct enum case)
|
||||||
|
let v1_content = r#"{
|
||||||
|
"target": "modrinth",
|
||||||
|
"mc_versions": ["1.20.1"],
|
||||||
|
"loaders": {"fabric": "0.15.0"},
|
||||||
|
"projects": [
|
||||||
|
{
|
||||||
|
"pakku_id": "test-id-1",
|
||||||
|
"type": "MOD",
|
||||||
|
"side": "BOTH",
|
||||||
|
"name": {"modrinth": "Test Mod"},
|
||||||
|
"slug": {"modrinth": "test-mod"},
|
||||||
|
"id": {"modrinth": "abc123"},
|
||||||
|
"files": [],
|
||||||
|
"pakku_links": [],
|
||||||
|
"aliases": [],
|
||||||
|
"update_strategy": "LATEST",
|
||||||
|
"redistributable": true,
|
||||||
|
"export": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lockfile_version": 1
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
let lockfile_path = temp_dir.path().join("pakku-lock.json");
|
||||||
|
std::fs::write(&lockfile_path, v1_content).unwrap();
|
||||||
|
|
||||||
|
let loaded = LockFile::load(temp_dir.path()).unwrap();
|
||||||
|
assert_eq!(loaded.lockfile_version, 2);
|
||||||
|
assert_eq!(loaded.projects.len(), 1);
|
||||||
|
assert_eq!(loaded.projects[0].pakku_id, Some("test-id-1".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_lockfile_rejects_future_version() {
|
||||||
|
// Test that lockfiles with version > current are rejected
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
let future_content = r#"{
|
||||||
|
"target": "modrinth",
|
||||||
|
"mc_versions": ["1.20.1"],
|
||||||
|
"loaders": {"fabric": "0.15.0"},
|
||||||
|
"projects": [],
|
||||||
|
"lockfile_version": 999
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
let lockfile_path = temp_dir.path().join("pakku-lock.json");
|
||||||
|
std::fs::write(&lockfile_path, future_content).unwrap();
|
||||||
|
|
||||||
|
let result = LockFile::load(temp_dir.path());
|
||||||
|
assert!(result.is_err());
|
||||||
|
let err = result.unwrap_err().to_string();
|
||||||
|
assert!(err.contains("newer than supported"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_lockfile_pretty_json_format() {
|
fn test_lockfile_pretty_json_format() {
|
||||||
// Test that saved JSON is pretty-printed
|
// Test that saved JSON is pretty-printed
|
||||||
|
|
@ -472,7 +562,10 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const LOCKFILE_VERSION: u32 = 1;
|
/// Current lockfile version - bump this when making breaking changes
|
||||||
|
const LOCKFILE_VERSION: u32 = 2;
|
||||||
|
/// Minimum supported lockfile version for migration
|
||||||
|
const MIN_SUPPORTED_VERSION: u32 = 1;
|
||||||
const LOCKFILE_NAME: &str = "pakku-lock.json";
|
const LOCKFILE_NAME: &str = "pakku-lock.json";
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
@ -494,13 +587,26 @@ impl LockFile {
|
||||||
path: P,
|
path: P,
|
||||||
validate: bool,
|
validate: bool,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let path = path.as_ref().join(LOCKFILE_NAME);
|
let path_ref = path.as_ref();
|
||||||
|
let lockfile_path = path_ref.join(LOCKFILE_NAME);
|
||||||
let content =
|
let content =
|
||||||
std::fs::read_to_string(&path).map_err(PakkerError::IoError)?;
|
std::fs::read_to_string(&lockfile_path).map_err(PakkerError::IoError)?;
|
||||||
|
|
||||||
let mut lockfile: Self = serde_json::from_str(&content)
|
let mut lockfile: Self = serde_json::from_str(&content)
|
||||||
.map_err(|e| PakkerError::InvalidLockFile(e.to_string()))?;
|
.map_err(|e| PakkerError::InvalidLockFile(e.to_string()))?;
|
||||||
|
|
||||||
|
// Check if migration is needed
|
||||||
|
if lockfile.lockfile_version < LOCKFILE_VERSION {
|
||||||
|
lockfile = lockfile.migrate()?;
|
||||||
|
// Save migrated lockfile
|
||||||
|
lockfile.save_without_validation(path_ref)?;
|
||||||
|
log::info!(
|
||||||
|
"Migrated lockfile from version {} to {}",
|
||||||
|
lockfile.lockfile_version,
|
||||||
|
LOCKFILE_VERSION
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if validate {
|
if validate {
|
||||||
lockfile.validate()?;
|
lockfile.validate()?;
|
||||||
}
|
}
|
||||||
|
|
@ -509,6 +615,42 @@ impl LockFile {
|
||||||
Ok(lockfile)
|
Ok(lockfile)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Migrate lockfile from older version to current version
|
||||||
|
fn migrate(mut self) -> Result<Self> {
|
||||||
|
if self.lockfile_version < MIN_SUPPORTED_VERSION {
|
||||||
|
return Err(PakkerError::InvalidLockFile(format!(
|
||||||
|
"Lockfile version {} is too old to migrate. Minimum supported: {}",
|
||||||
|
self.lockfile_version, MIN_SUPPORTED_VERSION
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration from v1 to v2
|
||||||
|
if self.lockfile_version == 1 {
|
||||||
|
log::info!("Migrating lockfile from v1 to v2...");
|
||||||
|
|
||||||
|
// v2 changes:
|
||||||
|
// - Projects now have explicit export field (defaults to true)
|
||||||
|
// - Side detection is more granular
|
||||||
|
for project in &mut self.projects {
|
||||||
|
// Ensure export field is set (v1 didn't always have it)
|
||||||
|
// Already has a default in Project, but be explicit
|
||||||
|
if !project.export {
|
||||||
|
project.export = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.lockfile_version = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Future migrations would go here:
|
||||||
|
// if self.lockfile_version == 2 {
|
||||||
|
// // migrate v2 -> v3
|
||||||
|
// self.lockfile_version = 3;
|
||||||
|
// }
|
||||||
|
|
||||||
|
Ok(self)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<()> {
|
pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<()> {
|
||||||
self.validate()?;
|
self.validate()?;
|
||||||
let path = path.as_ref().join(LOCKFILE_NAME);
|
let path = path.as_ref().join(LOCKFILE_NAME);
|
||||||
|
|
@ -525,10 +667,17 @@ impl LockFile {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn validate(&self) -> Result<()> {
|
pub fn validate(&self) -> Result<()> {
|
||||||
if self.lockfile_version != LOCKFILE_VERSION {
|
if self.lockfile_version > LOCKFILE_VERSION {
|
||||||
return Err(PakkerError::InvalidLockFile(format!(
|
return Err(PakkerError::InvalidLockFile(format!(
|
||||||
"Unsupported lockfile version: {}",
|
"Lockfile version {} is newer than supported version {}. Please \
|
||||||
self.lockfile_version
|
upgrade Pakker.",
|
||||||
|
self.lockfile_version, LOCKFILE_VERSION
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if self.lockfile_version < MIN_SUPPORTED_VERSION {
|
||||||
|
return Err(PakkerError::InvalidLockFile(format!(
|
||||||
|
"Lockfile version {} is too old. Minimum supported: {}",
|
||||||
|
self.lockfile_version, MIN_SUPPORTED_VERSION
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,79 @@ impl Project {
|
||||||
self.aliases.extend(other.aliases);
|
self.aliases.extend(other.aliases);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
pub fn versions_match_across_providers(&self) -> bool {
|
||||||
|
if self.files.len() <= 1 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group files by provider (using parent_id as proxy)
|
||||||
|
let mut versions_by_provider: HashMap<String, Vec<&str>> = HashMap::new();
|
||||||
|
for file in &self.files {
|
||||||
|
// Extract provider from file type or use parent_id
|
||||||
|
let provider = &file.file_type;
|
||||||
|
versions_by_provider
|
||||||
|
.entry(provider.clone())
|
||||||
|
.or_default()
|
||||||
|
.push(&file.file_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If only one provider, versions match
|
||||||
|
if versions_by_provider.len() <= 1 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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().and_then(parse_version))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// All versions should be the same
|
||||||
|
versions.windows(2).all(|w| w[0] == w[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if versions do NOT match across providers.
|
||||||
|
/// Returns Some with details if there's a mismatch, None if versions match.
|
||||||
|
pub fn check_version_mismatch(&self) -> Option<String> {
|
||||||
|
if self.versions_match_across_providers() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect version info by provider
|
||||||
|
let mut provider_versions: Vec<(String, String)> = Vec::new();
|
||||||
|
for file in &self.files {
|
||||||
|
provider_versions.push((file.file_type.clone(), file.file_name.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(format!(
|
||||||
|
"Version mismatch for {}: {}",
|
||||||
|
self.get_name(),
|
||||||
|
provider_versions
|
||||||
|
.iter()
|
||||||
|
.map(|(p, v)| format!("{p}={v}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn select_file(
|
pub fn select_file(
|
||||||
&mut self,
|
&mut self,
|
||||||
mc_versions: &[String],
|
mc_versions: &[String],
|
||||||
|
|
@ -254,6 +327,39 @@ impl ProjectFile {
|
||||||
|
|
||||||
mc_compatible && loader_compatible
|
mc_compatible && loader_compatible
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generate a viewable URL for this file based on its provider.
|
||||||
|
/// Returns None if the URL cannot be determined.
|
||||||
|
pub fn get_site_url(&self, project: &Project) -> Option<String> {
|
||||||
|
// Determine provider from file type
|
||||||
|
match self.file_type.as_str() {
|
||||||
|
"modrinth" => {
|
||||||
|
// Format: https://modrinth.com/mod/{slug}/version/{file_id}
|
||||||
|
let slug = project.slug.get("modrinth")?;
|
||||||
|
Some(format!(
|
||||||
|
"https://modrinth.com/mod/{}/version/{}",
|
||||||
|
slug, self.id
|
||||||
|
))
|
||||||
|
},
|
||||||
|
"curseforge" => {
|
||||||
|
// Format: https://www.curseforge.com/minecraft/mc-mods/{slug}/files/{file_id}
|
||||||
|
let slug = project.slug.get("curseforge")?;
|
||||||
|
Some(format!(
|
||||||
|
"https://www.curseforge.com/minecraft/mc-mods/{}/files/{}",
|
||||||
|
slug, self.id
|
||||||
|
))
|
||||||
|
},
|
||||||
|
"github" => {
|
||||||
|
// Format: https://github.com/{owner}/{repo}/releases/tag/{tag}
|
||||||
|
// parent_id contains owner/repo, id contains the tag/version
|
||||||
|
Some(format!(
|
||||||
|
"https://github.com/{}/releases/tag/{}",
|
||||||
|
self.parent_id, self.id
|
||||||
|
))
|
||||||
|
},
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -436,4 +542,230 @@ mod tests {
|
||||||
let result = project.select_file(&lockfile_mc, &lockfile_loaders);
|
let result = project.select_file(&lockfile_mc, &lockfile_loaders);
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_versions_match_across_providers_single_file() {
|
||||||
|
let mut project =
|
||||||
|
Project::new("test-id".to_string(), ProjectType::Mod, ProjectSide::Both);
|
||||||
|
|
||||||
|
project.files.push(ProjectFile {
|
||||||
|
file_type: "modrinth".to_string(),
|
||||||
|
file_name: "test-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/test.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(),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(project.versions_match_across_providers());
|
||||||
|
assert!(project.check_version_mismatch().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_versions_match_across_providers_same_file() {
|
||||||
|
let mut project =
|
||||||
|
Project::new("test-id".to_string(), ProjectType::Mod, ProjectSide::Both);
|
||||||
|
|
||||||
|
// Same file name from different providers
|
||||||
|
project.files.push(ProjectFile {
|
||||||
|
file_type: "modrinth".to_string(),
|
||||||
|
file_name: "test-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://modrinth.com/test.jar".to_string(),
|
||||||
|
id: "mr-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(),
|
||||||
|
});
|
||||||
|
|
||||||
|
project.files.push(ProjectFile {
|
||||||
|
file_type: "curseforge".to_string(),
|
||||||
|
file_name: "test-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://curseforge.com/test.jar".to_string(),
|
||||||
|
id: "cf-file1".to_string(),
|
||||||
|
parent_id: "mod456".to_string(),
|
||||||
|
hashes: HashMap::new(),
|
||||||
|
required_dependencies: vec![],
|
||||||
|
size: 1024,
|
||||||
|
date_published: "2024-01-01T00:00:00Z".to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(project.versions_match_across_providers());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_versions_mismatch_across_providers() {
|
||||||
|
let mut project =
|
||||||
|
Project::new("test-id".to_string(), ProjectType::Mod, ProjectSide::Both);
|
||||||
|
|
||||||
|
project
|
||||||
|
.name
|
||||||
|
.insert("test".to_string(), "Test Mod".to_string());
|
||||||
|
|
||||||
|
// Different file names from different providers
|
||||||
|
project.files.push(ProjectFile {
|
||||||
|
file_type: "modrinth".to_string(),
|
||||||
|
file_name: "test-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://modrinth.com/test.jar".to_string(),
|
||||||
|
id: "mr-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(),
|
||||||
|
});
|
||||||
|
|
||||||
|
project.files.push(ProjectFile {
|
||||||
|
file_type: "curseforge".to_string(),
|
||||||
|
file_name: "test-0.9.0.jar".to_string(), // Different version
|
||||||
|
mc_versions: vec!["1.20.1".to_string()],
|
||||||
|
loaders: vec!["fabric".to_string()],
|
||||||
|
release_type: ReleaseType::Release,
|
||||||
|
url: "https://curseforge.com/test.jar".to_string(),
|
||||||
|
id: "cf-file1".to_string(),
|
||||||
|
parent_id: "mod456".to_string(),
|
||||||
|
hashes: HashMap::new(),
|
||||||
|
required_dependencies: vec![],
|
||||||
|
size: 1024,
|
||||||
|
date_published: "2024-01-01T00:00:00Z".to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(!project.versions_match_across_providers());
|
||||||
|
let mismatch = project.check_version_mismatch();
|
||||||
|
assert!(mismatch.is_some());
|
||||||
|
let msg = mismatch.unwrap();
|
||||||
|
assert!(msg.contains("Version mismatch"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_site_url_modrinth() {
|
||||||
|
let mut project =
|
||||||
|
Project::new("test-id".to_string(), ProjectType::Mod, ProjectSide::Both);
|
||||||
|
project
|
||||||
|
.slug
|
||||||
|
.insert("modrinth".to_string(), "sodium".to_string());
|
||||||
|
|
||||||
|
let file = ProjectFile {
|
||||||
|
file_type: "modrinth".to_string(),
|
||||||
|
file_name: "sodium-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://modrinth.com/sodium.jar".to_string(),
|
||||||
|
id: "abc123".to_string(),
|
||||||
|
parent_id: "sodium".to_string(),
|
||||||
|
hashes: HashMap::new(),
|
||||||
|
required_dependencies: vec![],
|
||||||
|
size: 1024,
|
||||||
|
date_published: "2024-01-01T00:00:00Z".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = file.get_site_url(&project);
|
||||||
|
assert!(url.is_some());
|
||||||
|
let url = url.unwrap();
|
||||||
|
assert!(url.contains("modrinth.com"));
|
||||||
|
assert!(url.contains("sodium"));
|
||||||
|
assert!(url.contains("abc123"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_site_url_curseforge() {
|
||||||
|
let mut project =
|
||||||
|
Project::new("test-id".to_string(), ProjectType::Mod, ProjectSide::Both);
|
||||||
|
project
|
||||||
|
.slug
|
||||||
|
.insert("curseforge".to_string(), "jei".to_string());
|
||||||
|
|
||||||
|
let file = ProjectFile {
|
||||||
|
file_type: "curseforge".to_string(),
|
||||||
|
file_name: "jei-1.0.0.jar".to_string(),
|
||||||
|
mc_versions: vec!["1.20.1".to_string()],
|
||||||
|
loaders: vec!["forge".to_string()],
|
||||||
|
release_type: ReleaseType::Release,
|
||||||
|
url: "https://curseforge.com/jei.jar".to_string(),
|
||||||
|
id: "12345".to_string(),
|
||||||
|
parent_id: "jei".to_string(),
|
||||||
|
hashes: HashMap::new(),
|
||||||
|
required_dependencies: vec![],
|
||||||
|
size: 1024,
|
||||||
|
date_published: "2024-01-01T00:00:00Z".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = file.get_site_url(&project);
|
||||||
|
assert!(url.is_some());
|
||||||
|
let url = url.unwrap();
|
||||||
|
assert!(url.contains("curseforge.com"));
|
||||||
|
assert!(url.contains("jei"));
|
||||||
|
assert!(url.contains("12345"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_site_url_github() {
|
||||||
|
let project =
|
||||||
|
Project::new("test-id".to_string(), ProjectType::Mod, ProjectSide::Both);
|
||||||
|
|
||||||
|
let file = ProjectFile {
|
||||||
|
file_type: "github".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://github.com/owner/repo/releases/download/v1.0.0/mod.jar"
|
||||||
|
.to_string(),
|
||||||
|
id: "v1.0.0".to_string(),
|
||||||
|
parent_id: "owner/repo".to_string(),
|
||||||
|
hashes: HashMap::new(),
|
||||||
|
required_dependencies: vec![],
|
||||||
|
size: 1024,
|
||||||
|
date_published: "2024-01-01T00:00:00Z".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = file.get_site_url(&project);
|
||||||
|
assert!(url.is_some());
|
||||||
|
let url = url.unwrap();
|
||||||
|
assert!(url.contains("github.com"));
|
||||||
|
assert!(url.contains("owner/repo"));
|
||||||
|
assert!(url.contains("v1.0.0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_site_url_unknown_type() {
|
||||||
|
let project =
|
||||||
|
Project::new("test-id".to_string(), ProjectType::Mod, ProjectSide::Both);
|
||||||
|
|
||||||
|
let file = ProjectFile {
|
||||||
|
file_type: "unknown".to_string(),
|
||||||
|
file_name: "mod.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: "123".to_string(),
|
||||||
|
parent_id: "mod".to_string(),
|
||||||
|
hashes: HashMap::new(),
|
||||||
|
required_dependencies: vec![],
|
||||||
|
size: 1024,
|
||||||
|
date_published: "2024-01-01T00:00:00Z".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = file.get_site_url(&project);
|
||||||
|
assert!(url.is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,19 @@ use std::sync::Arc;
|
||||||
pub use curseforge::CurseForgePlatform;
|
pub use curseforge::CurseForgePlatform;
|
||||||
pub use github::GitHubPlatform;
|
pub use github::GitHubPlatform;
|
||||||
pub use modrinth::ModrinthPlatform;
|
pub use modrinth::ModrinthPlatform;
|
||||||
use once_cell::sync::Lazy;
|
|
||||||
pub use traits::PlatformClient;
|
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>> =
|
static HTTP_CLIENT: std::sync::LazyLock<Arc<reqwest::Client>> =
|
||||||
Lazy::new(|| Arc::new(RateLimiter::new(None)));
|
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(
|
pub fn create_platform(
|
||||||
platform: &str,
|
platform: &str,
|
||||||
|
|
@ -34,9 +40,21 @@ fn create_client(
|
||||||
api_key: Option<String>,
|
api_key: Option<String>,
|
||||||
) -> Result<Box<dyn PlatformClient>> {
|
) -> Result<Box<dyn PlatformClient>> {
|
||||||
match platform {
|
match platform {
|
||||||
"modrinth" => Ok(Box::new(ModrinthPlatform::new())),
|
"modrinth" => {
|
||||||
"curseforge" => Ok(Box::new(CurseForgePlatform::new(api_key))),
|
Ok(Box::new(ModrinthPlatform::with_client(get_http_client())))
|
||||||
"github" => Ok(Box::new(GitHubPlatform::new(api_key))),
|
},
|
||||||
|
"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!(
|
Err(crate::error::PakkerError::ConfigError(format!(
|
||||||
"Unknown platform: {platform}"
|
"Unknown platform: {platform}"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use std::collections::HashMap;
|
use std::{collections::HashMap, sync::Arc};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
|
|
@ -12,21 +12,30 @@ use crate::{
|
||||||
};
|
};
|
||||||
|
|
||||||
const CURSEFORGE_API_BASE: &str = "https://api.curseforge.com/v1";
|
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;
|
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 {
|
pub struct CurseForgePlatform {
|
||||||
client: Client,
|
client: Arc<Client>,
|
||||||
api_key: Option<String>,
|
api_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CurseForgePlatform {
|
impl CurseForgePlatform {
|
||||||
pub fn new(api_key: Option<String>) -> Self {
|
pub fn new(api_key: Option<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
client: Client::new(),
|
client: Arc::new(Client::new()),
|
||||||
api_key,
|
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> {
|
fn get_headers(&self) -> Result<reqwest::header::HeaderMap> {
|
||||||
let mut headers = reqwest::header::HeaderMap::new();
|
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 {
|
fn convert_project(&self, cf_project: CurseForgeProject) -> Project {
|
||||||
let pakku_id = generate_pakku_id();
|
let pakku_id = generate_pakku_id();
|
||||||
let project_type = Self::map_class_id(cf_project.class_id.unwrap_or(6));
|
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(
|
project.add_platform(
|
||||||
"curseforge".to_string(),
|
"curseforge".to_string(),
|
||||||
|
|
@ -124,7 +203,7 @@ impl CurseForgePlatform {
|
||||||
required_dependencies: cf_file
|
required_dependencies: cf_file
|
||||||
.dependencies
|
.dependencies
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|d| d.relation_type == 3)
|
.filter(|d| d.relation_type == DEPENDENCY_RELATION_TYPE_REQUIRED)
|
||||||
.map(|d| d.mod_id.to_string())
|
.map(|d| d.mod_id.to_string())
|
||||||
.collect(),
|
.collect(),
|
||||||
size: cf_file.file_length,
|
size: cf_file.file_length,
|
||||||
|
|
@ -317,11 +396,20 @@ impl PlatformClient for CurseForgePlatform {
|
||||||
// CurseForge API models
|
// CurseForge API models
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
struct CurseForgeProject {
|
struct CurseForgeProject {
|
||||||
id: u32,
|
id: u32,
|
||||||
name: String,
|
name: String,
|
||||||
slug: String,
|
slug: String,
|
||||||
#[serde(rename = "classId")]
|
#[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)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
|
@ -381,3 +469,112 @@ struct CurseForgeFilesResponse {
|
||||||
struct CurseForgeSearchResponse {
|
struct CurseForgeSearchResponse {
|
||||||
data: Vec<CurseForgeProject>,
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use std::collections::HashMap;
|
use std::{collections::HashMap, sync::Arc};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
|
|
@ -20,9 +20,9 @@ pub struct GitHubPlatform {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GitHubPlatform {
|
impl GitHubPlatform {
|
||||||
pub fn new(token: Option<String>) -> Self {
|
pub fn with_client(client: Arc<Client>, token: Option<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
client: Client::new(),
|
client: (*client).clone(),
|
||||||
token,
|
token,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use std::collections::HashMap;
|
use std::{collections::HashMap, sync::Arc};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
|
|
@ -14,16 +14,76 @@ use crate::{
|
||||||
const MODRINTH_API_BASE: &str = "https://api.modrinth.com/v2";
|
const MODRINTH_API_BASE: &str = "https://api.modrinth.com/v2";
|
||||||
|
|
||||||
pub struct ModrinthPlatform {
|
pub struct ModrinthPlatform {
|
||||||
client: Client,
|
client: Arc<Client>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ModrinthPlatform {
|
impl ModrinthPlatform {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
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 {
|
fn map_project_type(type_str: &str) -> ProjectType {
|
||||||
match type_str {
|
match type_str {
|
||||||
"mod" => ProjectType::Mod,
|
"mod" => ProjectType::Mod,
|
||||||
|
|
@ -123,15 +183,7 @@ impl PlatformClient for ModrinthPlatform {
|
||||||
_loaders: &[String],
|
_loaders: &[String],
|
||||||
) -> Result<Project> {
|
) -> Result<Project> {
|
||||||
let url = format!("{MODRINTH_API_BASE}/project/{identifier}");
|
let url = format!("{MODRINTH_API_BASE}/project/{identifier}");
|
||||||
|
self.request_project_url(&url).await
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn request_project_files(
|
async fn request_project_files(
|
||||||
|
|
@ -170,20 +222,7 @@ impl PlatformClient for ModrinthPlatform {
|
||||||
url.push_str(¶ms.join("&"));
|
url.push_str(¶ms.join("&"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let response = self.client.get(&url).send().await?;
|
self.request_project_files_url(&url).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(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn request_project_with_files(
|
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>> {
|
async fn lookup_by_hash(&self, hash: &str) -> Result<Option<Project>> {
|
||||||
// Modrinth uses SHA-1 hash for file lookups
|
// Modrinth uses SHA-1 hash for file lookups
|
||||||
let url = format!("{MODRINTH_API_BASE}/version_file/{hash}");
|
let url = format!("{MODRINTH_API_BASE}/version_file/{hash}");
|
||||||
|
self.lookup_by_hash_url(&url).await
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -280,3 +296,128 @@ struct ModrinthDependency {
|
||||||
project_id: Option<String>,
|
project_id: Option<String>,
|
||||||
dependency_type: 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -80,13 +80,13 @@ impl RateLimiter {
|
||||||
platform_requests
|
platform_requests
|
||||||
.retain(|t| now.duration_since(*t) < Duration::from_secs(60));
|
.retain(|t| now.duration_since(*t) < Duration::from_secs(60));
|
||||||
|
|
||||||
if platform_requests.len() >= burst as usize {
|
if platform_requests.len() >= burst as usize
|
||||||
if let Some(oldest) = platform_requests.first() {
|
&& let Some(oldest) = platform_requests.first()
|
||||||
let wait_time = interval.saturating_sub(now.duration_since(*oldest));
|
{
|
||||||
if wait_time > Duration::ZERO {
|
let wait_time = interval.saturating_sub(now.duration_since(*oldest));
|
||||||
drop(inner);
|
if wait_time > Duration::ZERO {
|
||||||
tokio::time::sleep(wait_time).await;
|
drop(inner);
|
||||||
}
|
tokio::time::sleep(wait_time).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
166
src/ui_utils.rs
166
src/ui_utils.rs
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
use std::io;
|
use std::io;
|
||||||
|
|
||||||
use dialoguer::{Confirm, MultiSelect, Select, theme::ColorfulTheme};
|
use dialoguer::{Confirm, Input, MultiSelect, Select, theme::ColorfulTheme};
|
||||||
|
|
||||||
/// Creates a terminal hyperlink using OSC 8 escape sequence
|
/// Creates a terminal hyperlink using OSC 8 escape sequence
|
||||||
/// Format: \x1b]8;;<URL>\x1b\\<TEXT>\x1b]8;;\x1b\\
|
/// Format: \x1b]8;;<URL>\x1b\\<TEXT>\x1b]8;;\x1b\\
|
||||||
|
|
@ -58,6 +58,136 @@ pub fn curseforge_project_url(project_id: &str) -> String {
|
||||||
format!("https://www.curseforge.com/minecraft/mc-mods/{project_id}")
|
format!("https://www.curseforge.com/minecraft/mc-mods/{project_id}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Calculate Levenshtein edit distance between two strings
|
||||||
|
#[allow(clippy::needless_range_loop)]
|
||||||
|
fn levenshtein_distance(a: &str, b: &str) -> usize {
|
||||||
|
let a = a.to_lowercase();
|
||||||
|
let b = b.to_lowercase();
|
||||||
|
let a_len = a.chars().count();
|
||||||
|
let b_len = b.chars().count();
|
||||||
|
|
||||||
|
if a_len == 0 {
|
||||||
|
return b_len;
|
||||||
|
}
|
||||||
|
if b_len == 0 {
|
||||||
|
return a_len;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut matrix = vec![vec![0usize; b_len + 1]; a_len + 1];
|
||||||
|
|
||||||
|
for i in 0..=a_len {
|
||||||
|
matrix[i][0] = i;
|
||||||
|
}
|
||||||
|
for j in 0..=b_len {
|
||||||
|
matrix[0][j] = j;
|
||||||
|
}
|
||||||
|
|
||||||
|
let a_chars: Vec<char> = a.chars().collect();
|
||||||
|
let b_chars: Vec<char> = b.chars().collect();
|
||||||
|
|
||||||
|
for i in 1..=a_len {
|
||||||
|
for j in 1..=b_len {
|
||||||
|
let cost = usize::from(a_chars[i - 1] != b_chars[j - 1]);
|
||||||
|
|
||||||
|
matrix[i][j] = (matrix[i - 1][j] + 1) // deletion
|
||||||
|
.min(matrix[i][j - 1] + 1) // insertion
|
||||||
|
.min(matrix[i - 1][j - 1] + cost); // substitution
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
matrix[a_len][b_len]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find similar strings to the input using Levenshtein distance.
|
||||||
|
/// Returns suggestions sorted by similarity (most similar first).
|
||||||
|
/// Only returns suggestions with distance <= `max_distance`.
|
||||||
|
pub fn suggest_similar<'a>(
|
||||||
|
input: &str,
|
||||||
|
candidates: &'a [String],
|
||||||
|
max_distance: usize,
|
||||||
|
) -> Vec<&'a str> {
|
||||||
|
let mut scored: Vec<(&str, usize)> = candidates
|
||||||
|
.iter()
|
||||||
|
.map(|c| (c.as_str(), levenshtein_distance(input, c)))
|
||||||
|
.filter(|(_, dist)| *dist <= max_distance && *dist > 0)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
scored.sort_by_key(|(_, dist)| *dist);
|
||||||
|
scored.into_iter().map(|(s, _)| s).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prompt user if they meant a similar project name.
|
||||||
|
/// Returns `Some(suggested_name)` if user confirms, None otherwise.
|
||||||
|
pub fn prompt_typo_suggestion(
|
||||||
|
input: &str,
|
||||||
|
candidates: &[String],
|
||||||
|
) -> io::Result<Option<String>> {
|
||||||
|
// Use a max distance based on input length for reasonable suggestions
|
||||||
|
let max_distance = (input.len() / 2).clamp(2, 4);
|
||||||
|
let suggestions = suggest_similar(input, candidates, max_distance);
|
||||||
|
|
||||||
|
if let Some(first_suggestion) = suggestions.first()
|
||||||
|
&& prompt_yes_no(&format!("Did you mean '{first_suggestion}'?"), true)?
|
||||||
|
{
|
||||||
|
return Ok(Some((*first_suggestion).to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prompt for text input with optional default value
|
||||||
|
pub fn prompt_input(prompt: &str, default: Option<&str>) -> io::Result<String> {
|
||||||
|
let theme = ColorfulTheme::default();
|
||||||
|
let mut input = Input::<String>::with_theme(&theme).with_prompt(prompt);
|
||||||
|
|
||||||
|
if let Some(def) = default {
|
||||||
|
input = input.default(def.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
input.interact_text().map_err(io::Error::other)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prompt for text input, returning None if empty
|
||||||
|
pub fn prompt_input_optional(prompt: &str) -> io::Result<Option<String>> {
|
||||||
|
let input: String = Input::with_theme(&ColorfulTheme::default())
|
||||||
|
.with_prompt(prompt)
|
||||||
|
.allow_empty(true)
|
||||||
|
.interact_text()
|
||||||
|
.map_err(io::Error::other)?;
|
||||||
|
|
||||||
|
if input.is_empty() {
|
||||||
|
Ok(None)
|
||||||
|
} else {
|
||||||
|
Ok(Some(input))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prompt for `CurseForge` API key when authentication fails.
|
||||||
|
/// Returns the API key if provided, None if cancelled.
|
||||||
|
pub fn prompt_curseforge_api_key() -> io::Result<Option<String>> {
|
||||||
|
use dialoguer::Password;
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!("CurseForge API key is required but not configured.");
|
||||||
|
println!("Get your API key from: https://console.curseforge.com/");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
if !prompt_yes_no("Would you like to enter your API key now?", true)? {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let key: String = Password::with_theme(&ColorfulTheme::default())
|
||||||
|
.with_prompt("CurseForge API key")
|
||||||
|
.interact()
|
||||||
|
.map_err(io::Error::other)?;
|
||||||
|
|
||||||
|
if key.is_empty() {
|
||||||
|
Ok(None)
|
||||||
|
} else {
|
||||||
|
Ok(Some(key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -74,4 +204,38 @@ mod tests {
|
||||||
let url = modrinth_project_url("sodium");
|
let url = modrinth_project_url("sodium");
|
||||||
assert_eq!(url, "https://modrinth.com/mod/sodium");
|
assert_eq!(url, "https://modrinth.com/mod/sodium");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_levenshtein_distance() {
|
||||||
|
assert_eq!(levenshtein_distance("kitten", "sitting"), 3);
|
||||||
|
assert_eq!(levenshtein_distance("saturday", "sunday"), 3);
|
||||||
|
assert_eq!(levenshtein_distance("", "abc"), 3);
|
||||||
|
assert_eq!(levenshtein_distance("abc", ""), 3);
|
||||||
|
assert_eq!(levenshtein_distance("abc", "abc"), 0);
|
||||||
|
assert_eq!(levenshtein_distance("ABC", "abc"), 0); // case insensitive
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_suggest_similar() {
|
||||||
|
let candidates = vec![
|
||||||
|
"sodium".to_string(),
|
||||||
|
"lithium".to_string(),
|
||||||
|
"phosphor".to_string(),
|
||||||
|
"iris".to_string(),
|
||||||
|
"fabric-api".to_string(),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Close typo should be suggested
|
||||||
|
let suggestions = suggest_similar("sodim", &candidates, 2);
|
||||||
|
assert!(!suggestions.is_empty());
|
||||||
|
assert_eq!(suggestions[0], "sodium");
|
||||||
|
|
||||||
|
// Complete mismatch should return empty
|
||||||
|
let suggestions = suggest_similar("xyz123", &candidates, 2);
|
||||||
|
assert!(suggestions.is_empty());
|
||||||
|
|
||||||
|
// Exact match returns empty (distance 0 filtered out)
|
||||||
|
let suggestions = suggest_similar("sodium", &candidates, 2);
|
||||||
|
assert!(suggestions.is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use rand::Rng;
|
use rand::RngExt;
|
||||||
|
|
||||||
const CHARSET: &[u8] =
|
const CHARSET: &[u8] =
|
||||||
b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue