diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index c5c7b40..57536ff 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -80,6 +80,7 @@ jobs: container: ${{ matrix.container && matrix.container.image || null }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CURSEFORGE_API_KEY: ${{ secrets.CURSEFORGE_API_KEY }} BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json steps: - name: enable windows longpaths diff --git a/.gitignore b/.gitignore index 1a4595e..ec9de82 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target +/scripts *.log diff --git a/Cargo.lock b/Cargo.lock index 4f31d75..e4cb7a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,6 +28,19 @@ dependencies = [ "cpufeatures 0.3.0", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "const-random", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -114,12 +127,33 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "arraydeque" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -130,6 +164,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "astral-tl" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d90933ffb0f97e2fc2e0de21da9d3f20597b804012d199843a6fe7c2810d28f3" +dependencies = [ + "memchr", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -216,9 +259,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" dependencies = [ "serde_core", ] @@ -311,6 +354,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "byteorder-lite" version = "0.1.0" @@ -353,6 +402,12 @@ dependencies = [ "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cfg-if" version = "1.0.4" @@ -449,6 +504,12 @@ dependencies = [ "tracing-error", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.5" @@ -557,6 +618,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + [[package]] name = "cpubits" version = "0.1.0" @@ -590,6 +660,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -630,7 +706,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "crossterm_winapi", "mio", "parking_lot", @@ -647,7 +723,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "crossterm_winapi", "derive_more", "document-features", @@ -701,7 +777,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" dependencies = [ "lab", - "phf", + "phf 0.11.3", ] [[package]] @@ -782,6 +858,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + [[package]] name = "deadpool" version = "0.12.3" @@ -866,6 +948,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.10.7" @@ -1062,6 +1150,18 @@ dependencies = [ "vt-push-parser", ] +[[package]] +name = "fastnbt" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c075c8f2b92819bec6af26321830809f47574c72fd5b53ce81436bbac54bcea" +dependencies = [ + "byteorder", + "cesu8", + "serde", + "serde_bytes", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -1117,6 +1217,12 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + [[package]] name = "fnv" version = "1.0.7" @@ -1135,6 +1241,29 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree 0.20.0", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1144,6 +1273,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -1273,6 +1412,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1327,6 +1475,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gimli" version = "0.32.3" @@ -1389,6 +1547,11 @@ name = "hashbrown" version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "hashlink" @@ -1426,6 +1589,44 @@ dependencies = [ "digest 0.11.2", ] +[[package]] +name = "html-escape" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c1ff2d1cbf39efe5af0900ced8a069b5e61557a17544eb0c4a50239937389e" + +[[package]] +name = "html-to-markdown-rs" +version = "3.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72131a643f3b9c379f5080626f1e7774b6abef974424f3f01fd952fcd23b60f3" +dependencies = [ + "ahash", + "astral-tl", + "base64", + "bitflags 2.13.1", + "html-escape", + "html5ever", + "lru", + "memchr", + "once_cell", + "phf 0.14.0", + "regex", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "html5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" +dependencies = [ + "log", + "markup5ever", +] + [[package]] name = "http" version = "1.4.0" @@ -1698,11 +1899,32 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", + "color_quant", + "gif", + "image-webp", "moxcms", "num-traits", "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", ] +[[package]] +name = "imagesize" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" + [[package]] name = "indenter" version = "0.3.4" @@ -1736,7 +1958,7 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "inotify-sys", "libc", ] @@ -1834,6 +2056,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1972,6 +2203,18 @@ dependencies = [ "libc", ] +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + [[package]] name = "lab" version = "0.11.0" @@ -2036,7 +2279,7 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", ] [[package]] @@ -2080,11 +2323,11 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lru" -version = "0.16.4" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" dependencies = [ - "hashbrown 0.16.1", + "hashbrown 0.17.0", ] [[package]] @@ -2112,6 +2355,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "markup5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + [[package]] name = "matchers" version = "0.2.0" @@ -2127,6 +2381,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + [[package]] name = "memmem" version = "0.1.1" @@ -2200,13 +2463,19 @@ dependencies = [ "pxfm", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + [[package]] name = "nix" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "libc", @@ -2229,7 +2498,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "fsevent-sys", "inotify", "kqueue", @@ -2247,7 +2516,7 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", ] [[package]] @@ -2438,6 +2707,7 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" dependencies = [ + "approx", "bytemuck", "fast-srgb8", "libm", @@ -2550,8 +2820,29 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ - "phf_macros", - "phf_shared", + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared 0.13.1", + "serde", +] + +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_macros 0.14.0", + "phf_shared 0.14.0", + "serde", ] [[package]] @@ -2560,8 +2851,18 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", ] [[package]] @@ -2570,18 +2871,51 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ - "phf_shared", + "phf_shared 0.11.3", "rand 0.8.6", ] +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" +dependencies = [ + "fastrand", + "phf_shared 0.14.0", +] + [[package]] name = "phf_macros" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_macros" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" +dependencies = [ + "phf_generator 0.14.0", + "phf_shared 0.14.0", "proc-macro2", "quote", "syn 2.0.117", @@ -2596,6 +2930,30 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -2614,13 +2972,22 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "crc32fast", "fdeflate", "flate2", "miniz_oxide", ] +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -2667,8 +3034,24 @@ dependencies = [ ] [[package]] -name = "prettyplease" -version = "0.2.37" +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ @@ -2716,6 +3099,25 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags 2.13.1", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + [[package]] name = "pxfm" version = "0.1.29" @@ -2742,6 +3144,12 @@ dependencies = [ "wide", ] +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quinn" version = "0.11.9" @@ -2894,14 +3302,15 @@ dependencies = [ [[package]] name = "ratatui" -version = "0.30.0" +version = "0.30.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" dependencies = [ "instability", "ratatui-core", "ratatui-crossterm", "ratatui-macros", + "ratatui-termina", "ratatui-termion", "ratatui-termwiz", "ratatui-widgets", @@ -2910,17 +3319,18 @@ dependencies = [ [[package]] name = "ratatui-core" -version = "0.1.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "compact_str", - "hashbrown 0.16.1", - "indoc", - "itertools", + "critical-section", + "hashbrown 0.17.0", + "itertools 0.14.0", "kasuari", "lru", + "palette", "serde", "strum", "thiserror 2.0.18", @@ -2931,9 +3341,9 @@ dependencies = [ [[package]] name = "ratatui-crossterm" -version = "0.1.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" dependencies = [ "cfg-if", "crossterm 0.28.1", @@ -2944,9 +3354,9 @@ dependencies = [ [[package]] name = "ratatui-image" -version = "10.0.6" +version = "11.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c57add959ab80c9a92be620fa6f8e4a64f7c014829250ba78862e8d81a903cb5" +checksum = "e000b7a22eae639460bc6ec8bb1cc689ecae5b0ed21935cd7d7dd52d38270c86" dependencies = [ "base64-simd", "icy_sixel", @@ -2954,25 +3364,37 @@ dependencies = [ "rand 0.8.6", "ratatui", "rustix 0.38.44", + "self_cell", "thiserror 1.0.69", "windows", ] [[package]] name = "ratatui-macros" -version = "0.7.0" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" dependencies = [ "ratatui-core", "ratatui-widgets", ] [[package]] -name = "ratatui-termion" +name = "ratatui-termina" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cade85a8591fbc911e147951422f0d6fd40f4948b271b6216c7dc01838996f8" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termion" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87c732202fa5a71a9da0991013f0853e53f87048f45198e3a1ca3ee722accc2f" dependencies = [ "instability", "ratatui-core", @@ -2981,9 +3403,9 @@ dependencies = [ [[package]] name = "ratatui-termwiz" -version = "0.1.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" dependencies = [ "ratatui-core", "termwiz", @@ -3014,15 +3436,15 @@ dependencies = [ [[package]] name = "ratatui-widgets" -version = "0.3.0" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" dependencies = [ - "bitflags 2.11.1", - "hashbrown 0.16.1", + "bitflags 2.13.1", + "hashbrown 0.17.0", "indoc", "instability", - "itertools", + "itertools 0.14.0", "line-clipping", "ratatui-core", "serde", @@ -3058,7 +3480,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", ] [[package]] @@ -3105,9 +3527,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.3" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -3117,9 +3539,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" dependencies = [ "aho-corasick", "memchr", @@ -3128,9 +3550,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "relative-path" @@ -3179,6 +3601,32 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "resvg" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9be183ad6a216aa96f33e4c8033b0988b8b3ea6fd2359d19af5bac4643fd8e81" +dependencies = [ + "gif", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", + "zune-jpeg", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + [[package]] name = "ring" version = "0.17.14" @@ -3206,6 +3654,10 @@ dependencies = [ "dirs", "dirs-next", "fast-strip-ansi", + "fastnbt", + "flate2", + "fs2", + "html-to-markdown-rs", "image", "insta", "libcasr", @@ -3214,15 +3666,19 @@ dependencies = [ "notify", "oauth2", "open", + "pulldown-cmark", "ratatui", "ratatui-image", "ratatui-textarea", "ratatui-themekit", "regex", "reqwest", - "rstest", + "resvg", + "rstest 0.23.0", "serde", "serde_json", + "sha1 0.10.7", + "sha2", "tachyonfx", "tempfile", "thiserror 2.0.18", @@ -3233,10 +3689,12 @@ dependencies = [ "tracing-appender", "tracing-subscriber", "tui-logger", + "tui-markdown", "tui-popup", "tui-prompts", "tui-scrollview", "tui-widget-list", + "unicode-segmentation", "which", "wiremock", "zip", @@ -3248,7 +3706,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4147b952f3f819eca0e99527022f7d6a8d05f111aeb0a62960c74eb283bec8fc" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "once_cell", "serde", "serde_derive", @@ -3256,6 +3714,21 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + [[package]] name = "rstest" version = "0.23.0" @@ -3264,10 +3737,21 @@ checksum = "0a2c585be59b6b5dd66a9d2084aa1d8bd52fbdb806eafdeffb52791147862035" dependencies = [ "futures", "futures-timer", - "rstest_macros", + "rstest_macros 0.23.0", "rustc_version", ] +[[package]] +name = "rstest" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" +dependencies = [ + "futures-timer", + "futures-util", + "rstest_macros 0.26.1", +] + [[package]] name = "rstest_macros" version = "0.23.0" @@ -3286,6 +3770,24 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "rstest_macros" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn 2.0.117", + "unicode-ident", +] + [[package]] name = "rust-ini" version = "0.21.3" @@ -3323,7 +3825,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -3336,7 +3838,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.12.1", @@ -3384,6 +3886,24 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + [[package]] name = "ryu" version = "1.0.23" @@ -3414,6 +3934,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + [[package]] name = "semver" version = "1.0.28" @@ -3442,6 +3968,16 @@ dependencies = [ "typeid", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -3507,6 +4043,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha1" version = "0.11.0" @@ -3587,6 +4134,15 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + [[package]] name = "siphasher" version = "1.0.2" @@ -3599,6 +4155,15 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + [[package]] name = "smallvec" version = "1.15.1" @@ -3627,6 +4192,39 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.13.1", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", +] + [[package]] name = "strsim" version = "0.11.1" @@ -3635,18 +4233,18 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" -version = "0.27.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" dependencies = [ "strum_macros", ] [[package]] name = "strum_macros" -version = "0.27.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" dependencies = [ "heck", "proc-macro2", @@ -3660,6 +4258,16 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "svgtypes" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" +dependencies = [ + "kurbo", + "siphasher", +] + [[package]] name = "symlink" version = "0.1.0" @@ -3742,6 +4350,28 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.1", + "parking_lot", + "rustix 1.1.4", + "signal-hook", + "windows-sys 0.61.2", +] + [[package]] name = "terminfo" version = "0.9.0" @@ -3750,8 +4380,8 @@ checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" dependencies = [ "fnv", "nom", - "phf", - "phf_codegen", + "phf 0.11.3", + "phf_codegen 0.11.3", ] [[package]] @@ -3782,7 +4412,7 @@ checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", "base64", - "bitflags 2.11.1", + "bitflags 2.13.1", "fancy-regex", "filedescriptor", "finl_unicode", @@ -3798,7 +4428,7 @@ dependencies = [ "ordered-float 4.6.0", "pest", "pest_derive", - "phf", + "phf 0.11.3", "serde", "sha2", "signal-hook", @@ -3918,6 +4548,32 @@ dependencies = [ "crunchy", ] +[[package]] +name = "tiny-skia" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -4066,7 +4722,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "bytes", "futures-util", "http", @@ -4180,6 +4836,15 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + [[package]] name = "tui-logger" version = "0.18.2" @@ -4197,6 +4862,20 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "tui-markdown" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c95afc7b66008823dea2614a800d944e18990690db47517efc66cd01ff9cee" +dependencies = [ + "itertools 0.15.0", + "pretty_assertions", + "pulldown-cmark", + "ratatui-core", + "rstest 0.26.1", + "tracing", +] + [[package]] name = "tui-popup" version = "0.7.4" @@ -4217,7 +4896,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2237609b6d31db3635072e3c2a0a501ab546ce4bbdc96cf1a62c2e7ce0ac7e0f" dependencies = [ "crossterm 0.29.0", - "itertools", + "itertools 0.14.0", "ratatui-core", "ratatui-macros", "ratatui-widgets", @@ -4269,12 +4948,48 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" + +[[package]] +name = "unicode-ccc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + [[package]] name = "unicode-segmentation" version = "1.13.2" @@ -4287,11 +5002,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ - "itertools", + "itertools 0.14.0", "unicode-segmentation", "unicode-width", ] +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + [[package]] name = "unicode-width" version = "0.2.2" @@ -4329,6 +5050,34 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "usvg" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d46cf96c5f498d36b7a9693bc6a7075c0bb9303189d61b2249b0dc3d309c07de" +dependencies = [ + "base64", + "data-url", + "flate2", + "fontdb", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree 0.21.1", + "rustybuzz", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path", + "ttf-parser", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + [[package]] name = "utf8_iter" version = "1.0.4" @@ -4539,7 +5288,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "hashbrown 0.15.5", "indexmap", "semver", @@ -4565,6 +5314,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web_atoms" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" +dependencies = [ + "phf 0.13.1", + "phf_codegen 0.13.1", + "string_cache", + "string_cache_codegen", +] + [[package]] name = "webpki-roots" version = "1.0.7" @@ -4574,6 +5335,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "wezterm-bidi" version = "0.2.3" @@ -5082,7 +5849,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.1", + "bitflags 2.13.1", "indexmap", "log", "serde", @@ -5127,6 +5894,12 @@ dependencies = [ "tap", ] +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + [[package]] name = "yaml-rust2" version = "0.10.4" @@ -5138,6 +5911,12 @@ dependencies = [ "hashlink", ] +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yoke" version = "0.8.2" @@ -5260,7 +6039,7 @@ dependencies = [ "memchr", "pbkdf2", "ppmd-rust", - "sha1", + "sha1 0.11.0", "time", "typed-path", "zeroize", @@ -5319,3 +6098,18 @@ dependencies = [ "cc", "pkg-config", ] + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index bbe1bad..b1457b1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ categories = ["command-line-utilities", "games"] [package.metadata.wix] upgrade-guid = "BEC08BE4-6A8F-4C1D-A1B4-B1AC963000AE" path-guid = "E5863AC2-13FC-45C8-B79D-55338F371A8A" +include = ["pkg/wix/main.wxs"] license = false eula = false @@ -30,27 +31,34 @@ config = "0.15.6" crossterm = "0.29.0" dirs-next = "2.0.0" log = "0.4" -ratatui = { version = "0.30.0", features = ["serde"] } +ratatui = { version = "0.30.0", features = ["serde", "unstable-rendered-line-info"] } ratatui-themekit = { version = "0.6", features = ["serde"] } ratatui-textarea = { version = "0.9.1", features = ["crossterm"] } +tui-markdown = { version = "0.3.7", default-features = false } +pulldown-cmark = { version = "0.13.4", default-features = false } +html-to-markdown-rs = { version = "3.9.0", features = ["visitor"] } +resvg = "0.47" reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] } serde = { version = "1.0.217", features = ["derive"] } serde_json = "1.0" +sha1 = "0.10" +sha2 = "0.10" toml = "1.1.2" tachyonfx = "0.25" throbber-widgets-tui = "0.11" thiserror = "2" -tokio = { version = "1", features = ["full"] } +tokio = { version = "1", features = ["full", "test-util"] } tracing = "0.1" tracing-appender = "0.2" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +unicode-segmentation = "1" tui-popup = "0.7" tui-prompts = "0.6" tui-logger = { version = "0.18", features = ["tracing-support"] } tui-scrollview = "0.6" tui-widget-list = "0.15" -ratatui-image = { version = "10.0.6", default-features = false, features = ["crossterm"] } -image = { version = "0.25", default-features = false, features = ["png"] } +ratatui-image = { version = "11.0.6", default-features = false, features = ["crossterm"] } +image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png", "webp"] } zip = "8.5.1" minecraft-msa-auth = "0.4.0" oauth2 = "5.0.0" @@ -61,6 +69,9 @@ notify = "8" regex = "1" fast-strip-ansi = "0.13" libcasr = "2.13" +fs2 = "0.4" +fastnbt = "2.6.2" +flate2 = "1.1.9" [profile.release] @@ -69,7 +80,6 @@ lto = true opt-level = "s" strip = true -# The profile that 'dist' will build with [profile.dist] inherits = "release" lto = "thin" @@ -79,4 +89,4 @@ tempfile = "3" insta = "1" wiremock = "0.6" rstest = "0.23" -image = { version = "0.25", default-features = false, features = ["png"] } +image = { version = "0.25", default-features = false, features = ["png", "webp"] } diff --git a/README.md b/README.md index d8fdeaa..bcf74be 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ it does everything you'd expect from a launcher. ### modpacks and accounts -`Modrinth`, `MultiMC`, and `GTNH` modpacks format is supported. Import it from a file, URL, or just the slug. Able to use multiple Microsoft accounts and offline accounts aswell. +Browse Modrinth and CurseForge modpacks or import Modrinth, CurseForge, MultiMC, Prism, and GTNH packs directly. Direct imports accept a file, Modrinth URL, or project slug. Source builds enable CurseForge when `CURSEFORGE_API_KEY` is set at compile time. Able to use multiple Microsoft accounts and offline accounts aswell. --- diff --git a/assets/config.toml b/assets/config.toml index 053d70e..88bf449 100644 --- a/assets/config.toml +++ b/assets/config.toml @@ -23,3 +23,17 @@ error_fly_out_ms = 300 # don’t let this blow up endlessly max_error_events = 50 + +[content] +# ask before choosing when the same file exists on multiple providers +ask_on_provider_conflict = true +# preferred for duplicate discovery results and automatic conflict resolution +# accepts "modrinth" or "curseforge" +preferred_provider = "modrinth" +# only show results from the preferred provider instead of filling from both +preferred_provider_only = false +# retry unchanged files that were not found after this many hours +unmatched_retry_hours = 24 +# avoid hashing enormous external packs during automatic provider matching; +# set to 0 to remove the limit +max_fingerprint_size_mib = 512 diff --git a/dist-workspace.toml b/dist-workspace.toml index 414a8fd..4561c87 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -26,4 +26,4 @@ hosting = "github" # Whether to install an updater program install-updater = false # Allow custom edits to the generated CI file -allow-dirty = ["ci"] +allow-dirty = ["ci", "msi"] diff --git a/docs/content-dependency-plan.md b/docs/content-dependency-plan.md new file mode 100644 index 0000000..22e2243 --- /dev/null +++ b/docs/content-dependency-plan.md @@ -0,0 +1,84 @@ +# Managed mod dependencies + +## Summary + +Discovery installs should understand provider-declared mod dependencies. Modrinth +and CurseForge dependency metadata is normalized into one internal model, while +modpacks continue to use their existing pack manifests and resource packs and +shaders remain unchanged. + +## Dependency handling + +- Recursively resolve required dependencies before downloading anything. +- For project-only requirements, select the newest compatible stable release for + the instance's Minecraft version and loader, falling back to beta and then + alpha. +- Honor exact Modrinth version requirements and fail clearly when the version is + incompatible. +- Deduplicate shared dependencies and reject cycles, incompatible installed + projects, and conflicting exact-version requirements. +- Keep an installed dependency unless the provider requires another exact + version. +- Recognize dependencies installed from the other provider only through an exact + fingerprint match. +- Do not install optional dependencies. Ignore embedded or included files because + they are already packaged. +- Resolve the complete operation before downloading, stage every download, and + update files and the content manifest together. Failed operations must restore + replaced files and remove staged downloads. +- Extend the existing install confirmation with the dependencies that will be + installed and any installed dependencies that will be replaced. + +## Tracking, updates, and removal + +- Store required dependency edges, exact provider aliases, and whether a file was + installed automatically in the content manifest with backward-compatible + defaults. +- Existing files remain user-managed. Directly installing or changing an + automatic dependency promotes it to user-managed content. +- Recalculate dependencies when a parent mod changes version. +- Keep the normal content-delete confirmation. If the removal leaves + automatically installed dependencies with no remaining dependents, only + include projects categorized exclusively as libraries. Missing categories or + any additional functional category keep the dependency installed. +- Keep shared dependencies while any installed mod requires them. +- Warn and require a stronger confirmation when directly removing a dependency + that installed mods still require, but allow the user to continue. + +## Provider and UI changes + +- Add normalized dependency relations and release type to the provider-neutral + version model. +- Add exact-version lookup to the provider interface. +- Run dependency resolution while the existing version popup is in its loading + state, then reuse its confirmation view for the installation summary. +- Show optional dependency counts without presenting them as installed content. +- Keep installation progress in the normal overview status area. + +## Tests + +- Provider parsing for every dependency relation and release channel. +- Transitive dependencies, shared libraries, cycles, conflicting versions, + incompatible projects, installed matches, and cross-provider fingerprints. +- Backward-compatible manifest loading, explicit promotion, updates, shared + references, and orphan cleanup. +- Staged installation rollback and successful multi-file commits. +- TUI interaction and snapshots for dependency summaries, replacements, orphan + cleanup, and required-library warnings. +- Focused tests, the full deterministic suite, Clippy with warnings denied, and + the repository snapshot check. + +## Discovery roadmap + +- [x] Managed mods, resource packs, and shaders +- [x] Modrinth and built-in CurseForge discovery +- [x] Modpack browsing and import +- [x] Search, endless scrolling, and page jumping +- [x] Provider project pages and version selection +- [x] Managed mod dependencies +- [x] Datapack discovery +- [x] Compatible update checks for managed content and discovered modpacks +- [x] Atomic bulk updates for mods, resource packs, shaders, and per-world datapacks +- [x] Rollback-safe managed modpack updates that preserve user files +- [x] Reinstall and arbitrary modpack version controls using the shared refresh engine +- [ ] Settings tab rework diff --git a/wix/main.wxs b/pkg/wix/main.wxs similarity index 95% rename from wix/main.wxs rename to pkg/wix/main.wxs index 8d18f36..0ded670 100644 --- a/wix/main.wxs +++ b/pkg/wix/main.wxs @@ -170,7 +170,7 @@ The product icon is the graphic that appears in the Add/Remove Programs control panel for the application. --> - + @@ -210,7 +210,7 @@ The banner BMP dimensions are 493 x 58 pixels. --> - + - + diff --git a/src/auth/accounts.rs b/src/auth/accounts.rs index a3f2107..300a697 100644 --- a/src/auth/accounts.rs +++ b/src/auth/accounts.rs @@ -192,188 +192,5 @@ pub fn create_offline_account(username: &str) -> Account { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn offline_uuid_is_valid_format() { - let uuid = offline_uuid("Steve"); - let parts: Vec<&str> = uuid.split('-').collect(); - assert_eq!(parts.len(), 5, "UUID must have 5 dash-separated parts"); - assert_eq!(parts[0].len(), 8); - assert_eq!(parts[1].len(), 4); - assert_eq!(parts[2].len(), 4); - assert_eq!(parts[3].len(), 4); - assert_eq!(parts[4].len(), 12); - } - - #[test] - fn offline_uuid_version_3_marker() { - let uuid = offline_uuid("Steve"); - assert!(uuid.split('-').nth(2).unwrap().starts_with('3')); - } - - #[test] - fn offline_uuid_variant_bit_set() { - let uuid = offline_uuid("Steve"); - let part3 = uuid.split('-').nth(3).unwrap(); - let first_nibble = u8::from_str_radix(&part3[..1], 16).unwrap(); - assert!((0x8..=0xb).contains(&first_nibble)); - } - - #[test] - fn offline_uuid_deterministic() { - assert_eq!(offline_uuid("Steve"), offline_uuid("Steve")); - assert_eq!(offline_uuid("Alex"), offline_uuid("Alex")); - } - - #[test] - fn offline_uuid_different_for_different_names() { - assert_ne!(offline_uuid("Steve"), offline_uuid("Alex")); - } - - #[test] - fn create_offline_account_fields() { - let acc = create_offline_account("TestPlayer"); - assert_eq!(acc.username, "TestPlayer"); - assert_eq!(acc.account_type, AccountType::Offline); - assert!(!acc.active); - assert!(acc.refresh_token.is_none()); - // pin the uuid to the deterministic offline_uuid output so a regression - // in the uuid derivation (e.g. salt change) would fail this test, not - // just a non-empty-string check that any garbage would pass. - assert_eq!(acc.uuid, offline_uuid("TestPlayer")); - } - - fn make_store(dir: &std::path::Path) -> AccountStore { - AccountStore { - accounts: Vec::new(), - path: dir.join("accounts.json"), - } - } - - fn microsoft_account(name: &str) -> Account { - Account { - uuid: format!("00000000-0000-0000-0000-{:012}", name.len()), - username: name.to_owned(), - account_type: AccountType::Microsoft, - active: false, - refresh_token: Some("refresh".to_owned()), - cached_mc_token: None, - cached_mc_token_expires_at: None, - } - } - - #[test] - fn store_add_first_becomes_active() { - let tmp = tempfile::tempdir().unwrap(); - let mut store = make_store(tmp.path()); - store.add(create_offline_account("Alice")); - assert_eq!(store.accounts.len(), 1); - assert!(store.accounts[0].active); - } - - #[test] - fn store_add_second_stays_inactive() { - let tmp = tempfile::tempdir().unwrap(); - let mut store = make_store(tmp.path()); - store.add(create_offline_account("Alice")); - store.add(create_offline_account("Bob")); - assert_eq!(store.accounts.len(), 2); - assert!(store.accounts[0].active); - assert!(!store.accounts[1].active); - } - - #[test] - fn store_add_duplicate_uuid_replaces() { - let tmp = tempfile::tempdir().unwrap(); - let mut store = make_store(tmp.path()); - store.add(create_offline_account("Alice")); - let mut dup = create_offline_account("Alice"); - dup.username = "AliceRenamed".to_owned(); - dup.uuid = store.accounts[0].uuid.clone(); - store.add(dup); - assert_eq!(store.accounts.len(), 1); - assert_eq!(store.accounts[0].username, "AliceRenamed"); - } - - #[test] - fn store_active_account_none_when_empty() { - let tmp = tempfile::tempdir().unwrap(); - let store = make_store(tmp.path()); - assert!(store.active_account().is_none()); - } - - #[test] - fn store_has_microsoft_account_when_one_exists() { - let tmp = tempfile::tempdir().unwrap(); - let mut store = make_store(tmp.path()); - store.add(create_offline_account("Offline")); - assert!(!store.has_microsoft_account()); - - store.add(microsoft_account("Owner")); - assert!(store.has_microsoft_account()); - } - - #[test] - fn store_active_account_returns_active() { - let tmp = tempfile::tempdir().unwrap(); - let mut store = make_store(tmp.path()); - store.add(create_offline_account("Alice")); - store.add(create_offline_account("Bob")); - let active = store.active_account().unwrap(); - assert_eq!(active.username, "Alice"); - } - - #[test] - fn store_set_active_changes_active() { - let tmp = tempfile::tempdir().unwrap(); - let mut store = make_store(tmp.path()); - store.add(create_offline_account("Alice")); - store.add(create_offline_account("Bob")); - store.set_active(1); - assert!(!store.accounts[0].active); - assert!(store.accounts[1].active); - } - - #[test] - fn store_remove_activates_first_remaining() { - let tmp = tempfile::tempdir().unwrap(); - let mut store = make_store(tmp.path()); - store.add(create_offline_account("Alice")); - store.add(create_offline_account("Bob")); - store.remove(0); - assert_eq!(store.accounts.len(), 1); - assert_eq!(store.accounts[0].username, "Bob"); - assert!(store.accounts[0].active); - } - - #[test] - fn store_remove_out_of_bounds_noop() { - let tmp = tempfile::tempdir().unwrap(); - let mut store = make_store(tmp.path()); - store.add(create_offline_account("Alice")); - store.remove(5); - assert_eq!(store.accounts.len(), 1); - } - - #[test] - fn store_save_and_reload() { - let tmp = tempfile::tempdir().unwrap(); - let mut store = make_store(tmp.path()); - store.add(create_offline_account("Alice")); - store.add(create_offline_account("Bob")); - store.save(); - - let reloaded = AccountStore { - accounts: serde_json::from_str( - &std::fs::read_to_string(tmp.path().join("accounts.json")).unwrap(), - ) - .unwrap(), - path: tmp.path().join("accounts.json"), - }; - assert_eq!(reloaded.accounts.len(), 2); - assert_eq!(reloaded.accounts[0].username, "Alice"); - assert!(reloaded.accounts[0].active); - } -} +#[path = "tests/accounts.rs"] +mod tests; diff --git a/src/auth/oauth.rs b/src/auth/oauth.rs index ab4b9bb..87ebae6 100644 --- a/src/auth/oauth.rs +++ b/src/auth/oauth.rs @@ -34,6 +34,19 @@ struct McProfile { name: String, } +fn normalize_profile_uuid(id: &str) -> Option { + (id.len() == 32 && id.bytes().all(|byte| byte.is_ascii_hexdigit())).then(|| { + format!( + "{}-{}-{}-{}-{}", + &id[..8], + &id[8..12], + &id[12..16], + &id[16..20], + &id[20..32], + ) + }) +} + // shared slot so the TUI can poll for the device code to show the user pub static DEVICE_CODE_DISPLAY: LazyLock>>> = LazyLock::new(|| Arc::new(Mutex::new(None))); @@ -61,7 +74,7 @@ async fn run_full_oauth_flow() -> Result<(String, Option), String> { user_code: details.user_code().secret().to_owned(), verification_uri: details.verification_uri().to_string(), }); - crate::tui::request_redraw(); + crate::feedback::request_redraw(); } let token = oauth_client @@ -86,7 +99,7 @@ pub fn start_microsoft_auth() -> Arc>> { let outcome = run_full_auth_flow().await; if let Ok(mut slot) = result_clone.lock() { *slot = Some(outcome); - crate::tui::request_redraw(); + crate::feedback::request_redraw(); } }); @@ -136,17 +149,9 @@ async fn exchange_and_build_account( }; // mojang returns uuids without dashes - let uuid = if profile.id.len() == 32 { - format!( - "{}-{}-{}-{}-{}", - &profile.id[..8], - &profile.id[8..12], - &profile.id[12..16], - &profile.id[16..20], - &profile.id[20..32], - ) - } else { - profile.id.clone() + let uuid = match normalize_profile_uuid(&profile.id) { + Some(uuid) => uuid, + None => return AuthResult::Error("Profile returned an invalid UUID".to_owned()), }; AuthResult::Success(Account { @@ -237,41 +242,5 @@ pub async fn refresh_and_get_token( } #[cfg(test)] -mod tests { - use super::*; - - fn microsoft_account(cached_mc_token_expires_at: Option) -> Account { - Account { - uuid: "00000000-0000-0000-0000-000000000000".to_owned(), - username: "TestPlayer".to_owned(), - account_type: AccountType::Microsoft, - active: true, - refresh_token: Some("refresh".to_owned()), - cached_mc_token: Some("cached".to_owned()), - cached_mc_token_expires_at, - } - } - - #[test] - fn cached_mc_token_is_valid_before_refresh_margin() { - let now = 1_000; - let account = microsoft_account(Some(now + MC_TOKEN_CACHE_REFRESH_MARGIN_SECS + 1)); - - assert_eq!(valid_cached_mc_token(&account, now), Some("cached")); - } - - #[test] - fn cached_mc_token_expires_inside_refresh_margin() { - let now = 1_000; - let account = microsoft_account(Some(now + MC_TOKEN_CACHE_REFRESH_MARGIN_SECS)); - - assert!(valid_cached_mc_token(&account, now).is_none()); - } - - #[test] - fn cached_mc_token_requires_expiry() { - let account = microsoft_account(None); - - assert!(valid_cached_mc_token(&account, 1_000).is_none()); - } -} +#[path = "tests/oauth.rs"] +mod tests; diff --git a/src/auth/tests/accounts.rs b/src/auth/tests/accounts.rs new file mode 100644 index 0000000..de9745e --- /dev/null +++ b/src/auth/tests/accounts.rs @@ -0,0 +1,184 @@ +use super::*; + +impl AccountStore { + pub(crate) fn empty_for_test(path: PathBuf) -> Self { + Self { + accounts: Vec::new(), + path, + } + } +} + +#[test] +fn offline_uuid_has_valid_v3_shape() { + let uuid = offline_uuid("Steve"); + let parts: Vec<&str> = uuid.split('-').collect(); + assert_eq!(parts.len(), 5, "UUID must have 5 dash-separated parts"); + assert_eq!(parts[0].len(), 8); + assert_eq!(parts[1].len(), 4); + assert_eq!(parts[2].len(), 4); + assert_eq!(parts[3].len(), 4); + assert_eq!(parts[4].len(), 12); + assert!(parts[2].starts_with('3')); + let first_nibble = u8::from_str_radix(&parts[3][..1], 16).unwrap(); + assert!((0x8..=0xb).contains(&first_nibble)); +} + +#[test] +fn offline_uuid_is_pinned_for_known_names() { + assert_eq!( + offline_uuid("Steve"), + "7e0a3689-ed7c-347c-87cc-3689ed7c47cc" + ); + assert_eq!(offline_uuid("Alex"), "a13d42a3-454f-3e39-a392-42a3454fe392"); +} + +#[test] +fn offline_uuid_different_for_different_names() { + assert_ne!(offline_uuid("Steve"), offline_uuid("Alex")); +} + +#[test] +fn create_offline_account_fields() { + let acc = create_offline_account("TestPlayer"); + assert_eq!(acc.username, "TestPlayer"); + assert_eq!(acc.account_type, AccountType::Offline); + assert!(!acc.active); + assert!(acc.refresh_token.is_none()); + // pin the uuid to the deterministic offline_uuid output so a regression + // in the uuid derivation (e.g. salt change) would fail this test, not + // just a non-empty-string check that any garbage would pass. + assert_eq!(acc.uuid, offline_uuid("TestPlayer")); +} + +fn make_store(dir: &std::path::Path) -> AccountStore { + AccountStore { + accounts: Vec::new(), + path: dir.join("accounts.json"), + } +} + +fn microsoft_account(name: &str) -> Account { + Account { + uuid: format!("00000000-0000-0000-0000-{:012}", name.len()), + username: name.to_owned(), + account_type: AccountType::Microsoft, + active: false, + refresh_token: Some("refresh".to_owned()), + cached_mc_token: None, + cached_mc_token_expires_at: None, + } +} + +#[test] +fn store_add_first_becomes_active() { + let tmp = tempfile::tempdir().unwrap(); + let mut store = make_store(tmp.path()); + store.add(create_offline_account("Alice")); + assert_eq!(store.accounts.len(), 1); + assert!(store.accounts[0].active); +} + +#[test] +fn store_add_second_stays_inactive() { + let tmp = tempfile::tempdir().unwrap(); + let mut store = make_store(tmp.path()); + store.add(create_offline_account("Alice")); + store.add(create_offline_account("Bob")); + assert_eq!(store.accounts.len(), 2); + assert!(store.accounts[0].active); + assert!(!store.accounts[1].active); +} + +#[test] +fn store_add_duplicate_uuid_replaces() { + let tmp = tempfile::tempdir().unwrap(); + let mut store = make_store(tmp.path()); + store.add(create_offline_account("Alice")); + let mut dup = create_offline_account("Alice"); + dup.username = "AliceRenamed".to_owned(); + dup.uuid = store.accounts[0].uuid.clone(); + store.add(dup); + assert_eq!(store.accounts.len(), 1); + assert_eq!(store.accounts[0].username, "AliceRenamed"); +} + +#[test] +fn store_active_account_none_when_empty() { + let tmp = tempfile::tempdir().unwrap(); + let store = make_store(tmp.path()); + assert!(store.active_account().is_none()); +} + +#[test] +fn store_has_microsoft_account_when_one_exists() { + let tmp = tempfile::tempdir().unwrap(); + let mut store = make_store(tmp.path()); + store.add(create_offline_account("Offline")); + assert!(!store.has_microsoft_account()); + + store.add(microsoft_account("Owner")); + assert!(store.has_microsoft_account()); +} + +#[test] +fn store_active_account_returns_active() { + let tmp = tempfile::tempdir().unwrap(); + let mut store = make_store(tmp.path()); + store.add(create_offline_account("Alice")); + store.add(create_offline_account("Bob")); + let active = store.active_account().unwrap(); + assert_eq!(active.username, "Alice"); +} + +#[test] +fn store_set_active_changes_active() { + let tmp = tempfile::tempdir().unwrap(); + let mut store = make_store(tmp.path()); + store.add(create_offline_account("Alice")); + store.add(create_offline_account("Bob")); + store.set_active(1); + assert!(!store.accounts[0].active); + assert!(store.accounts[1].active); +} + +#[test] +fn store_remove_activates_first_remaining() { + let tmp = tempfile::tempdir().unwrap(); + let mut store = make_store(tmp.path()); + store.add(create_offline_account("Alice")); + store.add(create_offline_account("Bob")); + store.remove(0); + assert_eq!(store.accounts.len(), 1); + assert_eq!(store.accounts[0].username, "Bob"); + assert!(store.accounts[0].active); +} + +#[test] +fn store_remove_out_of_bounds_noop() { + let tmp = tempfile::tempdir().unwrap(); + let mut store = make_store(tmp.path()); + store.add(create_offline_account("Alice")); + store.remove(5); + assert_eq!(store.accounts.len(), 1); +} + +#[test] +fn store_save_and_reload() { + let tmp = tempfile::tempdir().unwrap(); + let mut store = make_store(tmp.path()); + store.add(create_offline_account("Alice")); + store.add(create_offline_account("Bob")); + store.save(); + + let reloaded = AccountStore { + accounts: serde_json::from_str( + &std::fs::read_to_string(tmp.path().join("accounts.json")).unwrap(), + ) + .unwrap(), + path: tmp.path().join("accounts.json"), + }; + assert_eq!(reloaded.accounts.len(), 2); + assert_eq!(reloaded.accounts[0].username, "Alice"); + assert!(reloaded.accounts[0].active); +} diff --git a/src/auth/tests/oauth.rs b/src/auth/tests/oauth.rs new file mode 100644 index 0000000..ef8f05e --- /dev/null +++ b/src/auth/tests/oauth.rs @@ -0,0 +1,52 @@ +use super::*; + +fn microsoft_account(cached_mc_token_expires_at: Option) -> Account { + Account { + uuid: "00000000-0000-0000-0000-000000000000".to_owned(), + username: "TestPlayer".to_owned(), + account_type: AccountType::Microsoft, + active: true, + refresh_token: Some("refresh".to_owned()), + cached_mc_token: Some("cached".to_owned()), + cached_mc_token_expires_at, + } +} + +#[test] +fn cached_mc_token_is_valid_before_refresh_margin() { + let now = 1_000; + let account = microsoft_account(Some(now + MC_TOKEN_CACHE_REFRESH_MARGIN_SECS + 1)); + + assert_eq!(valid_cached_mc_token(&account, now), Some("cached")); +} + +#[test] +fn cached_mc_token_expires_inside_refresh_margin() { + let now = 1_000; + let account = microsoft_account(Some(now + MC_TOKEN_CACHE_REFRESH_MARGIN_SECS)); + + assert!(valid_cached_mc_token(&account, now).is_none()); +} + +#[test] +fn cached_mc_token_requires_expiry() { + let account = microsoft_account(None); + + assert!(valid_cached_mc_token(&account, 1_000).is_none()); +} + +#[test] +fn profile_uuid_is_normalized_without_slicing_unicode() { + assert_eq!( + normalize_profile_uuid("0123456789abcdef0123456789abcdef"), + Some("01234567-89ab-cdef-0123-456789abcdef".to_owned()) + ); + + let unicode_id = format!("{}é{}", "a".repeat(7), "b".repeat(23)); + assert_eq!(unicode_id.len(), 32); + assert_eq!(normalize_profile_uuid(&unicode_id), None); + assert_eq!( + normalize_profile_uuid("not-a-valid-minecraft-profile-id"), + None + ); +} diff --git a/src/cli/account.rs b/src/cli/account.rs index 4e61a98..b9032d4 100644 --- a/src/cli/account.rs +++ b/src/cli/account.rs @@ -19,22 +19,6 @@ pub async fn handle_account(matches: &ArgMatches) -> CliResult { } } -// trait indirection so a mock store can be swapped in for tests -trait AccountStoreLike { - fn has_microsoft_account(&self) -> bool; - fn add_account(&mut self, account: Account); -} - -impl AccountStoreLike for AccountStore { - fn has_microsoft_account(&self) -> bool { - AccountStore::has_microsoft_account(self) - } - - fn add_account(&mut self, account: Account) { - self.add(account); - } -} - fn list_accounts() -> CliResult { let store = AccountStore::load(); let rows = store @@ -109,7 +93,7 @@ async fn add_microsoft_account() -> CliResult { } } -fn add_offline_account(store: &mut T, username: &str) -> CliResult { +fn add_offline_account(store: &mut AccountStore, username: &str) -> CliResult { let username = username.trim(); if username.is_empty() { return Err(io::Error::other("offline username cannot be empty").into()); @@ -121,7 +105,7 @@ fn add_offline_account(store: &mut T, username: &str) -> Cl .into()); } - store.add_account(crate::auth::create_offline_account(username)); + store.add(crate::auth::create_offline_account(username)); Ok(()) } @@ -160,63 +144,5 @@ fn find_account_index(accounts: &[Account], username: &str) -> Option { use super::utils::{confirm, required_arg}; #[cfg(test)] -mod tests { - use super::{AccountStoreLike, add_offline_account}; - use crate::auth::{Account, AccountType}; - - #[derive(Default)] - struct MockStore { - accounts: Vec, - } - - impl AccountStoreLike for MockStore { - fn has_microsoft_account(&self) -> bool { - self.accounts - .iter() - .any(|account| account.account_type == AccountType::Microsoft) - } - - fn add_account(&mut self, account: Account) { - self.accounts.push(account); - } - } - - fn microsoft_account() -> Account { - Account { - uuid: "00000000-0000-0000-0000-000000000001".to_owned(), - username: "Owner".to_owned(), - account_type: AccountType::Microsoft, - active: false, - refresh_token: Some("refresh".to_owned()), - cached_mc_token: None, - cached_mc_token_expires_at: None, - } - } - - #[test] - fn creates_offline_account_after_microsoft_account_exists() { - let mut store = MockStore::default(); - store.add_account(microsoft_account()); - add_offline_account(&mut store, "Steve").expect("offline account should be added"); - - assert_eq!(store.accounts.len(), 2); - assert_eq!(store.accounts[1].username, "Steve"); - assert_eq!(store.accounts[1].account_type, AccountType::Offline); - } - - #[test] - fn rejects_offline_account_before_microsoft_account_exists() { - let mut store = MockStore::default(); - let err = add_offline_account(&mut store, "Steve") - .expect_err("offline account should require a microsoft account"); - - assert!(err.to_string().contains("Microsoft account")); - assert!(store.accounts.is_empty()); - } - - #[test] - fn rejects_empty_offline_username() { - let mut store = MockStore::default(); - assert!(add_offline_account(&mut store, " ").is_err()); - } -} +#[path = "tests/account.rs"] +mod tests; diff --git a/src/cli/content.rs b/src/cli/content.rs index dc5781a..fb65f8d 100644 --- a/src/cli/content.rs +++ b/src/cli/content.rs @@ -8,7 +8,7 @@ use clap::ArgMatches; use super::utils::{require_instance, required_arg}; use crate::cli::output::print_table; -use crate::instance::ContentEntry; +use crate::instance::content::entry::ContentEntry; type CliResult = Result<(), Box>; type Scanner = fn(&Path, &str) -> Vec; @@ -106,7 +106,7 @@ fn toggle_entry( return Ok(()); } - crate::instance::content::mods::toggle_entry(entry)?; + crate::instance::content::entry::toggle_entry(entry)?; println!( "{}d '{}'.", if should_enable { "Enable" } else { "Disable" }, @@ -116,33 +116,5 @@ fn toggle_entry( } #[cfg(test)] -mod tests { - use super::find_entry_by_stem; - use crate::instance::ContentEntry; - use std::path::PathBuf; - - fn entry(file_stem: &str) -> ContentEntry { - ContentEntry { - file_stem: file_stem.to_string(), - name: file_stem.to_string(), - description: String::new(), - enabled: true, - icon_bytes: None, - path: PathBuf::from(file_stem), - icon_lines: None, - } - } - - #[test] - fn matches_by_stem_case_insensitively() { - let entries = vec![entry("Sodium"), entry("Lithium")]; - let found = find_entry_by_stem(&entries, "sOdIuM").expect("entry should match"); - assert_eq!(found.file_stem, "Sodium"); - } - - #[test] - fn returns_none_for_missing_stem() { - let entries = vec![entry("Sodium")]; - assert!(find_entry_by_stem(&entries, "iris").is_none()); - } -} +#[path = "tests/content.rs"] +mod tests; diff --git a/src/cli/import.rs b/src/cli/import.rs index b08a1df..f4d46ab 100644 --- a/src/cli/import.rs +++ b/src/cli/import.rs @@ -39,18 +39,7 @@ pub async fn handle_import(matches: &ArgMatches) -> CliResult { .await .map_err(|e| format!("Failed to fetch versions: {e}"))?; - if versions.is_empty() { - return Err("No versions found for this modpack".into()); - } - - let version = if let Some(version_name) = override_version { - versions - .iter() - .find(|v| v.version_number == *version_name || v.name == *version_name) - .ok_or_else(|| format!("Version '{version_name}' not found"))? - } else { - &versions[0] - }; + let version = select_version(&versions, override_version.map(String::as_str))?; println!( "Using version {} ({})", @@ -58,7 +47,7 @@ pub async fn handle_import(matches: &ArgMatches) -> CliResult { version.game_versions.first().unwrap_or(&"?".to_string()) ); - let tmp_dir = manager.meta_dir.join("tmp"); + let tmp_dir = crate::storage::MetadataPaths::new(&manager.meta_dir).temporary(); std::fs::create_dir_all(&tmp_dir)?; modrinth::download_mrpack(&client, version, &tmp_dir) .await @@ -79,7 +68,7 @@ pub async fn handle_import(matches: &ArgMatches) -> CliResult { version.game_versions.first().unwrap_or(&"?".to_string()) ); - let tmp_dir = manager.meta_dir.join("tmp"); + let tmp_dir = crate::storage::MetadataPaths::new(&manager.meta_dir).temporary(); std::fs::create_dir_all(&tmp_dir)?; modrinth::download_mrpack(&client, &version, &tmp_dir) .await @@ -110,3 +99,26 @@ pub async fn handle_import(matches: &ArgMatches) -> CliResult { println!("Instance '{}' created successfully.", config.name); Ok(()) } + +fn select_version<'a>( + versions: &'a [modrinth::VersionInfo], + requested: Option<&str>, +) -> Result<&'a modrinth::VersionInfo, String> { + requested.map_or_else( + || { + versions + .first() + .ok_or_else(|| "No versions found for this modpack".to_owned()) + }, + |name| { + versions + .iter() + .find(|version| version.version_number == name || version.name == name) + .ok_or_else(|| format!("Version '{name}' not found")) + }, + ) +} + +#[cfg(test)] +#[path = "tests/import.rs"] +mod tests; diff --git a/src/cli/instance.rs b/src/cli/instance.rs index f5f2435..62c181d 100644 --- a/src/cli/instance.rs +++ b/src/cli/instance.rs @@ -7,8 +7,8 @@ use clap::ArgMatches; use super::utils::{confirm, required_arg}; use crate::cli::output::{format_datetime, print_table}; +use crate::instance::runtime::RunState; use crate::instance::{InstanceManager, ModLoader}; -use crate::running::RunState; type CliResult = Result<(), Box>; const LOCAL_CONFIG_PROFILE: &str = "instance default"; @@ -136,14 +136,14 @@ async fn launch_instance(matches: &ArgMatches) -> CliResult { let meta_dir = crate::config::SETTINGS.paths.resolve_meta_dir(); let config = manager.load_one(name)?; - crate::instance::launch::launch(&config, &instances_dir, &meta_dir) + crate::instance::launch::launch(&config, &instances_dir, &meta_dir, None) .await .map_err(|error| io::Error::other(format!("Launch failed: {}", error)))?; // poll until the game process exits. in CLI mode this blocks here // so the user gets a proper exit code at the end. loop { - match crate::running::get(name) { + match crate::instance::runtime::get(name) { Some(RunState::Crashed(Some(code))) => { println!("Game exited with status {}", code); break; @@ -354,21 +354,5 @@ fn apply_config_update( } #[cfg(test)] -mod tests { - use super::parse_resolution; - - #[test] - fn parses_valid_resolution() { - assert_eq!( - parse_resolution("1920x1080").expect("should parse"), - (1920, 1080) - ); - } - - #[test] - fn rejects_invalid_resolution_format() { - assert!(parse_resolution("1920").is_err()); - assert!(parse_resolution("1920xa").is_err()); - assert!(parse_resolution("0x1080").is_err()); - } -} +#[path = "tests/instance.rs"] +mod tests; diff --git a/src/cli/log.rs b/src/cli/log.rs index 862469a..dfd75e2 100644 --- a/src/cli/log.rs +++ b/src/cli/log.rs @@ -20,7 +20,7 @@ pub async fn handle_log(matches: &ArgMatches) -> CliResult { fn list_logs(instance: &str) -> CliResult { let instances_dir = crate::config::SETTINGS.paths.resolve_instances_dir(); require_instance(&instances_dir, instance)?; - let rows = crate::instance::log_files::scan_log_files(&instances_dir, instance) + let rows = crate::instance::logs::files::scan_log_files(&instances_dir, instance) .into_iter() .map(|entry| { let size = std::fs::metadata(&entry.path) @@ -42,7 +42,7 @@ async fn show_log(matches: &ArgMatches) -> CliResult { require_instance(&instances_dir, instance)?; let path = resolve_log_path(&instances_dir, instance, file)?; - let lines = crate::instance::log_files::read_log_file(&path); + let lines = crate::instance::logs::files::read_log_file(&path); for line in &lines { println!("{}", line); } @@ -53,7 +53,7 @@ async fn show_log(matches: &ArgMatches) -> CliResult { let mut last_len = lines.len(); loop { tokio::time::sleep(Duration::from_millis(500)).await; - let new_lines = crate::instance::log_files::read_log_file(&path); + let new_lines = crate::instance::logs::files::read_log_file(&path); for line in new_lines.iter().skip(last_len) { println!("{}", line); } @@ -71,14 +71,14 @@ pub(crate) fn resolve_log_path( file: Option<&str>, ) -> Result { if let Some(name) = file { - let path = crate::instance::log_files::log_dir(instances_dir, instance).join(name); + let path = crate::instance::logs::files::log_dir(instances_dir, instance).join(name); if !path.exists() { return Err(io::Error::other(format!("log '{}' not found", name))); } return Ok(path); } - crate::instance::log_files::scan_log_files(instances_dir, instance) + crate::instance::logs::files::scan_log_files(instances_dir, instance) .into_iter() .next() .map(|entry| entry.path) @@ -88,36 +88,5 @@ pub(crate) fn resolve_log_path( use super::utils::{require_instance, required_arg}; #[cfg(test)] -mod tests { - use super::resolve_log_path; - - #[test] - fn resolves_latest_log_when_no_file_is_given() { - let tmp = tempfile::tempdir().unwrap(); - let dir = tmp.path().join("demo/.minecraft/logs/launches"); - std::fs::create_dir_all(&dir).expect("log directory should exist"); - std::fs::write(dir.join("2024-01-02_03-04-05.log"), "newer").expect("write newer log"); - std::fs::write(dir.join("2024-01-01_03-04-05.log"), "older").expect("write older log"); - - let path = resolve_log_path(tmp.path(), "demo", None).expect("latest log should resolve"); - assert_eq!( - path.file_name().and_then(|name| name.to_str()), - Some("2024-01-02_03-04-05.log") - ); - } - - #[test] - fn resolves_named_log_file() { - let tmp = tempfile::tempdir().unwrap(); - let dir = tmp.path().join("demo/.minecraft/logs/launches"); - std::fs::create_dir_all(&dir).expect("log directory should exist"); - std::fs::write(dir.join("latest.log"), "hello").expect("write named log"); - - let path = resolve_log_path(tmp.path(), "demo", Some("latest.log")) - .expect("named log should resolve"); - assert_eq!( - path.file_name().and_then(|name| name.to_str()), - Some("latest.log") - ); - } -} +#[path = "tests/log.rs"] +mod tests; diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 7af32a9..91f1571 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -24,6 +24,49 @@ pub async fn init() { return; } + let instances_dir = crate::config::SETTINGS.paths.resolve_instances_dir(); + let meta_dir = crate::config::SETTINGS.paths.resolve_meta_dir(); + if crate::layout_migration::is_needed(&instances_dir, &meta_dir) { + let config = crate::config::get_config_path().join("config.toml"); + if let Err(error) = + crate::layout_migration::run(&instances_dir, &meta_dir, &config, |progress| { + eprintln!( + "{}: {} ({}/{})", + progress.phase, progress.item, progress.current, progress.total + ); + }) + { + eprintln!("error: layout migration failed safely: {error}"); + std::process::exit(1); + } + } else if let Err(error) = crate::layout_migration::initialize_new_layout(&meta_dir) { + eprintln!("error: cannot initialize metadata layout: {error}"); + std::process::exit(1); + } + if crate::layout_migration::cache_rebuild_pending(&meta_dir) { + let manager = crate::instance::InstanceManager::new(&instances_dir, &meta_dir); + let instances = manager.load_all(); + for (index, instance) in instances.iter().enumerate() { + eprintln!( + "Rebuilding runtime cache: {} ({}/{})", + instance.name, + index + 1, + instances.len() + ); + if let Err(error) = manager.repair_runtime_cache(instance).await { + eprintln!( + "error: runtime cache rebuild stopped safely for '{}': {error}", + instance.name + ); + std::process::exit(1); + } + } + if let Err(error) = crate::layout_migration::finish_cache_rebuild(&meta_dir) { + eprintln!("error: cannot finish runtime cache migration: {error}"); + std::process::exit(1); + } + } + let result = match matches.subcommand() { Some(("instance", sub_matches)) => instance::handle_instance(sub_matches).await, Some(("mod", sub_matches)) => content::handle_mod(sub_matches), @@ -246,21 +289,5 @@ fn build_content_command(name: &'static str, about: &'static str) -> Command { } #[cfg(test)] -mod tests { - use super::build_command; - - #[test] - fn parses_instance_list_subcommand() { - let matches = build_command() - .try_get_matches_from(["rmcl", "instance", "list"]) - .expect("command should parse"); - let instance = matches - .subcommand_matches("instance") - .expect("instance subcommand should be present"); - assert_eq!( - instance.subcommand_name(), - Some("list"), - "instance list should resolve to the list subcommand" - ); - } -} +#[path = "tests/arguments.rs"] +mod tests; diff --git a/src/cli/output.rs b/src/cli/output.rs index 9d8c56a..18f9f84 100644 --- a/src/cli/output.rs +++ b/src/cli/output.rs @@ -78,30 +78,5 @@ fn render_separator(widths: &[usize]) -> String { } #[cfg(test)] -mod tests { - use super::{format_datetime, render_table}; - use chrono::{TimeZone, Utc}; - - #[test] - fn render_table_aligns_columns() { - let rendered = render_table( - &["Name", "State"], - &[ - vec!["Alpha".to_string(), "enabled".to_string()], - vec!["Longer Name".to_string(), "off".to_string()], - ], - ); - - let lines: Vec<&str> = rendered.lines().collect(); - assert_eq!(lines[0], "Name State "); - assert_eq!(lines[1], "----------- -------"); - assert_eq!(lines[2], "Alpha enabled"); - assert_eq!(lines[3], "Longer Name off "); - } - - #[test] - fn formats_datetime_consistently() { - let dt = Utc.with_ymd_and_hms(2024, 1, 2, 3, 4, 5).unwrap(); - assert_eq!(format_datetime(&dt), "2024-01-02 03:04:05 UTC"); - } -} +#[path = "tests/output.rs"] +mod tests; diff --git a/src/cli/tests/account.rs b/src/cli/tests/account.rs new file mode 100644 index 0000000..dde4529 --- /dev/null +++ b/src/cli/tests/account.rs @@ -0,0 +1,44 @@ +use super::add_offline_account; +use crate::auth::{Account, AccountStore, AccountType}; + +fn microsoft_account() -> Account { + Account { + uuid: "00000000-0000-0000-0000-000000000001".to_owned(), + username: "Owner".to_owned(), + account_type: AccountType::Microsoft, + active: false, + refresh_token: Some("refresh".to_owned()), + cached_mc_token: None, + cached_mc_token_expires_at: None, + } +} + +#[test] +fn creates_offline_account_after_microsoft_account_exists() { + let temp = tempfile::tempdir().unwrap(); + let mut store = AccountStore::empty_for_test(temp.path().join("accounts.json")); + store.add(microsoft_account()); + add_offline_account(&mut store, "Steve").expect("offline account should be added"); + + assert_eq!(store.accounts.len(), 2); + assert_eq!(store.accounts[1].username, "Steve"); + assert_eq!(store.accounts[1].account_type, AccountType::Offline); +} + +#[test] +fn rejects_offline_account_before_microsoft_account_exists() { + let temp = tempfile::tempdir().unwrap(); + let mut store = AccountStore::empty_for_test(temp.path().join("accounts.json")); + let err = add_offline_account(&mut store, "Steve") + .expect_err("offline account should require a microsoft account"); + + assert!(err.to_string().contains("Microsoft account")); + assert!(store.accounts.is_empty()); +} + +#[test] +fn rejects_empty_offline_username() { + let temp = tempfile::tempdir().unwrap(); + let mut store = AccountStore::empty_for_test(temp.path().join("accounts.json")); + assert!(add_offline_account(&mut store, " ").is_err()); +} diff --git a/src/cli/tests/arguments.rs b/src/cli/tests/arguments.rs new file mode 100644 index 0000000..d9c49f5 --- /dev/null +++ b/src/cli/tests/arguments.rs @@ -0,0 +1,16 @@ +use super::build_command; + +#[test] +fn parses_instance_list_subcommand() { + let matches = build_command() + .try_get_matches_from(["rmcl", "instance", "list"]) + .expect("command should parse"); + let instance = matches + .subcommand_matches("instance") + .expect("instance subcommand should be present"); + assert_eq!( + instance.subcommand_name(), + Some("list"), + "instance list should resolve to the list subcommand" + ); +} diff --git a/src/cli/tests/content.rs b/src/cli/tests/content.rs new file mode 100644 index 0000000..a479b92 --- /dev/null +++ b/src/cli/tests/content.rs @@ -0,0 +1,37 @@ +use super::find_entry_by_stem; +use crate::instance::content::entry::ContentEntry; +use std::path::PathBuf; + +fn entry(file_stem: &str) -> ContentEntry { + ContentEntry { + file_stem: file_stem.to_string(), + name: file_stem.to_string(), + source_slug: None, + installed_path: None, + provider_project: None, + world_details: None, + title_suffix: None, + footer_label: None, + footer_change: None, + description: String::new(), + enabled: true, + icon_bytes: None, + provider_icon: false, + provider_description: false, + path: PathBuf::from(file_stem), + icon_lines: None, + } +} + +#[test] +fn matches_by_stem_case_insensitively() { + let entries = vec![entry("Sodium"), entry("Lithium")]; + let found = find_entry_by_stem(&entries, "sOdIuM").expect("entry should match"); + assert_eq!(found.file_stem, "Sodium"); +} + +#[test] +fn returns_none_for_missing_stem() { + let entries = vec![entry("Sodium")]; + assert!(find_entry_by_stem(&entries, "iris").is_none()); +} diff --git a/src/cli/tests/import.rs b/src/cli/tests/import.rs new file mode 100644 index 0000000..c5fbb32 --- /dev/null +++ b/src/cli/tests/import.rs @@ -0,0 +1,81 @@ +use super::*; + +fn version(name: &str, number: &str) -> modrinth::VersionInfo { + modrinth::VersionInfo { + id: number.to_owned(), + project_id: "project".to_owned(), + name: name.to_owned(), + version_number: number.to_owned(), + game_versions: vec!["1.21.1".to_owned()], + loaders: vec!["fabric".to_owned()], + version_type: modrinth::VersionType::Release, + dependencies: Vec::new(), + date_published: String::new(), + files: Vec::new(), + } +} + +#[test] +fn version_override_matches_name_or_number_and_rejects_unknown_values() { + let versions = vec![version("Stable", "1.0.0"), version("Beta", "2.0.0-beta")]; + + assert_eq!(select_version(&versions, None).unwrap().name, "Stable"); + assert_eq!( + select_version(&versions, Some("Beta")) + .unwrap() + .version_number, + "2.0.0-beta" + ); + assert_eq!( + select_version(&versions, Some("1.0.0")).unwrap().name, + "Stable" + ); + assert_eq!( + select_version(&versions, Some("missing")).unwrap_err(), + "Version 'missing' not found" + ); +} + +#[test] +fn import_command_parses_name_and_version_overrides() { + let matches = crate::cli::build_command() + .try_get_matches_from([ + "rmcl", + "import", + "example-pack", + "--name", + "Example", + "--version", + "1.2.3", + ]) + .unwrap(); + let import = matches.subcommand_matches("import").unwrap(); + + assert_eq!( + import.get_one::("source").map(String::as_str), + Some("example-pack") + ); + assert_eq!( + import.get_one::("name").map(String::as_str), + Some("Example") + ); + assert_eq!( + import.get_one::("version").map(String::as_str), + Some("1.2.3") + ); +} + +#[tokio::test] +async fn missing_local_pack_returns_a_file_not_found_error() { + let temp = tempfile::tempdir().unwrap(); + let missing = temp.path().join("missing.mrpack"); + let source = missing.to_string_lossy().into_owned(); + let matches = crate::cli::build_command() + .try_get_matches_from(["rmcl", "import", &source]) + .unwrap(); + let import = matches.subcommand_matches("import").unwrap(); + + let error = handle_import(import).await.unwrap_err().to_string(); + + assert_eq!(error, format!("File not found: {}", missing.display())); +} diff --git a/src/cli/tests/instance.rs b/src/cli/tests/instance.rs new file mode 100644 index 0000000..05bdbf7 --- /dev/null +++ b/src/cli/tests/instance.rs @@ -0,0 +1,16 @@ +use super::parse_resolution; + +#[test] +fn parses_valid_resolution() { + assert_eq!( + parse_resolution("1920x1080").expect("should parse"), + (1920, 1080) + ); +} + +#[test] +fn rejects_invalid_resolution_format() { + assert!(parse_resolution("1920").is_err()); + assert!(parse_resolution("1920xa").is_err()); + assert!(parse_resolution("0x1080").is_err()); +} diff --git a/src/cli/tests/log.rs b/src/cli/tests/log.rs new file mode 100644 index 0000000..27018e7 --- /dev/null +++ b/src/cli/tests/log.rs @@ -0,0 +1,31 @@ +use super::resolve_log_path; + +#[test] +fn resolves_latest_log_when_no_file_is_given() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("demo/minecraft/logs/launches"); + std::fs::create_dir_all(&dir).expect("log directory should exist"); + std::fs::write(dir.join("2024-01-02_03-04-05.log"), "newer").expect("write newer log"); + std::fs::write(dir.join("2024-01-01_03-04-05.log"), "older").expect("write older log"); + + let path = resolve_log_path(tmp.path(), "demo", None).expect("latest log should resolve"); + assert_eq!( + path.file_name().and_then(|name| name.to_str()), + Some("2024-01-02_03-04-05.log") + ); +} + +#[test] +fn resolves_named_log_file() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("demo/minecraft/logs/launches"); + std::fs::create_dir_all(&dir).expect("log directory should exist"); + std::fs::write(dir.join("latest.log"), "hello").expect("write named log"); + + let path = + resolve_log_path(tmp.path(), "demo", Some("latest.log")).expect("named log should resolve"); + assert_eq!( + path.file_name().and_then(|name| name.to_str()), + Some("latest.log") + ); +} diff --git a/src/cli/tests/output.rs b/src/cli/tests/output.rs new file mode 100644 index 0000000..2e00a4d --- /dev/null +++ b/src/cli/tests/output.rs @@ -0,0 +1,25 @@ +use super::{format_datetime, render_table}; +use chrono::{TimeZone, Utc}; + +#[test] +fn render_table_aligns_columns() { + let rendered = render_table( + &["Name", "State"], + &[ + vec!["Alpha".to_string(), "enabled".to_string()], + vec!["Longer Name".to_string(), "off".to_string()], + ], + ); + + let lines: Vec<&str> = rendered.lines().collect(); + assert_eq!(lines[0], "Name State "); + assert_eq!(lines[1], "----------- -------"); + assert_eq!(lines[2], "Alpha enabled"); + assert_eq!(lines[3], "Longer Name off "); +} + +#[test] +fn formats_datetime_consistently() { + let dt = Utc.with_ymd_and_hms(2024, 1, 2, 3, 4, 5).unwrap(); + assert_eq!(format_datetime(&dt), "2024-01-02 03:04:05 UTC"); +} diff --git a/src/cli/tests/version.rs b/src/cli/tests/version.rs new file mode 100644 index 0000000..8865378 --- /dev/null +++ b/src/cli/tests/version.rs @@ -0,0 +1,41 @@ +use super::filter_manifest_versions; +use crate::net::mojang::{LatestVersions, VersionEntry, VersionManifest}; +use std::collections::HashSet; + +fn manifest() -> VersionManifest { + VersionManifest { + latest: LatestVersions { + release: "1.20.1".to_string(), + snapshot: "24w01a".to_string(), + }, + versions: vec![ + VersionEntry { + id: "1.20.1".to_string(), + version_type: "release".to_string(), + url: "https://example.com/release".to_string(), + sha1: "a".to_string(), + }, + VersionEntry { + id: "24w01a".to_string(), + version_type: "snapshot".to_string(), + url: "https://example.com/snapshot".to_string(), + sha1: "b".to_string(), + }, + ], + } +} + +#[test] +fn filters_out_snapshots_by_default() { + let rows = filter_manifest_versions(&manifest(), None, false); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].id, "1.20.1"); +} + +#[test] +fn intersects_supported_versions() { + let supported = HashSet::from(["24w01a".to_string()]); + let rows = filter_manifest_versions(&manifest(), Some(&supported), true); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].id, "24w01a"); +} diff --git a/src/cli/version.rs b/src/cli/version.rs index e8b16db..4a522e2 100644 --- a/src/cli/version.rs +++ b/src/cli/version.rs @@ -96,46 +96,5 @@ fn filter_manifest_versions( } #[cfg(test)] -mod tests { - use super::filter_manifest_versions; - use crate::net::mojang::{LatestVersions, VersionEntry, VersionManifest}; - use std::collections::HashSet; - - fn manifest() -> VersionManifest { - VersionManifest { - latest: LatestVersions { - release: "1.20.1".to_string(), - snapshot: "24w01a".to_string(), - }, - versions: vec![ - VersionEntry { - id: "1.20.1".to_string(), - version_type: "release".to_string(), - url: "https://example.com/release".to_string(), - sha1: "a".to_string(), - }, - VersionEntry { - id: "24w01a".to_string(), - version_type: "snapshot".to_string(), - url: "https://example.com/snapshot".to_string(), - sha1: "b".to_string(), - }, - ], - } - } - - #[test] - fn filters_out_snapshots_by_default() { - let rows = filter_manifest_versions(&manifest(), None, false); - assert_eq!(rows.len(), 1); - assert_eq!(rows[0].id, "1.20.1"); - } - - #[test] - fn intersects_supported_versions() { - let supported = HashSet::from(["24w01a".to_string()]); - let rows = filter_manifest_versions(&manifest(), Some(&supported), true); - assert_eq!(rows.len(), 1); - assert_eq!(rows[0].id, "24w01a"); - } -} +#[path = "tests/version.rs"] +mod tests; diff --git a/src/config/mod.rs b/src/config/mod.rs index f2f7737..77b8bef 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -62,61 +62,11 @@ pub static SETTINGS: LazyLock = LazyLock::new(|| { paths: settings::Paths::default(), defaults: settings::Defaults::default(), ui: settings::Ui::default(), + content: settings::Content::default(), } }) }); #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn load_config_from_valid_toml() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("config.toml"); - std::fs::write( - &path, - r#" - [defaults] - memory_max = "4G" - "#, - ) - .unwrap(); - let config = load_config(&path).unwrap(); - assert_eq!(config.defaults.memory_max, "4G"); - assert_eq!(config.defaults.memory_min, "512M"); - } - - #[test] - fn load_config_from_empty_file() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("config.toml"); - std::fs::write(&path, "").unwrap(); - let config = load_config(&path).unwrap(); - assert_eq!(config.defaults.memory_max, "2G"); - } - - #[test] - fn load_config_missing_file_uses_defaults() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("nonexistent.toml"); - load_config(&path).unwrap(); - } - - #[test] - fn load_config_partial_sections() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("config.toml"); - std::fs::write( - &path, - r#" - [paths] - instances_dir = "/custom/path" - "#, - ) - .unwrap(); - let config = load_config(&path).unwrap(); - assert_eq!(config.paths.instances_dir, "/custom/path"); - assert!(config.paths.java_path.is_none()); - } -} +#[path = "tests/loading.rs"] +mod tests; diff --git a/src/config/settings.rs b/src/config/settings.rs index 04b8307..15aa18b 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -18,6 +18,104 @@ pub enum ImageProtocol { #[derive(Debug, Deserialize, Default)] pub struct General {} +#[derive(Debug, Deserialize)] +pub struct Content { + #[serde(default = "default_true")] + pub ask_on_provider_conflict: bool, + #[serde(default = "default_provider")] + pub preferred_provider: String, + #[serde(default)] + pub preferred_provider_only: bool, + #[serde(default = "default_unmatched_retry_hours")] + pub unmatched_retry_hours: u64, + #[serde(default = "default_max_fingerprint_size_mib")] + pub max_fingerprint_size_mib: u64, +} + +fn default_true() -> bool { + true +} + +fn default_provider() -> String { + "modrinth".to_owned() +} + +fn default_unmatched_retry_hours() -> u64 { + 24 +} + +fn default_max_fingerprint_size_mib() -> u64 { + 512 +} + +impl Default for Content { + fn default() -> Self { + Self { + ask_on_provider_conflict: true, + preferred_provider: default_provider(), + preferred_provider_only: false, + unmatched_retry_hours: default_unmatched_retry_hours(), + max_fingerprint_size_mib: default_max_fingerprint_size_mib(), + } + } +} + +impl Content { + pub fn preferred_provider(&self) -> &str { + self.preferred_provider_with_curseforge(crate::net::curseforge::api_key().is_some()) + } + + pub fn discovery_provider_enabled(&self, provider: &str) -> bool { + self.discovery_provider_enabled_with_curseforge( + provider, + crate::net::curseforge::api_key().is_some(), + ) + } + + pub fn discovery_provider_label(&self) -> &'static str { + self.discovery_provider_label_with_curseforge(crate::net::curseforge::api_key().is_some()) + } + + fn preferred_provider_with_curseforge(&self, curseforge_available: bool) -> &'static str { + if self.preferred_provider.eq_ignore_ascii_case("curseforge") && curseforge_available { + "curseforge" + } else { + "modrinth" + } + } + + fn discovery_provider_enabled_with_curseforge( + &self, + provider: &str, + curseforge_available: bool, + ) -> bool { + match provider { + "modrinth" => { + !self.preferred_provider_only + || self.preferred_provider_with_curseforge(curseforge_available) == "modrinth" + } + "curseforge" => { + curseforge_available + && (!self.preferred_provider_only + || self.preferred_provider_with_curseforge(curseforge_available) + == "curseforge") + } + _ => false, + } + } + + fn discovery_provider_label_with_curseforge(&self, curseforge_available: bool) -> &'static str { + match ( + self.discovery_provider_enabled_with_curseforge("modrinth", curseforge_available), + self.discovery_provider_enabled_with_curseforge("curseforge", curseforge_available), + ) { + (true, true) => "providers", + (false, true) => "CurseForge", + _ => "Modrinth", + } + } +} + #[derive(Debug, Deserialize)] pub struct Paths { #[serde(default = "default_instances_dir")] @@ -159,74 +257,10 @@ pub struct Config { pub defaults: Defaults, #[serde(default)] pub ui: Ui, + #[serde(default)] + pub content: Content, } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn effective_java_path_none_when_absent() { - let paths = Paths { - java_path: None, - ..Paths::default() - }; - assert!(paths.effective_java_path().is_none()); - } - - #[test] - fn effective_java_path_none_when_empty() { - let paths = Paths { - java_path: Some(String::new()), - ..Paths::default() - }; - assert!(paths.effective_java_path().is_none()); - } - - #[test] - fn effective_java_path_some_when_set() { - let paths = Paths { - java_path: Some("/usr/bin/java".to_owned()), - ..Paths::default() - }; - assert_eq!(paths.effective_java_path(), Some("/usr/bin/java")); - } - - #[test] - fn resolve_path_absolute() { - assert_eq!(resolve_path("/opt/rmcl"), PathBuf::from("/opt/rmcl")); - } - - #[test] - fn resolve_path_tilde_prefix() { - let resolved = resolve_path("~/games/rmcl"); - assert!(!resolved.to_string_lossy().starts_with('~')); - assert!(resolved.to_string_lossy().ends_with("games/rmcl")); - } - - #[test] - fn resolve_path_bare_tilde() { - let resolved = resolve_path("~"); - assert!(!resolved.to_string_lossy().starts_with('~')); - } - - #[test] - fn config_deserializes_from_empty_toml() { - let config: Config = toml::from_str("").unwrap(); - assert_eq!(config.defaults.memory_max, "2G"); - } - - #[test] - fn config_deserializes_partial_toml() { - let toml_str = r#" -[general] -debug = true - -[defaults] -memory_max = "8G" -"#; - let config: Config = toml::from_str(toml_str).unwrap(); - assert_eq!(config.defaults.memory_max, "8G"); - assert_eq!(config.defaults.memory_min, "512M"); - } -} +#[path = "tests/settings.rs"] +mod tests; diff --git a/src/config/tests/loading.rs b/src/config/tests/loading.rs new file mode 100644 index 0000000..b8709f3 --- /dev/null +++ b/src/config/tests/loading.rs @@ -0,0 +1,51 @@ +use super::*; + +#[test] +fn load_config_from_valid_toml() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("config.toml"); + std::fs::write( + &path, + r#" + [defaults] + memory_max = "4G" + "#, + ) + .unwrap(); + let config = load_config(&path).unwrap(); + assert_eq!(config.defaults.memory_max, "4G"); + assert_eq!(config.defaults.memory_min, "512M"); +} + +#[test] +fn load_config_from_empty_file() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("config.toml"); + std::fs::write(&path, "").unwrap(); + let config = load_config(&path).unwrap(); + assert_eq!(config.defaults.memory_max, "2G"); +} + +#[test] +fn load_config_missing_file_uses_defaults() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("nonexistent.toml"); + load_config(&path).unwrap(); +} + +#[test] +fn load_config_partial_sections() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("config.toml"); + std::fs::write( + &path, + r#" + [paths] + instances_dir = "/custom/path" + "#, + ) + .unwrap(); + let config = load_config(&path).unwrap(); + assert_eq!(config.paths.instances_dir, "/custom/path"); + assert!(config.paths.java_path.is_none()); +} diff --git a/src/config/tests/settings.rs b/src/config/tests/settings.rs new file mode 100644 index 0000000..80d5375 --- /dev/null +++ b/src/config/tests/settings.rs @@ -0,0 +1,104 @@ +use super::*; + +#[test] +fn effective_java_path_none_when_absent() { + let paths = Paths { + java_path: None, + ..Paths::default() + }; + assert!(paths.effective_java_path().is_none()); +} + +#[test] +fn effective_java_path_none_when_empty() { + let paths = Paths { + java_path: Some(String::new()), + ..Paths::default() + }; + assert!(paths.effective_java_path().is_none()); +} + +#[test] +fn effective_java_path_some_when_set() { + let paths = Paths { + java_path: Some("/usr/bin/java".to_owned()), + ..Paths::default() + }; + assert_eq!(paths.effective_java_path(), Some("/usr/bin/java")); +} + +#[test] +fn content_provider_settings_are_normalized() { + let content: Content = + toml::from_str("preferred_provider = \"curseforge\"\npreferred_provider_only = true") + .unwrap(); + assert_eq!( + content.preferred_provider_with_curseforge(false), + "modrinth" + ); + assert_eq!( + content.preferred_provider_with_curseforge(true), + "curseforge" + ); + assert!(content.preferred_provider_only); + assert!(content.discovery_provider_enabled_with_curseforge("modrinth", false)); + assert!(!content.discovery_provider_enabled_with_curseforge("curseforge", false)); + assert!(!content.discovery_provider_enabled_with_curseforge("modrinth", true)); + assert!(content.discovery_provider_enabled_with_curseforge("curseforge", true)); + assert_eq!( + content.discovery_provider_label_with_curseforge(false), + "Modrinth" + ); + assert_eq!( + content.discovery_provider_label_with_curseforge(true), + "CurseForge" + ); + + let merged = Content::default(); + assert_eq!( + merged.discovery_provider_label_with_curseforge(false), + "Modrinth" + ); + assert_eq!( + merged.discovery_provider_label_with_curseforge(true), + "providers" + ); +} + +#[test] +fn resolve_path_absolute() { + assert_eq!(resolve_path("/opt/rmcl"), PathBuf::from("/opt/rmcl")); +} + +#[test] +fn resolve_path_tilde_prefix() { + let resolved = resolve_path("~/games/rmcl"); + assert!(!resolved.to_string_lossy().starts_with('~')); + assert!(resolved.to_string_lossy().ends_with("games/rmcl")); +} + +#[test] +fn resolve_path_bare_tilde() { + let resolved = resolve_path("~"); + assert!(!resolved.to_string_lossy().starts_with('~')); +} + +#[test] +fn config_deserializes_from_empty_toml() { + let config: Config = toml::from_str("").unwrap(); + assert_eq!(config.defaults.memory_max, "2G"); +} + +#[test] +fn config_deserializes_partial_toml() { + let toml_str = r#" +[general] +debug = true + +[defaults] +memory_max = "8G" +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + assert_eq!(config.defaults.memory_max, "8G"); + assert_eq!(config.defaults.memory_min, "512M"); +} diff --git a/src/config/tests/theme.rs b/src/config/tests/theme.rs new file mode 100644 index 0000000..0acce86 --- /dev/null +++ b/src/config/tests/theme.rs @@ -0,0 +1,74 @@ +use super::*; + +// cover every BorderStyle variant. a mutation that swaps two arms of the +// match (e.g. Rounded -> Plain) would slip past testing just one variant. +#[rstest::rstest] +#[case::plain(BorderStyle::Plain, BorderType::Plain)] +#[case::rounded(BorderStyle::Rounded, BorderType::Rounded)] +#[case::double(BorderStyle::Double, BorderType::Double)] +#[case::thick(BorderStyle::Thick, BorderType::Thick)] +fn border_style_roundtrip(#[case] style: BorderStyle, #[case] expected: BorderType) { + assert_eq!(style.to_border_type(), expected); +} + +#[test] +fn theme_config_deserialize_builtin() { + let toml_str = r#" +theme = "dracula" +border_style = "plain" +"#; + let config: ThemeConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(config.theme, "dracula"); + assert_eq!(config.border_style, BorderStyle::Plain); + assert!(config.custom.is_none()); +} + +#[test] +fn theme_config_with_partial_overrides() { + let toml_str = r#" +theme = "dracula" + +[custom] +accent = "Red" +"#; + let config: ThemeConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(config.theme, "dracula"); + let overrides = config.custom.unwrap(); + assert_eq!(overrides.accent, Some(Color::Red)); + assert!(overrides.text.is_none()); +} + +#[test] +fn resolve_with_overrides_keeps_base() { + let config = ThemeConfig { + theme: "dracula".to_owned(), + custom: Some(ThemeOverrides { + accent: Some(Color::Red), + ..ThemeOverrides::default() + }), + ..ThemeConfig::default() + }; + let theme = resolve_app_theme(&config); + assert_eq!(theme.accent(), Color::Red); + let base = resolve_theme("dracula"); + assert_eq!(theme.text(), base.text()); + assert_eq!(theme.error(), base.error()); +} + +#[test] +fn resolve_builtin_theme() { + let theme = resolve_theme("dracula"); + let expected = if std::env::var_os("NO_COLOR").is_some() { + "no-color" + } else { + "dracula" + }; + assert_eq!(theme.id(), expected); +} + +#[test] +fn theme_config_empty_toml_uses_defaults() { + let config: ThemeConfig = toml::from_str("").unwrap(); + assert_eq!(config.theme, "catppuccin"); + assert_eq!(config.border_style, BorderStyle::Rounded); +} diff --git a/src/config/theme.rs b/src/config/theme.rs index ba0289f..b6c3e0e 100644 --- a/src/config/theme.rs +++ b/src/config/theme.rs @@ -169,78 +169,5 @@ pub static BORDER_STYLE: LazyLock = LazyLock::new(|| THEME_CONFIG.border_style.clone()); #[cfg(test)] -mod tests { - use super::*; - - // cover every BorderStyle variant. a mutation that swaps two arms of the - // match (e.g. Rounded -> Plain) would slip past testing just one variant. - #[rstest::rstest] - #[case::plain(BorderStyle::Plain, BorderType::Plain)] - #[case::rounded(BorderStyle::Rounded, BorderType::Rounded)] - #[case::double(BorderStyle::Double, BorderType::Double)] - #[case::thick(BorderStyle::Thick, BorderType::Thick)] - fn border_style_roundtrip(#[case] style: BorderStyle, #[case] expected: BorderType) { - assert_eq!(style.to_border_type(), expected); - } - - #[test] - fn theme_config_deserialize_builtin() { - let toml_str = r#" -theme = "dracula" -border_style = "plain" -"#; - let config: ThemeConfig = toml::from_str(toml_str).unwrap(); - assert_eq!(config.theme, "dracula"); - assert_eq!(config.border_style, BorderStyle::Plain); - assert!(config.custom.is_none()); - } - - #[test] - fn theme_config_with_partial_overrides() { - let toml_str = r#" -theme = "dracula" - -[custom] -accent = "Red" -"#; - let config: ThemeConfig = toml::from_str(toml_str).unwrap(); - assert_eq!(config.theme, "dracula"); - let overrides = config.custom.unwrap(); - assert_eq!(overrides.accent, Some(Color::Red)); - assert!(overrides.text.is_none()); - } - - #[test] - fn resolve_with_overrides_keeps_base() { - let config = ThemeConfig { - theme: "dracula".to_owned(), - custom: Some(ThemeOverrides { - accent: Some(Color::Red), - ..ThemeOverrides::default() - }), - ..ThemeConfig::default() - }; - let theme = resolve_app_theme(&config); - assert_eq!(theme.accent(), Color::Red); - let base = resolve_theme("dracula"); - assert_eq!(theme.text(), base.text()); - assert_eq!(theme.error(), base.error()); - } - - #[test] - fn resolve_builtin_theme() { - let config = ThemeConfig { - theme: "dracula".to_owned(), - ..ThemeConfig::default() - }; - let theme = resolve_app_theme(&config); - assert_eq!(theme.id(), "dracula"); - } - - #[test] - fn theme_config_empty_toml_uses_defaults() { - let config: ThemeConfig = toml::from_str("").unwrap(); - assert_eq!(config.theme, "catppuccin"); - assert_eq!(config.border_style, BorderStyle::Rounded); - } -} +#[path = "tests/theme.rs"] +mod tests; diff --git a/src/feedback/errors.rs b/src/feedback/errors.rs new file mode 100644 index 0000000..3412881 --- /dev/null +++ b/src/feedback/errors.rs @@ -0,0 +1,90 @@ +// thread-safe FIFO queue for error/warning toasts displayed in the UI. +// also (ab)used for INFO toasts like "desktop shortcut created" because +// why build a separate notification system when this one works fine. +// +// callers pass id: 0 and push_error assigns a real unique id. the id is +// used by the render layer to track per-toast animation state. + +use std::collections::VecDeque; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +use std::sync::LazyLock; +use tracing::Level; + +const MAX_ERROR_EVENTS: usize = 50; +static NEXT_ERROR_ID: AtomicU64 = AtomicU64::new(1); + +#[derive(Debug, Clone)] +pub struct ErrorEvent { + pub id: u64, + pub level: Level, + pub message: String, + pub pushed_at: Instant, +} + +pub static ERROR_EVENTS: LazyLock>>> = + LazyLock::new(|| Arc::new(Mutex::new(VecDeque::new()))); + +pub fn push_error(event: ErrorEvent) { + match ERROR_EVENTS.lock() { + Ok(mut events) => { + let mut event = event; + event.id = NEXT_ERROR_ID.fetch_add(1, Ordering::Relaxed); + + events.push_back(event); + + while events.len() > MAX_ERROR_EVENTS { + events.pop_front(); + } + super::request_redraw(); + } + Err(e) => { + tracing::error!("Error buffer lock poisoned: {}", e); + } + } +} + +#[must_use] +pub fn has_errors() -> bool { + match ERROR_EVENTS.lock() { + Ok(events) => !events.is_empty(), + Err(_) => false, + } +} + +#[must_use] +pub fn pop_error() -> Option { + match ERROR_EVENTS.lock() { + Ok(mut events) => { + let event = events.pop_front(); + if event.is_some() { + super::request_redraw(); + } + event + } + Err(_) => None, + } +} + +#[must_use] +pub fn peek_error() -> Option { + match ERROR_EVENTS.lock() { + Ok(events) => events.front().cloned(), + Err(_) => None, + } +} + +#[must_use] +// returned in reverse order (newest first) so they stack top-down in the UI +pub fn peek_all_errors() -> Vec { + match ERROR_EVENTS.lock() { + Ok(events) => events.iter().rev().cloned().collect(), + Err(_) => Vec::new(), + } +} + +#[cfg(test)] +#[path = "tests/errors.rs"] +mod tests; diff --git a/src/feedback/mod.rs b/src/feedback/mod.rs new file mode 100644 index 0000000..0b8744f --- /dev/null +++ b/src/feedback/mod.rs @@ -0,0 +1,16 @@ +// application feedback shared by background work and every frontend. + +use std::sync::atomic::{AtomicBool, Ordering}; + +pub mod errors; +pub mod progress; + +static REDRAW_REQUESTED: AtomicBool = AtomicBool::new(true); + +pub fn request_redraw() { + REDRAW_REQUESTED.store(true, Ordering::Release); +} + +pub(crate) fn take_redraw_request() -> bool { + REDRAW_REQUESTED.swap(false, Ordering::AcqRel) +} diff --git a/src/feedback/progress.rs b/src/feedback/progress.rs new file mode 100644 index 0000000..5050cfe --- /dev/null +++ b/src/feedback/progress.rs @@ -0,0 +1,222 @@ +// global progress state shared between background tasks and the status bar widget. +// background tasks set the action/progress, the render loop reads it every frame. + +use std::collections::BTreeMap; +use std::sync::LazyLock; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +#[derive(Debug, Default, Clone)] +pub struct ProgressState { + pub current_action: Option, + pub progress: Option<(u64, u64)>, + pub sub_action: Option, + tasks: BTreeMap, + legacy_active: bool, +} + +#[derive(Debug, Clone)] +struct TaskState { + action: String, + sub_action: Option, + progress: Option<(u64, u64)>, +} + +pub static PROGRESS: LazyLock>> = + LazyLock::new(|| Arc::new(Mutex::new(ProgressState::default()))); +static NEXT_TASK_ID: AtomicU64 = AtomicU64::new(1); + +pub struct ProgressTask { + id: u64, + finished: bool, +} + +#[derive(Clone)] +pub struct ProgressTaskHandle { + id: u64, +} + +impl ProgressTask { + pub fn start(action: impl Into) -> Self { + let id = NEXT_TASK_ID.fetch_add(1, Ordering::Relaxed); + let action = action.into(); + if let Ok(mut state) = PROGRESS.lock() { + state.tasks.insert( + id, + TaskState { + action: action.clone(), + sub_action: None, + progress: None, + }, + ); + refresh_visible(&mut state); + } + tracing::info!("{}", action); + super::request_redraw(); + Self { + id, + finished: false, + } + } + + pub fn handle(&self) -> ProgressTaskHandle { + ProgressTaskHandle { id: self.id } + } + + pub fn set_action(&self, text: impl Into) { + update_task_action(self.id, text.into()); + } + + pub fn set_sub_action(&self, text: impl Into) { + update_task_sub_action(self.id, text.into()); + } + + pub fn set_progress(&self, current: u64, total: u64) { + update_task_progress(self.id, current, total); + } + + pub fn finish(mut self) { + self.remove(); + self.finished = true; + } + + pub fn fail(mut self, error: impl std::fmt::Display) { + tracing::warn!("Progress task failed: {error}"); + self.remove(); + self.finished = true; + } + + fn remove(&self) { + if let Ok(mut state) = PROGRESS.lock() { + state.tasks.remove(&self.id); + refresh_visible(&mut state); + } + super::request_redraw(); + } +} + +impl ProgressTaskHandle { + pub fn set_sub_action(&self, text: impl Into) { + update_task_sub_action(self.id, text.into()); + } + + pub fn set_progress(&self, current: u64, total: u64) { + update_task_progress(self.id, current, total); + } +} + +fn update_task_action(id: u64, text: String) { + if let Ok(mut state) = PROGRESS.lock() { + if let Some(task) = state.tasks.get_mut(&id) { + task.action = text; + task.progress = None; + } + refresh_visible(&mut state); + } + super::request_redraw(); +} + +fn update_task_sub_action(id: u64, text: String) { + if let Ok(mut state) = PROGRESS.lock() { + if let Some(task) = state.tasks.get_mut(&id) { + task.sub_action = Some(text); + } + refresh_visible(&mut state); + } + super::request_redraw(); +} + +fn update_task_progress(id: u64, current: u64, total: u64) { + if let Ok(mut state) = PROGRESS.lock() { + if let Some(task) = state.tasks.get_mut(&id) { + task.progress = Some((current, total)); + } + refresh_visible(&mut state); + } + super::request_redraw(); +} + +impl Drop for ProgressTask { + fn drop(&mut self) { + if !self.finished { + self.remove(); + } + } +} + +fn refresh_visible(state: &mut ProgressState) { + if let Some((_, task)) = state.tasks.last_key_value() { + state.current_action = Some(task.action.clone()); + state.sub_action.clone_from(&task.sub_action); + state.progress = task.progress; + } else if !state.legacy_active { + state.current_action = None; + state.sub_action = None; + state.progress = None; + } +} + +pub fn set_action(text: impl Into) { + let text = text.into(); + match PROGRESS.lock() { + Ok(mut state) => { + state.legacy_active = true; + state.current_action = Some(text.clone()); + state.progress = None; + super::request_redraw(); + } + Err(e) => { + tracing::error!("Progress lock poisoned: {}", e); + } + } + tracing::info!("{}", text); +} + +pub fn set_progress(current: u64, total: u64) { + match PROGRESS.lock() { + Ok(mut state) => { + state.progress = Some((current, total)); + super::request_redraw(); + } + Err(e) => { + tracing::error!("Progress lock poisoned: {}", e); + } + } +} + +pub fn set_sub_action(text: impl Into) { + let text = text.into(); + match PROGRESS.lock() { + Ok(mut state) => { + state.sub_action = Some(text.clone()); + super::request_redraw(); + } + Err(e) => { + tracing::error!("Progress lock poisoned: {}", e); + } + } + tracing::debug!(" {}", text); +} + +pub fn clear() { + match PROGRESS.lock() { + Ok(mut state) => { + state.legacy_active = false; + refresh_visible(&mut state); + super::request_redraw(); + } + Err(e) => { + tracing::error!("Progress lock poisoned: {}", e); + } + } +} + +pub fn is_active() -> bool { + PROGRESS + .lock() + .is_ok_and(|state| state.current_action.is_some()) +} + +#[cfg(test)] +#[path = "tests/progress.rs"] +mod tests; diff --git a/src/feedback/tests/errors.rs b/src/feedback/tests/errors.rs new file mode 100644 index 0000000..d687e64 --- /dev/null +++ b/src/feedback/tests/errors.rs @@ -0,0 +1,85 @@ +use super::*; +use crate::tests::TEST_LOCK; + +fn clear_errors_for_test() { + ERROR_EVENTS.lock().unwrap().clear(); +} + +fn make_event(msg: &str) -> ErrorEvent { + ErrorEvent { + id: 0, + level: Level::ERROR, + message: msg.to_string(), + pushed_at: Instant::now(), + } +} + +#[test] +fn peek_does_not_remove() { + let _guard = TEST_LOCK.lock().unwrap(); + clear_errors_for_test(); + + push_error(make_event("peek-test")); + + let count_before = peek_all_errors().len(); + let peeked = peek_error(); + let count_after = peek_all_errors().len(); + + assert_eq!( + count_before, count_after, + "peek should not change queue length" + ); + + assert!(peeked.is_some()); + assert_eq!(peeked.unwrap().message, "peek-test"); +} + +#[test] +fn peek_all_returns_newest_first() { + let _guard = TEST_LOCK.lock().unwrap(); + clear_errors_for_test(); + + push_error(make_event("newest_a")); + push_error(make_event("newest_b")); + + let all = peek_all_errors(); + + assert_eq!(all.len(), 2); + assert!(all[0].message.ends_with("_b")); + assert!(all[1].message.ends_with("_a")); + assert!(all[0].id > all[1].id); +} + +#[test] +fn auto_assigned_ids_are_unique() { + let _guard = TEST_LOCK.lock().unwrap(); + clear_errors_for_test(); + + push_error(make_event("unique_1")); + push_error(make_event("unique_2")); + + let all = peek_all_errors(); + + assert_eq!(all.len(), 2); + assert_ne!(all[0].id, all[1].id); +} + +#[test] +fn overflow_drops_oldest() { + let _guard = TEST_LOCK.lock().unwrap(); + clear_errors_for_test(); + + for i in 0..(MAX_ERROR_EVENTS + 10) { + push_error(make_event(&format!("overflow_{i}"))); + } + + let all = peek_all_errors(); + + assert_eq!(all.len(), MAX_ERROR_EVENTS); + + assert!(!all.iter().any(|e| e.message == "overflow_0")); + assert!(!all.iter().any(|e| e.message == "overflow_9")); + + assert!(all.iter().any(|e| e.message == "overflow_10")); + assert!(all.iter().any(|e| e.message == "overflow_59")); +} diff --git a/src/feedback/tests/progress.rs b/src/feedback/tests/progress.rs new file mode 100644 index 0000000..ecdcf62 --- /dev/null +++ b/src/feedback/tests/progress.rs @@ -0,0 +1,73 @@ +use super::*; +use crate::tests::TEST_LOCK; + +#[test] +fn newest_task_is_visible_and_drop_restores_previous_task() { + let _guard = TEST_LOCK.lock().unwrap(); + clear(); + let first = ProgressTask::start("first"); + first.set_progress(1, 2); + { + let second = ProgressTask::start("second"); + second.set_sub_action("working"); + let state = PROGRESS.lock().unwrap(); + assert_eq!(state.current_action.as_deref(), Some("second")); + assert_eq!(state.sub_action.as_deref(), Some("working")); + } + let state = PROGRESS.lock().unwrap(); + assert_eq!(state.current_action.as_deref(), Some("first")); + assert_eq!(state.progress, Some((1, 2))); + drop(state); + first.finish(); + clear(); +} + +#[test] +fn changing_phase_resets_progress_to_indeterminate() { + let _guard = TEST_LOCK.lock().unwrap(); + clear(); + let task = ProgressTask::start("inventory"); + task.set_progress(4, 10); + task.set_action("provider matching"); + + let state = PROGRESS.lock().unwrap(); + assert_eq!(state.current_action.as_deref(), Some("provider matching")); + assert_eq!(state.progress, None); + drop(state); + + task.finish(); + assert!(!is_active()); + clear(); +} + +#[test] +fn clearing_legacy_progress_keeps_owned_tasks() { + let _guard = TEST_LOCK.lock().unwrap(); + clear(); + let task = ProgressTask::start("indexing"); + set_action("legacy download"); + clear(); + + let state = PROGRESS.lock().unwrap(); + assert_eq!(state.current_action.as_deref(), Some("indexing")); + drop(state); + + task.finish(); + clear(); +} + +#[test] +fn a_new_legacy_action_does_not_reuse_the_previous_phase_progress() { + let _guard = TEST_LOCK.lock().unwrap(); + clear(); + set_action("Downloading installer"); + set_progress(1, 1); + + set_action("Running installer"); + + let state = PROGRESS.lock().unwrap(); + assert_eq!(state.current_action.as_deref(), Some("Running installer")); + assert_eq!(state.progress, None); + drop(state); + clear(); +} diff --git a/src/instance/config_sync.rs b/src/instance/config_sync.rs index 3a76ccb..51a44f1 100644 --- a/src/instance/config_sync.rs +++ b/src/instance/config_sync.rs @@ -110,7 +110,7 @@ pub fn switch_profile( meta_dir: &Path, instance_dir: &Path, ) -> Result, ConfigSyncError> { - if crate::running::get(instance_name).is_some() { + if crate::instance::runtime::get(instance_name).is_some() { return Err(ConfigSyncError::InstanceRunning { instance: instance_name.to_string(), }); @@ -203,18 +203,15 @@ fn profile_dir(meta_dir: &Path, profile: &str) -> PathBuf { } fn profiles_dir(meta_dir: &Path) -> PathBuf { - meta_dir.join("config-sync").join("profiles") + crate::storage::MetadataPaths::new(meta_dir).profiles() } fn minecraft_dir(instance_dir: &Path) -> PathBuf { - instance_dir.join(".minecraft") + instance_dir.join(crate::storage::MINECRAFT_DIR_NAME) } fn local_backup_dir(instance_dir: &Path) -> PathBuf { - instance_dir - .join(".rmcl") - .join("config-sync") - .join("local-config") + crate::storage::InstancePaths::new(instance_dir).local_config() } fn acquire_lock(profile_dir: &Path) -> Result { @@ -224,6 +221,7 @@ fn acquire_lock(profile_dir: &Path) -> Result { .read(true) .write(true) .create(true) + .truncate(false) .open(path)?; file.lock()?; Ok(ConfigSyncLock { file }) @@ -332,338 +330,5 @@ fn copy_dir_contents(src: &Path, dst: &Path) -> Result<(), ConfigSyncError> { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn first_prepare_seeds_shared_config() { - let tmp = tempfile::tempdir().unwrap(); - let meta = tmp.path().join("meta"); - let minecraft = tmp.path().join("instance/.minecraft"); - create_profile(&meta, "main").unwrap(); - std::fs::create_dir_all(minecraft.join("config/nested")).unwrap(); - std::fs::write(minecraft.join("options.txt"), "local-options").unwrap(); - std::fs::write(minecraft.join("optionsshaders.txt"), "shader-options").unwrap(); - std::fs::write(minecraft.join("config/options.txt"), "local-config").unwrap(); - std::fs::write(minecraft.join("config/nested/mod.toml"), "nested").unwrap(); - - assert!(prepare(Some("main"), &meta, &minecraft).unwrap()); - - assert_eq!( - std::fs::read_to_string(meta.join("config-sync/profiles/main/options.txt")).unwrap(), - "local-options" - ); - assert_eq!( - std::fs::read_to_string(meta.join("config-sync/profiles/main/optionsshaders.txt")) - .unwrap(), - "shader-options" - ); - assert_eq!( - std::fs::read_to_string(meta.join("config-sync/profiles/main/config/options.txt")) - .unwrap(), - "local-config" - ); - assert_eq!( - std::fs::read_to_string(meta.join("config-sync/profiles/main/config/nested/mod.toml")) - .unwrap(), - "nested" - ); - } - - #[test] - fn prepare_mirrors_shared_config_into_instance() { - let tmp = tempfile::tempdir().unwrap(); - let meta = tmp.path().join("meta"); - let minecraft = tmp.path().join("instance/.minecraft"); - std::fs::create_dir_all(meta.join("config-sync/profiles/main/config")).unwrap(); - std::fs::create_dir_all(minecraft.join("config")).unwrap(); - std::fs::write( - meta.join("config-sync/profiles/main/options.txt"), - "shared-options", - ) - .unwrap(); - std::fs::write( - meta.join("config-sync/profiles/main/config/shared.toml"), - "shared", - ) - .unwrap(); - std::fs::write(minecraft.join("options.txt"), "stale-options").unwrap(); - std::fs::write(minecraft.join("config/local.toml"), "stale").unwrap(); - - assert!(prepare(Some("main"), &meta, &minecraft).unwrap()); - - assert_eq!( - std::fs::read_to_string(minecraft.join("options.txt")).unwrap(), - "shared-options" - ); - assert_eq!( - std::fs::read_to_string(minecraft.join("config/shared.toml")).unwrap(), - "shared" - ); - assert!(!minecraft.join("config/local.toml").exists()); - } - - #[test] - fn finish_mirrors_instance_config_back_to_shared() { - let tmp = tempfile::tempdir().unwrap(); - let meta = tmp.path().join("meta"); - let minecraft = tmp.path().join("instance/.minecraft"); - std::fs::create_dir_all(meta.join("config-sync/profiles/main/config")).unwrap(); - std::fs::write( - meta.join("config-sync/profiles/main/config/old.toml"), - "old", - ) - .unwrap(); - std::fs::create_dir_all(minecraft.join("config")).unwrap(); - std::fs::write(minecraft.join("options.txt"), "new-options").unwrap(); - std::fs::write(minecraft.join("config/new.toml"), "new").unwrap(); - - finish(Some("main"), &meta, &minecraft).unwrap(); - - assert_eq!( - std::fs::read_to_string(meta.join("config-sync/profiles/main/options.txt")).unwrap(), - "new-options" - ); - assert_eq!( - std::fs::read_to_string(meta.join("config-sync/profiles/main/config/new.toml")) - .unwrap(), - "new" - ); - assert!( - !meta - .join("config-sync/profiles/main/config/old.toml") - .exists() - ); - } - - #[test] - fn prepare_releases_lock_for_another_instance() { - let tmp = tempfile::tempdir().unwrap(); - let meta = tmp.path().join("meta"); - let minecraft = tmp.path().join("instance/.minecraft"); - create_profile(&meta, "main").unwrap(); - std::fs::create_dir_all(minecraft.join("config")).unwrap(); - - assert!(prepare(Some("main"), &meta, &minecraft).unwrap()); - let second = tmp.path().join("second/.minecraft"); - std::fs::create_dir_all(second.join("config")).unwrap(); - - assert!(prepare(Some("main"), &meta, &second).unwrap()); - } - - #[test] - fn profile_rejects_path_traversal() { - let tmp = tempfile::tempdir().unwrap(); - let err = prepare(Some("../bad"), tmp.path(), tmp.path()).unwrap_err(); - - assert!(matches!(err, ConfigSyncError::InvalidProfile(_))); - } - - #[test] - fn profile_rejects_builtin_names() { - for profile in [ - "none", - "default", - "local", - "instance default", - "local default", - ] { - let err = validate_profile(profile).unwrap_err(); - assert!(matches!(err, ConfigSyncError::InvalidProfile(_))); - } - } - - #[test] - fn prepare_ignores_deleted_profile() { - let tmp = tempfile::tempdir().unwrap(); - let meta = tmp.path().join("meta"); - let minecraft = tmp.path().join("instance/.minecraft"); - std::fs::create_dir_all(minecraft.join("config")).unwrap(); - - let lock = prepare(Some("deleted"), &meta, &minecraft).unwrap(); - - assert!(!lock); - assert!(!meta.join("config-sync/profiles/deleted").exists()); - } - - #[test] - fn create_profile_trims_and_lists_profiles() { - let tmp = tempfile::tempdir().unwrap(); - - let profile = create_profile(tmp.path(), " main ").unwrap(); - let profiles = list_profiles(tmp.path()).unwrap(); - - assert_eq!(profile, "main"); - assert_eq!(profiles, vec!["main"]); - } - - #[test] - fn delete_profile_removes_profile_dir() { - let tmp = tempfile::tempdir().unwrap(); - create_profile(tmp.path(), "main").unwrap(); - - delete_profile(tmp.path(), "main").unwrap(); - - assert!(list_profiles(tmp.path()).unwrap().is_empty()); - } - - #[test] - fn switch_to_profile_backs_up_local_config_and_restores_none() { - let tmp = tempfile::tempdir().unwrap(); - let meta = tmp.path().join("meta"); - let instance = tmp.path().join("instance"); - let minecraft = instance.join(".minecraft"); - std::fs::create_dir_all(minecraft.join("config")).unwrap(); - std::fs::write(minecraft.join("options.txt"), "local-options").unwrap(); - std::fs::write(minecraft.join("config/local.txt"), "local").unwrap(); - - let selected = switch_profile("inst", None, Some("main"), &meta, &instance).unwrap(); - assert_eq!(selected.as_deref(), Some("main")); - assert_eq!( - std::fs::read_to_string(instance.join(".rmcl/config-sync/local-config/options.txt")) - .unwrap(), - "local-options" - ); - assert_eq!( - std::fs::read_to_string( - instance.join(".rmcl/config-sync/local-config/config/local.txt") - ) - .unwrap(), - "local" - ); - - std::fs::write(minecraft.join("options.txt"), "shared-options").unwrap(); - std::fs::write(minecraft.join("config/shared.txt"), "shared").unwrap(); - let selected = switch_profile("inst", Some("main"), None, &meta, &instance).unwrap(); - - assert_eq!(selected, None); - assert_eq!( - std::fs::read_to_string(meta.join("config-sync/profiles/main/options.txt")).unwrap(), - "shared-options" - ); - assert_eq!( - std::fs::read_to_string(meta.join("config-sync/profiles/main/config/shared.txt")) - .unwrap(), - "shared" - ); - assert_eq!( - std::fs::read_to_string(minecraft.join("options.txt")).unwrap(), - "local-options" - ); - assert_eq!( - std::fs::read_to_string(minecraft.join("config/local.txt")).unwrap(), - "local" - ); - assert!(!minecraft.join("config/shared.txt").exists()); - } - - #[test] - fn switch_from_deleted_profile_restores_local_without_recreating_profile() { - let tmp = tempfile::tempdir().unwrap(); - let meta = tmp.path().join("meta"); - let instance = tmp.path().join("instance"); - let minecraft = instance.join(".minecraft"); - std::fs::create_dir_all(minecraft.join("config")).unwrap(); - std::fs::create_dir_all(instance.join(".rmcl/config-sync/local-config/config")).unwrap(); - std::fs::write(minecraft.join("options.txt"), "deleted-profile-options").unwrap(); - std::fs::write( - instance.join(".rmcl/config-sync/local-config/options.txt"), - "local-options", - ) - .unwrap(); - std::fs::write( - instance.join(".rmcl/config-sync/local-config/config/local.txt"), - "local", - ) - .unwrap(); - - let selected = switch_profile("inst", Some("deleted"), None, &meta, &instance).unwrap(); - - assert_eq!(selected, None); - assert_eq!( - std::fs::read_to_string(minecraft.join("options.txt")).unwrap(), - "local-options" - ); - assert_eq!( - std::fs::read_to_string(minecraft.join("config/local.txt")).unwrap(), - "local" - ); - assert!(!meta.join("config-sync/profiles/deleted").exists()); - } - - #[test] - fn switch_between_profiles_saves_old_and_loads_new() { - let tmp = tempfile::tempdir().unwrap(); - let meta = tmp.path().join("meta"); - let instance = tmp.path().join("instance"); - let minecraft = instance.join(".minecraft"); - std::fs::create_dir_all(minecraft.join("config")).unwrap(); - std::fs::write(minecraft.join("options.txt"), "changed-a-options").unwrap(); - std::fs::write(minecraft.join("config/a.txt"), "changed-a").unwrap(); - create_profile(&meta, "a").unwrap(); - std::fs::create_dir_all(meta.join("config-sync/profiles/b/config")).unwrap(); - std::fs::write( - meta.join("config-sync/profiles/b/options.txt"), - "profile-b-options", - ) - .unwrap(); - std::fs::write( - meta.join("config-sync/profiles/b/config/b.txt"), - "profile-b", - ) - .unwrap(); - - let selected = switch_profile("inst", Some("a"), Some("b"), &meta, &instance).unwrap(); - - assert_eq!(selected.as_deref(), Some("b")); - assert_eq!( - std::fs::read_to_string(meta.join("config-sync/profiles/a/options.txt")).unwrap(), - "changed-a-options" - ); - assert_eq!( - std::fs::read_to_string(meta.join("config-sync/profiles/a/config/a.txt")).unwrap(), - "changed-a" - ); - assert_eq!( - std::fs::read_to_string(minecraft.join("options.txt")).unwrap(), - "profile-b-options" - ); - assert_eq!( - std::fs::read_to_string(minecraft.join("config/b.txt")).unwrap(), - "profile-b" - ); - assert!(!minecraft.join("config/a.txt").exists()); - } - - #[test] - fn second_instance_uses_profile_options_saved_by_first_instance() { - let tmp = tempfile::tempdir().unwrap(); - let meta = tmp.path().join("meta"); - let first = tmp.path().join("first/.minecraft"); - let second_instance = tmp.path().join("second"); - let second = second_instance.join(".minecraft"); - std::fs::create_dir_all(first.join("config")).unwrap(); - std::fs::create_dir_all(second.join("config")).unwrap(); - std::fs::write(first.join("options.txt"), "first-default").unwrap(); - std::fs::write(second.join("options.txt"), "second-local").unwrap(); - create_profile(&meta, "main").unwrap(); - - assert!(prepare(Some("main"), &meta, &first).unwrap()); - std::fs::write(first.join("options.txt"), "changed-in-main").unwrap(); - finish(Some("main"), &meta, &first).unwrap(); - - switch_profile("second", None, Some("main"), &meta, &second_instance).unwrap(); - - assert_eq!( - std::fs::read_to_string(second.join("options.txt")).unwrap(), - "changed-in-main" - ); - assert_eq!( - std::fs::read_to_string( - second_instance.join(".rmcl/config-sync/local-config/options.txt") - ) - .unwrap(), - "second-local" - ); - } -} +#[path = "tests/config_sync.rs"] +mod tests; diff --git a/src/instance/content/datapacks.rs b/src/instance/content/datapacks.rs new file mode 100644 index 0000000..5c27b59 --- /dev/null +++ b/src/instance/content/datapacks.rs @@ -0,0 +1,9 @@ +// data pack scanning backed by the same pack.mcmeta reader as resource packs. + +use std::path::Path; + +use super::entry::ContentEntry; + +pub fn scan_one_datapack(path: &Path, file_stem: &str, enabled: bool) -> ContentEntry { + super::packs::scan_one_pack(path, file_stem, enabled) +} diff --git a/src/instance/content/dependencies.rs b/src/instance/content/dependencies.rs new file mode 100644 index 0000000..bd9660a --- /dev/null +++ b/src/instance/content/dependencies.rs @@ -0,0 +1,1156 @@ +use std::collections::{HashMap, HashSet, VecDeque}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use crate::instance::content::provider::{ContentProvider, FingerprintQuery, ProviderRegistry}; +use crate::instance::{ + ContentFileRecord, ContentKind, ContentManifest, InstanceConfig, ProviderProject, +}; +use crate::net::NetError; +use crate::net::modrinth::{ + DependencyType, ProjectInfo, VersionDependency, VersionInfo, VersionType, +}; + +static NEXT_INSTALL_ID: AtomicU64 = AtomicU64::new(1); + +#[derive(Debug, Clone)] +pub struct InstallRoot { + pub provider: String, + pub project_id: String, + pub title: String, + pub version: VersionInfo, + pub installed_path: Option, + pub kind: ContentKind, + pub target_world: Option, + pub force_reinstall: bool, +} + +#[derive(Debug, Clone)] +pub struct PlannedInstall { + pub provider: String, + pub project_id: String, + pub title: String, + pub version: VersionInfo, + pub installed_path: Option, + pub kind: ContentKind, + pub destination: PathBuf, + pub provider_aliases: Vec, + pub required_dependencies: Vec, + pub automatic_dependency: bool, + pub cleanup_eligible: bool, + pub replacement: bool, +} + +impl PlannedInstall { + pub fn needs_download(&self) -> bool { + self.installed_path.is_none() || self.replacement + } + + pub fn identity(&self) -> ProviderProject { + ProviderProject { + provider: self.provider.clone(), + project_id: self.project_id.clone(), + version_id: self.version.id.clone(), + } + } +} + +#[derive(Debug, Clone)] +pub struct DependencyPlan { + pub items: Vec, + pub root_count: usize, + pub optional_dependencies: usize, +} + +pub struct InstallResult { + pub root_path: PathBuf, + pub replaced: bool, + pub skipped: bool, + pub orphaned_dependencies: Vec, +} + +impl DependencyPlan { + pub fn dependency_installs(&self) -> impl Iterator { + self.items + .iter() + .skip(self.root_count) + .filter(|item| item.installed_path.is_none()) + } + + pub fn dependency_replacements(&self) -> impl Iterator { + self.items + .iter() + .skip(self.root_count) + .filter(|item| item.replacement) + } +} + +pub async fn install( + registry: &ProviderRegistry, + manifest_path: &Path, + minecraft_dir: &Path, + plan: &DependencyPlan, +) -> Result { + let root = plan + .items + .first() + .ok_or_else(|| NetError::Parse("Dependency plan is empty".to_owned()))?; + for item in &plan.items { + tokio::fs::create_dir_all(&item.destination).await?; + } + let staging = staging_directory(minecraft_dir); + tokio::fs::create_dir(&staging).await?; + + let result = install_staged(registry, manifest_path, minecraft_dir, &staging, plan).await; + if let Err(error) = tokio::fs::remove_dir_all(&staging).await + && error.kind() != std::io::ErrorKind::NotFound + { + tracing::warn!( + "Failed to remove dependency install staging directory '{}': {error}", + staging.display() + ); + } + result.map(|(root_path, orphaned_dependencies)| InstallResult { + root_path, + replaced: root.replacement, + skipped: !plan.items.iter().any(PlannedInstall::needs_download), + orphaned_dependencies, + }) +} + +fn staging_directory(minecraft_dir: &Path) -> PathBuf { + minecraft_dir.join(format!( + ".rmcl-install-{}", + NEXT_INSTALL_ID.fetch_add(1, Ordering::Relaxed) + )) +} + +struct StagedFile { + item: usize, + source: PathBuf, + target: PathBuf, +} + +struct CommittedFile { + target: PathBuf, + old_path: Option, + backup: Option, +} + +async fn install_staged( + registry: &ProviderRegistry, + manifest_path: &Path, + minecraft_dir: &Path, + staging: &Path, + plan: &DependencyPlan, +) -> Result<(PathBuf, Vec), NetError> { + let mut staged = Vec::new(); + let mut targets = HashSet::new(); + for (index, item) in plan.items.iter().enumerate() { + if !item.needs_download() { + continue; + } + let provider = registry.get(&item.provider).ok_or_else(|| { + NetError::Parse(format!("{} content provider is unavailable", item.provider)) + })?; + let item_staging = staging.join(format!("item-{index}")); + tokio::fs::create_dir(&item_staging).await?; + let outcome = provider + .download_version(&item.version, &item_staging, None) + .await?; + let source = match outcome { + crate::net::modrinth::DownloadOutcome::Downloaded(path) + | crate::net::modrinth::DownloadOutcome::SkippedExisting(path) => path, + }; + let file_name = source.file_name().ok_or_else(|| { + NetError::Parse(format!( + "Downloaded dependency '{}' has no filename", + item.title + )) + })?; + let target = item.destination.join(file_name); + if !targets.insert(target.clone()) { + return Err(NetError::Parse(format!( + "Multiple selected projects install '{}'", + target.display() + ))); + } + if target.exists() + && item + .installed_path + .as_ref() + .is_none_or(|installed| installed != &target) + { + return Err(NetError::Parse(format!( + "Cannot install '{}' because '{}' already exists", + item.title, + target.display() + ))); + } + staged.push(StagedFile { + item: index, + source, + target, + }); + } + + let mut committed = Vec::new(); + for (index, file) in staged.iter().enumerate() { + let item = &plan.items[file.item]; + let old_path = item.installed_path.clone(); + let backup = if let Some(old_path) = old_path.as_ref() { + let backup = staging.join(format!("backup-{index}")); + if let Err(error) = tokio::fs::rename(old_path, &backup).await { + rollback_files(&committed).await; + return Err(error.into()); + } + Some(backup) + } else { + None + }; + if let Err(error) = tokio::fs::rename(&file.source, &file.target).await { + if let (Some(old_path), Some(backup)) = (&old_path, &backup) { + let _ = tokio::fs::rename(backup, old_path).await; + } + rollback_files(&committed).await; + return Err(error.into()); + } + committed.push(CommittedFile { + target: file.target.clone(), + old_path, + backup, + }); + } + + let previous = + ContentManifest::load(manifest_path).map_err(|error| NetError::Parse(error.to_string()))?; + let records = match build_records(plan, &previous, minecraft_dir, &staged) { + Ok(records) => records, + Err(error) => { + rollback_files(&committed).await; + return Err(error); + } + }; + if let Err(error) = ContentManifest::update(manifest_path, |manifest| { + for (old_relative, record) in &records { + if let Some(old_relative) = old_relative + && old_relative != &record.relative_path + { + manifest.remove(old_relative); + } + manifest.upsert(record.clone()); + } + Ok(()) + }) { + rollback_files(&committed).await; + return Err(NetError::Parse(error.to_string())); + } + for committed in &committed { + if let Some(backup) = &committed.backup + && let Err(error) = tokio::fs::remove_file(backup).await + { + tracing::warn!( + "Failed to remove dependency install backup '{}': {error}", + backup.display() + ); + } + } + + let orphaned_dependencies = match ContentManifest::load(manifest_path) { + Ok(updated) => updated + .orphaned_dependencies() + .into_iter() + .map(|relative| minecraft_dir.join(relative)) + .collect(), + Err(error) => { + tracing::warn!("Failed to check for unused dependencies: {error}"); + Vec::new() + } + }; + let root = &plan.items[0]; + let root_path = if root.needs_download() { + staged + .iter() + .find(|file| file.item == 0) + .map(|file| file.target.clone()) + .ok_or_else(|| NetError::Parse("Installed root file is missing".to_owned())) + } else { + root.installed_path + .clone() + .ok_or_else(|| NetError::Parse("Installed root path is missing".to_owned())) + }?; + Ok((root_path, orphaned_dependencies)) +} + +fn build_records( + plan: &DependencyPlan, + previous: &ContentManifest, + minecraft_dir: &Path, + staged: &[StagedFile], +) -> Result, ContentFileRecord)>, NetError> { + plan.items + .iter() + .enumerate() + .map(|(index, item)| { + let old_relative = item + .installed_path + .as_ref() + .and_then(|path| path.strip_prefix(minecraft_dir).ok()) + .map(Path::to_owned); + if !item.needs_download() { + let old_relative = old_relative.ok_or_else(|| { + NetError::Parse(format!("Installed path for '{}' is invalid", item.title)) + })?; + let mut record = previous.record(&old_relative).cloned().ok_or_else(|| { + NetError::Parse(format!("Manifest record for '{}' is missing", item.title)) + })?; + let identity = item.identity(); + if !record.matches_project(&identity.provider, &identity.project_id) + && !record.provider_aliases.contains(&identity) + { + record.provider_aliases.push(identity); + } + record.required_dependencies = item.required_dependencies.clone(); + record.automatic_dependency = item.automatic_dependency; + record.cleanup_eligible = item.cleanup_eligible; + return Ok((Some(old_relative), record)); + } + let path = staged + .iter() + .find(|file| file.item == index) + .map(|file| file.target.clone()) + .ok_or_else(|| { + NetError::Parse(format!("Staged file for '{}' is missing", item.title)) + })?; + let relative_path = path + .strip_prefix(minecraft_dir) + .map_err(|error| NetError::Parse(error.to_string()))? + .to_owned(); + Ok(( + old_relative, + ContentFileRecord { + relative_path, + kind: item.kind, + enabled: true, + fingerprint: crate::instance::content::manifest::fingerprint(&path)?, + resolution: crate::instance::Resolution::Resolved { + project: item.identity(), + }, + provider_aliases: item.provider_aliases.clone(), + provider_checks: vec![item.provider.clone()], + required_dependencies: item.required_dependencies.clone(), + automatic_dependency: item.automatic_dependency, + cleanup_eligible: item.cleanup_eligible, + }, + )) + }) + .collect() +} + +async fn rollback_files(committed: &[CommittedFile]) { + for committed in committed.iter().rev() { + if let Err(error) = tokio::fs::remove_file(&committed.target).await + && error.kind() != std::io::ErrorKind::NotFound + { + tracing::warn!( + "Failed to remove rolled back content '{}': {error}", + committed.target.display() + ); + } + if let (Some(old_path), Some(backup)) = (&committed.old_path, &committed.backup) + && let Err(error) = tokio::fs::rename(backup, old_path).await + { + tracing::warn!( + "Failed to restore rolled back content '{}': {error}", + old_path.display() + ); + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct ProjectKey { + provider: String, + project_id: String, +} + +impl ProjectKey { + fn new(provider: &str, project_id: impl Into) -> Self { + Self { + provider: provider.to_owned(), + project_id: project_id.into(), + } + } +} + +#[derive(Debug, Clone)] +struct Node { + title: String, + kind: ContentKind, + version: VersionInfo, + installed: Option, + automatic_dependency: bool, + cleanup_eligible: bool, + exact: bool, + dependencies: Vec, + optional_dependencies: usize, + incompatible: Vec, +} + +#[derive(Debug, Clone)] +struct InstalledMatch { + relative_path: PathBuf, + identity: ProviderProject, + aliases: Vec, + automatic_dependency: bool, +} + +#[derive(Debug, Clone)] +struct Incompatible { + project_id: String, + version_id: Option, +} + +#[derive(Debug, Clone)] +struct Requirement { + parent: ProjectKey, + parent_version_id: String, + dependency: VersionDependency, + ancestors: Vec, +} + +pub async fn resolve( + registry: &ProviderRegistry, + manifest: &ContentManifest, + minecraft_dir: &Path, + instance: &InstanceConfig, + root: InstallRoot, +) -> Result { + if root.kind == ContentKind::DataPack && root.target_world.is_none() { + return Err(NetError::Parse( + "A target world is required for datapack installation".to_owned(), + )); + } + let provider = registry.get(&root.provider).ok_or_else(|| { + NetError::Parse(format!("{} content provider is unavailable", root.provider)) + })?; + let root_version = provider.version(&root.version.id).await?; + validate_compatible(&root_version, instance, root.kind)?; + if !root_version.project_id.is_empty() && root_version.project_id != root.project_id { + return Err(NetError::Parse(format!( + "Selected version belongs to project '{}', not '{}'", + root_version.project_id, root.project_id + ))); + } + let remote_matches = resolve_installed_files(provider, manifest).await?; + let root_key = ProjectKey::new(&root.provider, root.project_id.clone()); + let root_installed = installed_root(manifest, minecraft_dir, &root, &remote_matches); + let mut nodes = HashMap::from([( + root_key.clone(), + Node { + title: root.title, + kind: root.kind, + version: root_version.clone(), + automatic_dependency: false, + cleanup_eligible: false, + exact: true, + installed: root_installed, + dependencies: Vec::new(), + optional_dependencies: 0, + incompatible: Vec::new(), + }, + )]); + let mut queue = VecDeque::new(); + expand_node( + provider, + &root_key, + &root_version, + vec![root_key.clone()], + &mut nodes, + &mut queue, + ) + .await?; + + while let Some(requirement) = queue.pop_front() { + if nodes + .get(&requirement.parent) + .is_none_or(|parent| parent.version.id != requirement.parent_version_id) + { + continue; + } + let exact = requirement.dependency.version_id.is_some(); + let parent_kind = nodes + .get(&requirement.parent) + .map(|node| node.kind) + .unwrap_or(root.kind); + let choice = resolve_requirement( + provider, + manifest, + &remote_matches, + instance, + parent_kind, + root.target_world.as_deref(), + &requirement.dependency, + ) + .await?; + if choice.kind == ContentKind::DataPack && root.target_world.is_none() { + return Err(NetError::Parse(format!( + "Datapack dependency '{}' requires a target world", + choice.title + ))); + } + let key = ProjectKey::new(provider.id(), choice.version.project_id.clone()); + if requirement.ancestors.contains(&key) { + return Err(NetError::Parse(format!( + "Dependency cycle detected at '{}'", + choice.title + ))); + } + if let Some(parent) = nodes.get_mut(&requirement.parent) + && !parent.dependencies.contains(&key) + { + parent.dependencies.push(key.clone()); + } + + let replace_selection = match nodes.get(&key) { + Some(existing) + if exact && existing.exact && existing.version.id != choice.version.id => + { + return Err(NetError::Parse(format!( + "Conflicting required versions for '{}': '{}' and '{}'", + choice.title, existing.version.version_number, choice.version.version_number + ))); + } + Some(existing) => exact && !existing.exact && existing.version.id != choice.version.id, + None => true, + }; + if !replace_selection { + continue; + } + + let mut ancestors = requirement.ancestors; + ancestors.push(key.clone()); + nodes.insert( + key.clone(), + Node { + title: choice.title, + kind: choice.kind, + version: choice.version.clone(), + installed: choice.installed, + automatic_dependency: choice.automatic_dependency, + cleanup_eligible: choice.cleanup_eligible, + exact, + dependencies: Vec::new(), + optional_dependencies: 0, + incompatible: Vec::new(), + }, + ); + expand_node( + provider, + &key, + &choice.version, + ancestors, + &mut nodes, + &mut queue, + ) + .await?; + } + + let order = reachable_order(&root_key, &nodes); + reject_installed_incompatibilities( + provider, + manifest, + &remote_matches, + &order, + &nodes, + root.target_world.as_deref(), + )?; + let optional_dependencies = order + .iter() + .filter_map(|key| nodes.get(key)) + .map(|node| node.optional_dependencies) + .sum(); + let items = order + .iter() + .filter_map(|key| { + nodes.get(key).map(|node| { + planned_install( + key, + node, + &nodes, + minecraft_dir, + root.target_world.as_deref(), + root.force_reinstall && key == &root_key, + ) + }) + }) + .collect(); + Ok(DependencyPlan { + items, + root_count: 1, + optional_dependencies, + }) +} + +pub fn merge(plans: Vec) -> Result { + let optional_dependencies = plans.iter().map(|plan| plan.optional_dependencies).sum(); + let mut merged: Vec<(PlannedInstall, bool)> = Vec::new(); + for plan in plans { + for (index, item) in plan.items.into_iter().enumerate() { + let root = index < plan.root_count; + if let Some((existing, existing_root)) = merged.iter_mut().find(|(existing, _)| { + existing.provider == item.provider + && existing.project_id == item.project_id + && existing.destination == item.destination + }) { + if existing.version.id != item.version.id { + return Err(NetError::Parse(format!( + "Conflicting selected versions for '{}': '{}' and '{}'", + item.title, existing.version.version_number, item.version.version_number + ))); + } + for dependency in item.required_dependencies { + if !existing.required_dependencies.contains(&dependency) { + existing.required_dependencies.push(dependency); + } + } + for alias in item.provider_aliases { + if !existing.provider_aliases.contains(&alias) { + existing.provider_aliases.push(alias); + } + } + if root && !*existing_root { + existing.title = item.title; + if item.installed_path.is_some() { + existing.installed_path = item.installed_path; + } + existing.replacement |= item.replacement; + existing.automatic_dependency = false; + existing.cleanup_eligible = false; + *existing_root = true; + } + continue; + } + merged.push((item, root)); + } + } + merged.sort_by_key(|(_, root)| !*root); + let root_count = merged.iter().take_while(|(_, root)| *root).count(); + Ok(DependencyPlan { + items: merged.into_iter().map(|(item, _)| item).collect(), + root_count, + optional_dependencies, + }) +} + +struct ResolvedChoice { + title: String, + kind: ContentKind, + version: VersionInfo, + installed: Option, + automatic_dependency: bool, + cleanup_eligible: bool, +} + +async fn resolve_requirement( + provider: &dyn ContentProvider, + manifest: &ContentManifest, + remote_matches: &HashMap, + instance: &InstanceConfig, + parent_kind: ContentKind, + target_world: Option<&Path>, + dependency: &VersionDependency, +) -> Result { + let exact_version = match dependency.version_id.as_deref() { + Some(version_id) => Some(provider.version(version_id).await?), + None => None, + }; + let project_id = dependency + .project_id + .clone() + .or_else(|| { + exact_version + .as_ref() + .map(|version| version.project_id.clone()) + }) + .filter(|project_id| !project_id.is_empty()) + .ok_or_else(|| { + NetError::Parse(format!( + "Required dependency '{}' has no provider project", + dependency.file_name.as_deref().unwrap_or("unknown") + )) + })?; + let project = match provider.project(&project_id).await { + Ok(project) => Some(project), + Err(error) => { + tracing::warn!( + "Could not load metadata for dependency '{project_id}'; keeping it out of automatic cleanup: {error}" + ); + None + } + }; + let kind = dependency_kind(project.as_ref(), exact_version.as_ref(), parent_kind); + let installed = find_installed( + manifest, + remote_matches, + provider.id(), + &project_id, + kind, + target_world, + ); + let version = if let Some(version) = exact_version { + version + } else { + let installed_version = match &installed { + Some(installed) => match provider.version(&installed.identity.version_id).await { + Ok(version) => Some(version), + Err(error) => { + tracing::warn!( + "Could not load installed dependency version '{}'; selecting a compatible replacement: {error}", + installed.identity.version_id + ); + None + } + }, + None => None, + }; + match installed_version { + Some(version) if validate_compatible(&version, instance, kind).is_ok() => version, + _ => { + let versions = provider + .compatible_versions(&project_id, kind, &instance.game_version, instance.loader) + .await?; + select_preferred_version(versions).ok_or_else(|| { + NetError::Parse(format!( + "No compatible dependency version found for project '{project_id}'" + )) + })? + } + } + }; + validate_compatible(&version, instance, kind)?; + if !version.project_id.is_empty() && version.project_id != project_id { + return Err(NetError::Parse(format!( + "Dependency version '{}' belongs to project '{}', not '{}'", + version.version_number, version.project_id, project_id + ))); + } + let automatic_dependency = installed + .as_ref() + .is_none_or(|installed| installed.automatic_dependency); + let cleanup_eligible = + automatic_dependency && project.as_ref().is_some_and(ProjectInfo::is_library_only); + Ok(ResolvedChoice { + title: project + .map(|project| project.title) + .unwrap_or_else(|| project_id.clone()), + version, + kind, + automatic_dependency, + cleanup_eligible, + installed, + }) +} + +async fn expand_node( + provider: &dyn ContentProvider, + key: &ProjectKey, + version: &VersionInfo, + ancestors: Vec, + nodes: &mut HashMap, + queue: &mut VecDeque, +) -> Result<(), NetError> { + let mut required = Vec::new(); + let mut incompatible = Vec::new(); + let mut optional_dependencies = 0; + for dependency in &version.dependencies { + match dependency.dependency_type { + DependencyType::Required => required.push(dependency.clone()), + DependencyType::Optional => optional_dependencies += 1, + DependencyType::Incompatible => { + let (project_id, version_id) = dependency_identity(provider, dependency).await?; + incompatible.push(Incompatible { + project_id, + version_id, + }); + } + DependencyType::Embedded | DependencyType::Unknown => {} + } + } + if let Some(node) = nodes.get_mut(key) { + node.optional_dependencies = optional_dependencies; + node.incompatible = incompatible; + } + queue.extend(required.into_iter().map(|dependency| Requirement { + parent: key.clone(), + parent_version_id: version.id.clone(), + dependency, + ancestors: ancestors.clone(), + })); + Ok(()) +} + +async fn dependency_identity( + provider: &dyn ContentProvider, + dependency: &VersionDependency, +) -> Result<(String, Option), NetError> { + if let Some(project_id) = dependency.project_id.clone() { + return Ok((project_id, dependency.version_id.clone())); + } + let version_id = dependency.version_id.as_deref().ok_or_else(|| { + NetError::Parse("Incompatible dependency has no provider project".to_owned()) + })?; + let version = provider.version(version_id).await?; + Ok((version.project_id, Some(version_id.to_owned()))) +} + +fn select_preferred_version(mut versions: Vec) -> Option { + versions.sort_by(|left, right| { + release_rank(left.version_type) + .cmp(&release_rank(right.version_type)) + .then_with(|| right.date_published.cmp(&left.date_published)) + }); + versions.into_iter().next() +} + +fn dependency_kind( + project: Option<&ProjectInfo>, + version: Option<&VersionInfo>, + parent_kind: ContentKind, +) -> ContentKind { + if version.is_some_and(|version| { + version + .loaders + .iter() + .any(|loader| loader.eq_ignore_ascii_case("datapack")) + }) { + return ContentKind::DataPack; + } + if version.is_some_and(|version| { + version.loaders.iter().any(|loader| { + matches!( + loader.to_ascii_lowercase().as_str(), + "fabric" | "forge" | "neoforge" | "quilt" + ) + }) + }) { + return ContentKind::Mod; + } + if project.is_some_and(|project| { + project + .loaders + .iter() + .any(|loader| loader.eq_ignore_ascii_case("datapack")) + }) { + return ContentKind::DataPack; + } + match project.map(|project| project.project_type.as_str()) { + Some("resourcepack") => ContentKind::ResourcePack, + Some("shader") => ContentKind::Shader, + Some("datapack") => ContentKind::DataPack, + Some("mod") => ContentKind::Mod, + _ => parent_kind, + } +} + +fn release_rank(version_type: VersionType) -> u8 { + match version_type { + VersionType::Release => 0, + VersionType::Beta => 1, + VersionType::Alpha => 2, + VersionType::Unknown => 3, + } +} + +fn validate_compatible( + version: &VersionInfo, + instance: &InstanceConfig, + kind: ContentKind, +) -> Result<(), NetError> { + let loader = instance.loader.to_string().to_ascii_lowercase(); + let supports_game = version + .game_versions + .iter() + .any(|game_version| game_version == &instance.game_version); + let supports_loader = kind != ContentKind::Mod + || version + .loaders + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(&loader)); + if !supports_game || !supports_loader { + return Err(NetError::Parse(format!( + "Required dependency '{}' does not support Minecraft {} with {}", + version.version_number, instance.game_version, instance.loader + ))); + } + Ok(()) +} + +async fn resolve_installed_files( + provider: &dyn ContentProvider, + manifest: &ContentManifest, +) -> Result, NetError> { + let files = manifest + .files + .iter() + .filter(|record| record.enabled) + .map(|record| FingerprintQuery { + key: record.relative_path.to_string_lossy().into_owned(), + kind: record.kind, + fingerprint: record.fingerprint.clone(), + }) + .collect::>(); + if files.is_empty() { + return Ok(HashMap::new()); + } + Ok(provider + .resolve_files(&files) + .await? + .into_iter() + .map(|resolved| (PathBuf::from(resolved.key), resolved.project)) + .collect()) +} + +fn installed_root( + manifest: &ContentManifest, + minecraft_dir: &Path, + root: &InstallRoot, + remote_matches: &HashMap, +) -> Option { + let path = root.installed_path.as_ref()?; + let relative_path = path.strip_prefix(minecraft_dir).ok()?.to_owned(); + let record = manifest.record(&relative_path)?; + Some(installed_match( + record, + remote_matches.get(&relative_path), + &root.provider, + &root.project_id, + )) +} + +fn find_installed( + manifest: &ContentManifest, + remote_matches: &HashMap, + provider: &str, + project_id: &str, + kind: ContentKind, + target_world: Option<&Path>, +) -> Option { + manifest + .files + .iter() + .filter(|record| record.enabled && record.kind == kind) + .filter(|record| record_in_target(record, kind, target_world)) + .find_map(|record| { + let remote = remote_matches.get(&record.relative_path); + if !record.matches_project(provider, project_id) + && !remote.is_some_and(|project| { + project.provider == provider && project.project_id == project_id + }) + { + return None; + } + Some(installed_match(record, remote, provider, project_id)) + }) +} + +fn record_in_target( + record: &ContentFileRecord, + kind: ContentKind, + target_world: Option<&Path>, +) -> bool { + if kind != ContentKind::DataPack { + return true; + } + let Some(world_name) = target_world.and_then(Path::file_name) else { + return false; + }; + record + .relative_path + .starts_with(Path::new("saves").join(world_name).join("datapacks")) +} + +fn installed_match( + record: &ContentFileRecord, + remote: Option<&ProviderProject>, + provider: &str, + project_id: &str, +) -> InstalledMatch { + let identity = record + .project_for_provider(provider, project_id) + .or_else(|| { + remote + .filter(|project| project.provider == provider && project.project_id == project_id) + }) + .cloned() + .unwrap_or_else(|| ProviderProject { + provider: provider.to_owned(), + project_id: project_id.to_owned(), + version_id: String::new(), + }); + let mut aliases = record.provider_aliases.clone(); + if let Some(project) = record.resolved_project() + && project != &identity + && !aliases.contains(project) + { + aliases.push(project.clone()); + } + if record.resolved_project() != Some(&identity) && !aliases.contains(&identity) { + aliases.push(identity.clone()); + } + InstalledMatch { + relative_path: record.relative_path.clone(), + identity, + aliases, + automatic_dependency: record.automatic_dependency, + } +} + +fn reachable_order(root: &ProjectKey, nodes: &HashMap) -> Vec { + let mut order = Vec::new(); + let mut pending = VecDeque::from([root.clone()]); + let mut seen = HashSet::new(); + while let Some(key) = pending.pop_front() { + if !seen.insert(key.clone()) { + continue; + } + order.push(key.clone()); + if let Some(node) = nodes.get(&key) { + pending.extend(node.dependencies.iter().cloned()); + } + } + order +} + +fn reject_installed_incompatibilities( + provider: &dyn ContentProvider, + manifest: &ContentManifest, + remote_matches: &HashMap, + order: &[ProjectKey], + nodes: &HashMap, + target_world: Option<&Path>, +) -> Result<(), NetError> { + for key in order { + let Some(node) = nodes.get(key) else { + continue; + }; + for incompatible in &node.incompatible { + let installed = find_installed_incompatible( + manifest, + remote_matches, + provider.id(), + &incompatible.project_id, + target_world, + ); + let selected = order.iter().find_map(|selected_key| { + (selected_key.provider == provider.id() + && selected_key.project_id == incompatible.project_id) + .then(|| nodes.get(selected_key)) + .flatten() + }); + let conflicts = installed.as_ref().is_some_and(|installed| { + incompatible + .version_id + .as_ref() + .is_none_or(|version_id| installed.identity.version_id == *version_id) + }) || selected.is_some_and(|selected| { + incompatible + .version_id + .as_ref() + .is_none_or(|version_id| selected.version.id == *version_id) + }); + if conflicts { + return Err(NetError::Parse(format!( + "'{}' is incompatible with installed project '{}'", + node.title, incompatible.project_id + ))); + } + } + } + Ok(()) +} + +fn find_installed_incompatible( + manifest: &ContentManifest, + remote_matches: &HashMap, + provider: &str, + project_id: &str, + target_world: Option<&Path>, +) -> Option { + manifest + .files + .iter() + .filter(|record| record.enabled) + .filter(|record| { + record.kind != ContentKind::DataPack + || record_in_target(record, ContentKind::DataPack, target_world) + }) + .find_map(|record| { + let remote = remote_matches.get(&record.relative_path); + (record.matches_project(provider, project_id) + || remote.is_some_and(|project| { + project.provider == provider && project.project_id == project_id + })) + .then(|| installed_match(record, remote, provider, project_id)) + }) +} + +fn planned_install( + key: &ProjectKey, + node: &Node, + nodes: &HashMap, + minecraft_dir: &Path, + target_world: Option<&Path>, + force_reinstall: bool, +) -> PlannedInstall { + let installed_path = node + .installed + .as_ref() + .map(|installed| minecraft_dir.join(&installed.relative_path)); + let replacement = force_reinstall + || node.installed.as_ref().is_some_and(|installed| { + installed.identity.version_id.is_empty() + || installed.identity.version_id != node.version.id + }); + PlannedInstall { + provider: key.provider.clone(), + project_id: key.project_id.clone(), + title: node.title.clone(), + version: node.version.clone(), + installed_path, + kind: node.kind, + destination: match node.kind { + ContentKind::DataPack => target_world + .expect("datapack dependency plan requires a world") + .join("datapacks"), + kind => minecraft_dir.join(kind.directory()), + }, + provider_aliases: node.installed.as_ref().map_or_else(Vec::new, |installed| { + if replacement { + Vec::new() + } else { + installed.aliases.clone() + } + }), + required_dependencies: node + .dependencies + .iter() + .filter_map(|dependency| { + nodes + .get(dependency) + .map(|dependency_node| ProviderProject { + provider: dependency.provider.clone(), + project_id: dependency.project_id.clone(), + version_id: dependency_node.version.id.clone(), + }) + }) + .collect(), + automatic_dependency: node.automatic_dependency, + cleanup_eligible: node.cleanup_eligible, + replacement, + } +} + +#[cfg(test)] +#[path = "../tests/content/dependencies.rs"] +mod tests; diff --git a/src/instance/content/entry.rs b/src/instance/content/entry.rs new file mode 100644 index 0000000..3fe074b --- /dev/null +++ b/src/instance/content/entry.rs @@ -0,0 +1,73 @@ +use std::path::PathBuf; + +use super::icons::IconCell; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WorldGameMode { + Survival, + Creative, + Adventure, + Spectator, + Hardcore, +} + +impl WorldGameMode { + pub fn label(self) -> &'static str { + match self { + Self::Survival => "Survival", + Self::Creative => "Creative", + Self::Adventure => "Adventure", + Self::Spectator => "Spectator", + Self::Hardcore => "Hardcore", + } + } +} + +#[derive(Debug, Clone)] +pub struct WorldDetails { + pub game_mode: Option, + pub last_played: Option>, + pub minecraft_version: Option, + pub size: Option, + pub datapacks: Vec, +} + +#[derive(Debug, Clone)] +pub struct ContentEntry { + pub file_stem: String, + pub name: String, + pub source_slug: Option, + pub installed_path: Option, + pub provider_project: Option, + pub world_details: Option, + pub title_suffix: Option, + pub footer_label: Option, + pub footer_change: Option<(String, String)>, + pub description: String, + pub enabled: bool, + pub icon_bytes: Option>, + pub provider_icon: bool, + pub provider_description: bool, + pub path: PathBuf, + pub icon_lines: Option>>, +} + +// enable/disable by renaming the file with/without ".disabled" suffix. +pub fn toggle_entry(entry: &ContentEntry) -> Result<(), std::io::Error> { + toggle_entry_path(entry).map(drop) +} + +pub(crate) fn toggle_entry_path(entry: &ContentEntry) -> Result, std::io::Error> { + let Some(file_name) = entry.path.file_name().and_then(|name| name.to_str()) else { + return Ok(None); + }; + let new_name = if entry.enabled { + format!("{file_name}.disabled") + } else { + file_name.trim_end_matches(".disabled").to_owned() + }; + let mut new_path = entry.path.clone(); + new_path.set_file_name(new_name); + std::fs::rename(&entry.path, &new_path)?; + Ok(Some(new_path)) +} diff --git a/src/instance/content/icons.rs b/src/instance/content/icons.rs new file mode 100644 index 0000000..c0a2d6d --- /dev/null +++ b/src/instance/content/icons.rs @@ -0,0 +1,248 @@ +// terminal icon rasterization shared by every content type and markdown. + +// a single "pixel" in a terminal icon. +#[derive(Debug, Clone, Copy)] +pub struct IconCell { + pub symbol: char, + pub bg_r: u8, + pub bg_g: u8, + pub bg_b: u8, + pub fg_r: u8, + pub fg_g: u8, + pub fg_b: u8, +} + +pub(crate) fn make_icon_pixels( + bytes: &[u8], + width: u16, + height: u16, +) -> Option>> { + let image = image::load_from_memory(bytes).ok()?; + Some(make_icon_pixels_from_image(&image, width, height)) +} + +pub(crate) fn make_icon_pixels_from_image( + image: &image::DynamicImage, + width: u16, + height: u16, +) -> Vec> { + let resized = image.resize_exact( + u32::from(width), + u32::from(height) * 2, + image::imageops::FilterType::Nearest, + ); + let rgb = resized.to_rgb8(); + + let mut rows = Vec::new(); + for row in 0..height { + let mut columns = Vec::new(); + for column in 0..width { + let top_y = u32::from(row) * 2; + let bottom_y = (top_y + 1).min(rgb.height().saturating_sub(1)); + let [tr, tg, tb] = rgb.get_pixel(u32::from(column), top_y).0; + let [br, bg, bb] = rgb.get_pixel(u32::from(column), bottom_y).0; + columns.push(IconCell { + symbol: '\u{2584}', + bg_r: br, + bg_g: bg, + bg_b: bb, + fg_r: tr, + fg_g: tg, + fg_b: tb, + }); + } + rows.push(columns); + } + rows +} + +pub(crate) fn make_icon_quadrants_from_image( + image: &image::DynamicImage, + width: u16, + height: u16, +) -> Vec> { + let resized = image + .resize_exact( + u32::from(width) * 2, + u32::from(height) * 2, + image::imageops::FilterType::Lanczos3, + ) + .to_rgb8(); + + (0..height) + .map(|row| { + (0..width) + .map(|column| { + let x = u32::from(column) * 2; + let y = u32::from(row) * 2; + quadrant_cell([ + resized.get_pixel(x, y).0, + resized.get_pixel(x + 1, y).0, + resized.get_pixel(x, y + 1).0, + resized.get_pixel(x + 1, y + 1).0, + ]) + }) + .collect() + }) + .collect() +} + +fn quadrant_cell(pixels: [[u8; 3]; 4]) -> IconCell { + let mut pair = (0, 0); + let mut max_distance = 0; + for left in 0..pixels.len() { + for right in (left + 1)..pixels.len() { + let distance = color_distance(pixels[left], pixels[right]); + if distance > max_distance { + max_distance = distance; + pair = (left, right); + } + } + } + + let bg = pixels[pair.0]; + let fg = pixels[pair.1]; + let mask = pixels + .iter() + .enumerate() + .fold(0_u8, |mask, (index, pixel)| { + if color_distance(*pixel, fg) <= color_distance(*pixel, bg) { + mask | (1 << index) + } else { + mask + } + }); + let symbol = match mask { + 0 => ' ', + 1 => '\u{2598}', + 2 => '\u{259d}', + 3 => '\u{2580}', + 4 => '\u{2596}', + 5 => '\u{258c}', + 6 => '\u{259e}', + 7 => '\u{259b}', + 8 => '\u{2597}', + 9 => '\u{259a}', + 10 => '\u{2590}', + 11 => '\u{259c}', + 12 => '\u{2584}', + 13 => '\u{2599}', + 14 => '\u{259f}', + _ => '\u{2588}', + }; + + IconCell { + symbol, + bg_r: bg[0], + bg_g: bg[1], + bg_b: bg[2], + fg_r: fg[0], + fg_g: fg[1], + fg_b: fg[2], + } +} + +fn color_distance(left: [u8; 3], right: [u8; 3]) -> u32 { + left.into_iter() + .zip(right) + .map(|(left, right)| { + let delta = i32::from(left) - i32::from(right); + (delta * delta) as u32 + }) + .sum() +} + +// 6 columns by 3 terminal rows is physically square with the usual 1:2 cell ratio. +pub(crate) fn fallback_icon() -> Vec> { + const MASK: [[bool; 6]; 6] = [ + [false, true, true, true, true, false], + [true, false, false, false, false, true], + [false, false, false, true, true, false], + [false, false, true, true, false, false], + [false, false, false, false, false, false], + [false, false, true, true, false, false], + ]; + MASK.chunks_exact(2) + .map(|pair| { + pair[0] + .iter() + .zip(pair[1]) + .map(|(&top, bottom)| fallback_cell(top, bottom)) + .collect() + }) + .collect() +} + +fn fallback_cell(top: bool, bottom: bool) -> IconCell { + let background = if top { 150 } else { 45 }; + let foreground = if bottom { 150 } else { 45 }; + IconCell { + symbol: '\u{2584}', + bg_r: background, + bg_g: background, + bg_b: background, + fg_r: foreground, + fg_g: foreground, + fg_b: foreground, + } +} + +pub(crate) fn fallback_icon_large() -> Vec> { + let background = IconCell { + symbol: '\u{2584}', + bg_r: 50, + bg_g: 50, + bg_b: 50, + fg_r: 50, + fg_g: 50, + fg_b: 50, + }; + let bottom = IconCell { + symbol: '\u{2584}', + bg_r: 50, + bg_g: 50, + bg_b: 50, + fg_r: 130, + fg_g: 130, + fg_b: 130, + }; + let top = IconCell { + symbol: '\u{2584}', + bg_r: 130, + bg_g: 130, + bg_b: 130, + fg_r: 50, + fg_g: 50, + fg_b: 50, + }; + vec![ + vec![ + background, background, bottom, bottom, bottom, bottom, bottom, bottom, bottom, bottom, + background, background, + ], + vec![ + background, background, bottom, bottom, bottom, bottom, bottom, bottom, bottom, bottom, + background, background, + ], + vec![ + background, background, background, background, background, background, top, top, top, + top, background, background, + ], + vec![ + background, background, background, background, background, background, top, top, top, + top, background, background, + ], + vec![ + background, background, background, background, top, top, top, top, background, + background, background, background, + ], + vec![ + background, background, background, background, top, top, top, top, background, + background, background, background, + ], + ] +} + +#[cfg(test)] +#[path = "../tests/content/icons.rs"] +mod tests; diff --git a/src/instance/content/manifest.rs b/src/instance/content/manifest.rs new file mode 100644 index 0000000..296aca0 --- /dev/null +++ b/src/instance/content/manifest.rs @@ -0,0 +1,464 @@ +use std::collections::{BTreeMap, HashMap}; +use std::io::{Read, Seek}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, LazyLock, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; +use sha1::Digest as _; + +const MANIFEST_VERSION: u32 = 1; +static MANIFEST_LOCKS: LazyLock>>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ContentKind { + Mod, + ResourcePack, + Shader, + DataPack, +} + +impl ContentKind { + pub fn directory(self) -> &'static str { + match self { + Self::Mod => "mods", + Self::ResourcePack => "resourcepacks", + Self::Shader => "shaderpacks", + Self::DataPack => "datapacks", + } + } + + pub fn unavailable_message(self, loader: crate::instance::ModLoader) -> Option<&'static str> { + if loader != crate::instance::ModLoader::Vanilla { + return None; + } + match self { + Self::Mod => Some("Vanilla does not support mods."), + Self::Shader => Some("Vanilla does not support shaders."), + Self::ResourcePack | Self::DataPack => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProviderProject { + pub provider: String, + pub project_id: String, + pub version_id: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum Resolution { + #[default] + Pending, + Resolved { + project: ProviderProject, + }, + Unmatched { + checked_at: i64, + providers: Vec, + }, + Ambiguous { + candidates: Vec, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FileFingerprint { + pub size: u64, + pub modified_ns: u128, + pub hashes: BTreeMap, +} + +impl FileFingerprint { + pub fn hash(&self, algorithm: &str) -> Option<&str> { + self.hashes.get(algorithm).map(String::as_str) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ContentFileRecord { + pub relative_path: PathBuf, + pub kind: ContentKind, + pub enabled: bool, + pub fingerprint: FileFingerprint, + #[serde(default)] + pub resolution: Resolution, + #[serde(default)] + pub provider_aliases: Vec, + #[serde(default)] + pub provider_checks: Vec, + #[serde(default)] + pub required_dependencies: Vec, + #[serde(default)] + pub automatic_dependency: bool, + #[serde(default)] + pub cleanup_eligible: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContentManifest { + pub version: u32, + #[serde(default)] + pub files: Vec, +} + +impl Default for ContentManifest { + fn default() -> Self { + Self { + version: MANIFEST_VERSION, + files: Vec::new(), + } + } +} + +impl ContentManifest { + pub fn load(path: &Path) -> Result { + if !path.exists() { + return Ok(Self::default()); + } + let bytes = std::fs::read(path)?; + let manifest: Self = serde_json::from_slice(&bytes)?; + if manifest.version != MANIFEST_VERSION { + return Err(ManifestError::UnsupportedVersion(manifest.version)); + } + Ok(manifest) + } + + pub fn save(&self, path: &Path) -> Result<(), ManifestError> { + let parent = path.parent().ok_or_else(|| { + ManifestError::InvalidPath(format!("{} has no parent", path.display())) + })?; + std::fs::create_dir_all(parent)?; + let bytes = serde_json::to_vec_pretty(self)?; + crate::storage::write_atomic(path, &bytes)?; + Ok(()) + } + + pub fn update( + path: &Path, + update: impl FnOnce(&mut Self) -> Result, + ) -> Result { + let lock = { + let mut locks = MANIFEST_LOCKS + .lock() + .map_err(|_| ManifestError::LockPoisoned)?; + locks + .entry(path.to_owned()) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone() + }; + let _guard = lock.lock().map_err(|_| ManifestError::LockPoisoned)?; + let mut manifest = Self::load(path)?; + let result = update(&mut manifest)?; + manifest.save(path)?; + Ok(result) + } + + pub fn record(&self, relative_path: &Path) -> Option<&ContentFileRecord> { + self.files + .iter() + .find(|record| record.relative_path == relative_path) + } + + pub fn upsert(&mut self, record: ContentFileRecord) { + if let Some(existing) = self + .files + .iter_mut() + .find(|existing| existing.relative_path == record.relative_path) + { + *existing = record; + } else { + self.files.push(record); + self.files + .sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); + } + } + + pub fn remove(&mut self, relative_path: &Path) { + self.files + .retain(|record| record.relative_path != relative_path); + } + + pub fn rename_record(&mut self, from: &Path, to: &Path, enabled: bool) -> bool { + let Some(index) = self + .files + .iter() + .position(|record| record.relative_path == from || record.relative_path == to) + else { + return false; + }; + let mut record = self.files.remove(index); + record.relative_path = to.to_owned(); + record.enabled = enabled; + self.upsert(record); + true + } + + pub fn resolved_project_path( + &self, + provider: &str, + project_id: &str, + minecraft_dir: &Path, + ) -> Option { + self.files.iter().find_map(|record| { + record + .matches_project(provider, project_id) + .then(|| minecraft_dir.join(&record.relative_path)) + }) + } + + pub fn resolved_project_path_under( + &self, + provider: &str, + project_id: &str, + minecraft_dir: &Path, + directory: &Path, + ) -> Option { + self.files.iter().find_map(|record| { + let path = minecraft_dir.join(&record.relative_path); + (record.matches_project(provider, project_id) && path.starts_with(directory)) + .then_some(path) + }) + } + + pub fn resolved_project_record( + &self, + provider: &str, + project_id: &str, + ) -> Option<&ContentFileRecord> { + self.files + .iter() + .find(|record| record.matches_project(provider, project_id)) + } + + pub fn dependent_paths(&self, relative_path: &Path) -> Vec { + let Some(target) = self.record(relative_path) else { + return Vec::new(); + }; + self.files + .iter() + .filter(|record| record.relative_path != relative_path) + .filter(|record| dependency_applies(target, record)) + .map(|record| record.relative_path.clone()) + .collect() + } + + pub fn orphaned_dependencies_after_removing(&self, relative_path: &Path) -> Vec { + self.orphaned_dependencies_with_removed(std::iter::once(relative_path.to_owned())) + } + + pub fn orphaned_dependencies(&self) -> Vec { + self.orphaned_dependencies_with_removed(std::iter::empty()) + } + + fn orphaned_dependencies_with_removed( + &self, + removed: impl IntoIterator, + ) -> Vec { + let mut removed = removed + .into_iter() + .collect::>(); + let mut orphans = Vec::new(); + loop { + let mut found = false; + for candidate in &self.files { + if !candidate.automatic_dependency + || !candidate.cleanup_eligible + || removed.contains(&candidate.relative_path) + { + continue; + } + let still_required = self + .files + .iter() + .filter(|record| !removed.contains(&record.relative_path)) + .any(|record| dependency_applies(candidate, record)); + if !still_required { + removed.insert(candidate.relative_path.clone()); + orphans.push(candidate.relative_path.clone()); + found = true; + } + } + if !found { + break; + } + } + orphans + } +} + +fn dependency_applies(candidate: &ContentFileRecord, dependent: &ContentFileRecord) -> bool { + let matches = dependent + .required_dependencies + .iter() + .any(|dependency| candidate.matches_project(&dependency.provider, &dependency.project_id)); + if !matches || candidate.kind != ContentKind::DataPack { + return matches; + } + datapack_world(&candidate.relative_path) == datapack_world(&dependent.relative_path) +} + +fn datapack_world(path: &Path) -> Option<&std::ffi::OsStr> { + let mut components = path.components(); + (components.next()?.as_os_str() == "saves") + .then(|| components.next().map(std::path::Component::as_os_str)) + .flatten() +} + +impl ContentFileRecord { + pub fn resolved_project(&self) -> Option<&ProviderProject> { + match &self.resolution { + Resolution::Resolved { project } => Some(project), + _ => None, + } + } + + pub fn matches_project(&self, provider: &str, project_id: &str) -> bool { + self.resolved_project() + .into_iter() + .chain(self.provider_aliases.iter()) + .any(|project| project.provider == provider && project.project_id == project_id) + } + + pub fn project_for_provider( + &self, + provider: &str, + project_id: &str, + ) -> Option<&ProviderProject> { + self.resolved_project() + .into_iter() + .chain(self.provider_aliases.iter()) + .find(|project| project.provider == provider && project.project_id == project_id) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ManifestError { + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + #[error("Unsupported content manifest version {0}")] + UnsupportedVersion(u32), + #[error("Invalid manifest path: {0}")] + InvalidPath(String), + #[error("Content manifest lock was poisoned")] + LockPoisoned, +} + +pub fn fingerprint(path: &Path) -> Result { + let metadata = std::fs::metadata(path)?; + let modified_ns = metadata + .modified() + .unwrap_or(SystemTime::UNIX_EPOCH) + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let mut file = std::fs::File::open(path)?; + let mut sha1 = sha1::Sha1::new(); + let mut sha512 = sha2::Sha512::new(); + let mut curseforge_len = 0u32; + let mut buffer = [0u8; 64 * 1024]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + sha1.update(&buffer[..read]); + sha512.update(&buffer[..read]); + curseforge_len = curseforge_len.saturating_add( + buffer[..read] + .iter() + .filter(|byte| !matches!(byte, b'\t' | b'\n' | b'\r' | b' ')) + .count() as u32, + ); + } + file.rewind()?; + let hashes = BTreeMap::from([ + ("sha1".to_owned(), format!("{:x}", sha1.finalize())), + ("sha512".to_owned(), format!("{:x}", sha512.finalize())), + ( + "curseforge".to_owned(), + curseforge_fingerprint(&mut file, curseforge_len)?.to_string(), + ), + ]); + Ok(FileFingerprint { + size: metadata.len(), + modified_ns, + hashes, + }) +} + +fn curseforge_fingerprint(reader: &mut impl Read, length: u32) -> std::io::Result { + const M: u32 = 0x5bd1e995; + let mut hash = 1 ^ length; + let mut block = [0u8; 4]; + let mut block_len = 0; + let mut buffer = [0u8; 64 * 1024]; + loop { + let read = reader.read(&mut buffer)?; + if read == 0 { + break; + } + for &byte in &buffer[..read] { + if matches!(byte, b'\t' | b'\n' | b'\r' | b' ') { + continue; + } + block[block_len] = byte; + block_len += 1; + if block_len == 4 { + let mut value = u32::from_le_bytes(block); + value = value.wrapping_mul(M); + value ^= value >> 24; + value = value.wrapping_mul(M); + hash = hash.wrapping_mul(M) ^ value; + block_len = 0; + } + } + } + match block_len { + 3 => { + hash ^= u32::from(block[2]) << 16; + hash ^= u32::from(block[1]) << 8; + hash ^= u32::from(block[0]); + hash = hash.wrapping_mul(M); + } + 2 => { + hash ^= u32::from(block[1]) << 8; + hash ^= u32::from(block[0]); + hash = hash.wrapping_mul(M); + } + 1 => { + hash ^= u32::from(block[0]); + hash = hash.wrapping_mul(M); + } + _ => {} + } + hash ^= hash >> 13; + hash = hash.wrapping_mul(M); + hash ^= hash >> 15; + Ok(hash) +} + +pub fn fingerprint_metadata(path: &Path) -> Result { + let metadata = std::fs::metadata(path)?; + let modified_ns = metadata + .modified() + .unwrap_or(SystemTime::UNIX_EPOCH) + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + Ok(FileFingerprint { + size: metadata.len(), + modified_ns, + hashes: BTreeMap::new(), + }) +} + +#[cfg(test)] +#[path = "../tests/content/manifest.rs"] +mod tests; diff --git a/src/instance/content/mod.rs b/src/instance/content/mod.rs index 6b66eb7..44c479a 100644 --- a/src/instance/content/mod.rs +++ b/src/instance/content/mod.rs @@ -1,13 +1,26 @@ // scanning and toggling instance content (mods, resource packs, shaders, worlds). // minecraft uses a ".disabled" suffix convention for disabled content, so this leans on that heavily. +pub mod datapacks; +pub mod dependencies; +pub mod entry; +pub mod icons; +pub mod manifest; pub mod mods; +mod packs; +pub mod provider; +pub mod reconcile; pub mod resource_packs; pub mod shaders; +pub mod updates; pub mod worlds; -pub use mods::scan_one_mod; -pub use mods::{ContentEntry, scan_mods, toggle_entry}; +pub use datapacks::scan_one_datapack; +pub(crate) use icons::{ + IconCell, fallback_icon, fallback_icon_large, make_icon_pixels, make_icon_pixels_from_image, + make_icon_quadrants_from_image, +}; +pub use mods::{scan_mods, scan_one_mod}; pub use resource_packs::{scan_one_resource_pack, scan_resource_packs}; pub use shaders::{scan_one_shader, scan_shaders}; pub use worlds::{scan_one_world, scan_worlds}; diff --git a/src/instance/content/mods.rs b/src/instance/content/mods.rs index 22e3d4a..63fce9d 100644 --- a/src/instance/content/mods.rs +++ b/src/instance/content/mods.rs @@ -1,37 +1,16 @@ -// mod scanning, metadata extraction, and icon rendering for the content list. +// mod scanning and loader-specific metadata extraction. // jar files are just zips, so it cracks them open looking for loader-specific // metadata (fabric.mod.json, quilt.mod.json, mods.toml, mcmod.info) to get // names, descriptions, and icons. if none of those work, falls back to common // root-level icon paths (logo.png, icon.png, pack.png) or just the filename. use std::io::Read; -use std::path::{Path, PathBuf}; +use std::path::Path; use serde::Deserialize; -// a single "pixel" in the terminal icon. uses the unicode half-block trick -// where each character cell shows two vertical pixels (fg = top, bg = bottom) -#[derive(Debug, Clone, Copy)] -pub struct IconCell { - pub symbol: char, - pub bg_r: u8, - pub bg_g: u8, - pub bg_b: u8, - pub fg_r: u8, - pub fg_g: u8, - pub fg_b: u8, -} - -#[derive(Debug, Clone)] -pub struct ContentEntry { - pub file_stem: String, - pub name: String, - pub description: String, - pub enabled: bool, - pub icon_bytes: Option>, - pub path: PathBuf, - pub icon_lines: Option>>, -} +use super::entry::ContentEntry; +use super::icons::{fallback_icon, make_icon_pixels}; #[derive(Deserialize, Default)] struct FabricModJson { @@ -40,6 +19,8 @@ struct FabricModJson { #[serde(default)] description: String, #[serde(default)] + version: String, + #[serde(default)] icon: serde_json::Value, } @@ -72,6 +53,8 @@ struct QuiltModJson { #[derive(Deserialize, Default)] struct QuiltLoader { + #[serde(default)] + version: String, #[serde(default)] metadata: QuiltMetadata, } @@ -93,7 +76,7 @@ impl QuiltMetadata { } pub fn scan_one_mod(path: &Path, file_stem: &str, enabled: bool) -> ContentEntry { - let (name, description, icon_bytes) = read_mod_metadata(path); + let (name, description, version, icon_bytes) = read_mod_metadata(path); let icon_lines = icon_bytes .as_ref() .and_then(|bytes| make_icon_pixels(bytes, 6, 3)) @@ -108,9 +91,18 @@ pub fn scan_one_mod(path: &Path, file_stem: &str, enabled: bool) -> ContentEntry ContentEntry { file_stem: file_stem.to_owned(), name: display_name, + source_slug: None, + installed_path: None, + provider_project: None, + world_details: None, + title_suffix: None, + footer_label: display_version(version), + footer_change: None, description, enabled, icon_bytes, + provider_icon: false, + provider_description: false, path: path.to_path_buf(), icon_lines, } @@ -119,7 +111,7 @@ pub fn scan_one_mod(path: &Path, file_stem: &str, enabled: bool) -> ContentEntry pub fn scan_mods(instances_dir: &Path, instance_name: &str) -> Vec { let mods_dir = instances_dir .join(instance_name) - .join(".minecraft") + .join(crate::storage::MINECRAFT_DIR_NAME) .join("mods"); let read_dir = match std::fs::read_dir(&mods_dir) { @@ -151,12 +143,12 @@ pub fn scan_mods(instances_dir: &Path, instance_name: &str) -> Vec // checks fabric.mod.json, quilt.mod.json, META-INF/mods.toml (forge), // META-INF/neoforge.mods.toml, and mcmod.info (legacy forge). if none of // those yield an icon, falls back to common root-level paths. -fn read_mod_metadata(jar_path: &Path) -> (String, String, Option>) { +fn read_mod_metadata(jar_path: &Path) -> (String, String, String, Option>) { let file = match std::fs::File::open(jar_path) { Ok(file) => file, Err(e) => { tracing::trace!("Failed to open mod JAR {}: {}", jar_path.display(), e); - return (String::new(), String::new(), None); + return (String::new(), String::new(), String::new(), None); } }; @@ -168,13 +160,14 @@ fn read_mod_metadata(jar_path: &Path) -> (String, String, Option>) { jar_path.display(), e ); - return (String::new(), String::new(), None); + return (String::new(), String::new(), String::new(), None); } }; // try each loader's metadata in order. if we get metadata but the // declared icon path is missing, fall back to common root-level icons. - type MetaReader = fn(&mut zip::ZipArchive) -> Option<(String, String, String)>; + type MetaReader = + fn(&mut zip::ZipArchive) -> Option<(String, String, String, String)>; let readers: [MetaReader; 4] = [ read_fabric_meta, read_quilt_meta, @@ -183,7 +176,7 @@ fn read_mod_metadata(jar_path: &Path) -> (String, String, Option>) { ]; for reader in &readers { - if let Some((name, description, icon_path)) = reader(&mut archive) { + if let Some((name, description, version, icon_path)) = reader(&mut archive) { let icon_path = icon_path.trim_start_matches('/'); let icon = if icon_path.is_empty() { None @@ -197,7 +190,7 @@ fn read_mod_metadata(jar_path: &Path) -> (String, String, Option>) { name, !icon_path.is_empty() ); - return (name, description, icon); + return (name, description, version, icon); } } @@ -208,7 +201,12 @@ fn read_mod_metadata(jar_path: &Path) -> (String, String, Option>) { jar_path.display(), icon_bytes.is_some() ); - (String::new(), String::new(), icon_bytes) + (String::new(), String::new(), String::new(), icon_bytes) +} + +fn display_version(version: String) -> Option { + let version = version.trim(); + (!version.is_empty() && !version.contains("${")).then(|| version.to_owned()) } fn try_fallback_icons(archive: &mut zip::ZipArchive) -> Option> { @@ -222,27 +220,28 @@ fn try_fallback_icons(archive: &mut zip::ZipArchive) -> Option, -) -> Option<(String, String, String)> { +) -> Option<(String, String, String, String)> { let mut entry = archive.by_name("fabric.mod.json").ok()?; let mut raw = String::new(); entry.read_to_string(&mut raw).ok()?; let sanitized = sanitize_json_strings(&raw); let data: FabricModJson = serde_json::from_str(&sanitized).ok()?; let icon = data.icon_path(); - Some((data.name, data.description, icon)) + Some((data.name, data.description, data.version, icon)) } fn read_quilt_meta( archive: &mut zip::ZipArchive, -) -> Option<(String, String, String)> { +) -> Option<(String, String, String, String)> { let mut entry = archive.by_name("quilt.mod.json").ok()?; let mut raw = String::new(); entry.read_to_string(&mut raw).ok()?; let sanitized = sanitize_json_strings(&raw); let data: QuiltModJson = serde_json::from_str(&sanitized).ok()?; + let version = data.quilt_loader.version; let meta = data.quilt_loader.metadata; let icon = meta.icon_path(); - Some((meta.name, meta.description, icon)) + Some((meta.name, meta.description, version, icon)) } // forge (META-INF/mods.toml) and neoforge (META-INF/neoforge.mods.toml) @@ -252,7 +251,7 @@ fn read_quilt_meta( // but no [[mods]] section. we still want the icon in that case. fn read_forge_toml_meta( archive: &mut zip::ZipArchive, -) -> Option<(String, String, String)> { +) -> Option<(String, String, String, String)> { let raw = read_zip_string(archive, "META-INF/neoforge.mods.toml") .or_else(|| read_zip_string(archive, "META-INF/mods.toml"))?; let table: toml::Table = raw.parse().ok()?; @@ -261,7 +260,7 @@ fn read_forge_toml_meta( .and_then(|v| v.as_str()) .unwrap_or("") .to_owned(); - let (name, description) = table + let (name, description, version) = table .get("mods") .and_then(|v| v.as_array()) .and_then(|a| a.first()) @@ -278,17 +277,22 @@ fn read_forge_toml_meta( .unwrap_or("") .trim() .to_owned(); - (n, d) + let version = first + .get("version") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_owned(); + (n, d, version) }) .unwrap_or_default(); - Some((name, description, logo)) + Some((name, description, version, logo)) } // legacy forge mcmod.info is either a bare json array of mod entries // or an object with a "modList" key wrapping the array fn read_mcmod_info( archive: &mut zip::ZipArchive, -) -> Option<(String, String, String)> { +) -> Option<(String, String, String, String)> { let mut entry = archive.by_name("mcmod.info").ok()?; let mut raw = String::new(); entry.read_to_string(&mut raw).ok()?; @@ -309,12 +313,17 @@ fn read_mcmod_info( .and_then(|v| v.as_str()) .unwrap_or("") .to_owned(); + let version = first + .get("version") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_owned(); let logo = first .get("logoFile") .and_then(|v| v.as_str()) .unwrap_or("") .to_owned(); - Some((name, description, logo)) + Some((name, description, version, logo)) } fn read_zip_string(archive: &mut zip::ZipArchive, path: &str) -> Option { @@ -368,619 +377,6 @@ fn read_zip_bytes(archive: &mut zip::ZipArchive, path: &str) -> O Some(bytes) } -// downscales an icon to terminal resolution. resizes to height*2 because each -// terminal cell renders two vertical pixels using half-block characters (▀) -pub(crate) fn make_icon_pixels( - bytes: &[u8], - width: u16, - height: u16, -) -> Option>> { - let img = image::load_from_memory(bytes).ok()?; - Some(make_icon_pixels_from_image(&img, width, height)) -} - -pub(crate) fn make_icon_pixels_from_image( - img: &image::DynamicImage, - width: u16, - height: u16, -) -> Vec> { - let resized = img.resize_exact( - u32::from(width), - u32::from(height) * 2, - image::imageops::FilterType::Nearest, - ); - let rgb = resized.to_rgb8(); - - let mut rows = Vec::new(); - for row in 0..height { - let mut cols = Vec::new(); - for col in 0..width { - let top_y = u32::from(row) * 2; - let bottom_y = (u32::from(row) * 2 + 1).min(rgb.height().saturating_sub(1)); - let [tr, tg, tb] = rgb.get_pixel(u32::from(col), top_y).0; - let [br, bg, bb] = rgb.get_pixel(u32::from(col), bottom_y).0; - cols.push(IconCell { - symbol: '\u{2584}', - bg_r: br, - bg_g: bg, - bg_b: bb, - fg_r: tr, - fg_g: tg, - fg_b: tb, - }); - } - rows.push(cols); - } - - rows -} - -pub(crate) fn make_icon_quadrants_from_image( - img: &image::DynamicImage, - width: u16, - height: u16, -) -> Vec> { - let resized = img - .resize_exact( - u32::from(width) * 2, - u32::from(height) * 2, - image::imageops::FilterType::Lanczos3, - ) - .to_rgb8(); - - (0..height) - .map(|row| { - (0..width) - .map(|col| { - let x = u32::from(col) * 2; - let y = u32::from(row) * 2; - let pixels = [ - resized.get_pixel(x, y).0, - resized.get_pixel(x + 1, y).0, - resized.get_pixel(x, y + 1).0, - resized.get_pixel(x + 1, y + 1).0, - ]; - quadrant_cell(pixels) - }) - .collect() - }) - .collect() -} - -fn quadrant_cell(pixels: [[u8; 3]; 4]) -> IconCell { - let mut pair = (0, 0); - let mut max_distance = 0; - for left in 0..pixels.len() { - for right in (left + 1)..pixels.len() { - let distance = color_distance(pixels[left], pixels[right]); - if distance > max_distance { - max_distance = distance; - pair = (left, right); - } - } - } - - let bg = pixels[pair.0]; - let fg = pixels[pair.1]; - let mask = pixels - .iter() - .enumerate() - .fold(0_u8, |mask, (index, pixel)| { - if color_distance(*pixel, fg) <= color_distance(*pixel, bg) { - mask | (1 << index) - } else { - mask - } - }); - let symbol = match mask { - 0 => ' ', - 1 => '\u{2598}', - 2 => '\u{259d}', - 3 => '\u{2580}', - 4 => '\u{2596}', - 5 => '\u{258c}', - 6 => '\u{259e}', - 7 => '\u{259b}', - 8 => '\u{2597}', - 9 => '\u{259a}', - 10 => '\u{2590}', - 11 => '\u{259c}', - 12 => '\u{2584}', - 13 => '\u{2599}', - 14 => '\u{259f}', - _ => '\u{2588}', - }; - - IconCell { - symbol, - bg_r: bg[0], - bg_g: bg[1], - bg_b: bg[2], - fg_r: fg[0], - fg_g: fg[1], - fg_b: fg[2], - } -} - -fn color_distance(left: [u8; 3], right: [u8; 3]) -> u32 { - left.into_iter() - .zip(right) - .map(|(left, right)| { - let delta = i32::from(left) - i32::from(right); - (delta * delta) as u32 - }) - .sum() -} - -// 6x3 fallback icon showing a "?" pattern for mods without icons. -pub(super) fn fallback_icon() -> Vec> { - let b = IconCell { - symbol: '\u{2584}', - bg_r: 50, - bg_g: 50, - bg_b: 50, - fg_r: 50, - fg_g: 50, - fg_b: 50, - }; - let tb = IconCell { - symbol: '\u{2584}', - bg_r: 50, - bg_g: 50, - bg_b: 50, - fg_r: 130, - fg_g: 130, - fg_b: 130, - }; - let bt = IconCell { - symbol: '\u{2584}', - bg_r: 130, - bg_g: 130, - bg_b: 130, - fg_r: 50, - fg_g: 50, - fg_b: 50, - }; - vec![ - vec![b, tb, tb, tb, tb, b], - vec![b, b, b, bt, bt, b], - vec![b, b, bt, bt, b, b], - ] -} - -// 12x6 fallback icon showing a "?" pattern for worlds without icons. -pub(super) fn fallback_icon_large() -> Vec> { - let b = IconCell { - symbol: '\u{2584}', - bg_r: 50, - bg_g: 50, - bg_b: 50, - fg_r: 50, - fg_g: 50, - fg_b: 50, - }; - let tb = IconCell { - symbol: '\u{2584}', - bg_r: 50, - bg_g: 50, - bg_b: 50, - fg_r: 130, - fg_g: 130, - fg_b: 130, - }; - let bt = IconCell { - symbol: '\u{2584}', - bg_r: 130, - bg_g: 130, - bg_b: 130, - fg_r: 50, - fg_g: 50, - fg_b: 50, - }; - vec![ - vec![b, b, tb, tb, tb, tb, tb, tb, tb, tb, b, b], - vec![b, b, tb, tb, tb, tb, tb, tb, tb, tb, b, b], - vec![b, b, b, b, b, b, bt, bt, bt, bt, b, b], - vec![b, b, b, b, b, b, bt, bt, bt, bt, b, b], - vec![b, b, b, b, bt, bt, bt, bt, b, b, b, b], - vec![b, b, b, b, bt, bt, bt, bt, b, b, b, b], - ] -} - -// enable/disable by renaming the file with/without ".disabled" suffix. -// the minecraft way, apparently. -pub fn toggle_entry(entry: &ContentEntry) -> Result<(), std::io::Error> { - let file_name = match entry.path.file_name().and_then(|name| name.to_str()) { - Some(name) => name, - None => return Ok(()), - }; - - let new_name = if entry.enabled { - format!("{file_name}.disabled") - } else { - file_name.trim_end_matches(".disabled").to_string() - }; - - let mut new_path = entry.path.clone(); - new_path.set_file_name(new_name); - std::fs::rename(&entry.path, new_path) -} - #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn quadrant_raster_has_requested_dimensions() { - let image = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel( - 2, - 2, - image::Rgb([12, 34, 56]), - )); - let rows = make_icon_quadrants_from_image(&image, 7, 3); - - assert_eq!(rows.len(), 3); - assert!(rows.iter().all(|row| row.len() == 7)); - assert!(rows.iter().flatten().all(|cell| cell.symbol == '\u{2588}')); - } - - fn setup_mods_dir(tmp: &Path, instance: &str) -> PathBuf { - let dir = tmp.join(instance).join(".minecraft").join("mods"); - std::fs::create_dir_all(&dir).unwrap(); - dir - } - - #[test] - fn scan_mods_empty_dir() { - let tmp = tempfile::tempdir().unwrap(); - setup_mods_dir(tmp.path(), "inst"); - let mods = scan_mods(tmp.path(), "inst"); - assert!(mods.is_empty()); - } - - #[test] - fn scan_mods_missing_dir_returns_empty() { - let tmp = tempfile::tempdir().unwrap(); - let mods = scan_mods(tmp.path(), "ghost"); - assert!(mods.is_empty()); - } - - #[test] - fn scan_mods_finds_jar_files() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_mods_dir(tmp.path(), "inst"); - std::fs::write(dir.join("cool-mod.jar"), b"PK\x03\x04").unwrap(); - std::fs::write(dir.join("other-mod.jar.disabled"), b"PK\x03\x04").unwrap(); - let mods = scan_mods(tmp.path(), "inst"); - assert_eq!(mods.len(), 2); - } - - #[test] - fn scan_mods_enabled_disabled_flags() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_mods_dir(tmp.path(), "inst"); - std::fs::write(dir.join("active.jar"), b"PK\x03\x04").unwrap(); - std::fs::write(dir.join("inactive.jar.disabled"), b"PK\x03\x04").unwrap(); - let mods = scan_mods(tmp.path(), "inst"); - let active = mods.iter().find(|m| m.file_stem == "active").unwrap(); - let inactive = mods.iter().find(|m| m.file_stem == "inactive").unwrap(); - assert!(active.enabled); - assert!(!inactive.enabled); - } - - #[test] - fn scan_mods_ignores_non_jar() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_mods_dir(tmp.path(), "inst"); - std::fs::write(dir.join("readme.txt"), "not a mod").unwrap(); - std::fs::write(dir.join("config.json"), "{}").unwrap(); - std::fs::write(dir.join("real.jar"), b"PK\x03\x04").unwrap(); - let mods = scan_mods(tmp.path(), "inst"); - assert_eq!(mods.len(), 1); - } - - #[test] - fn scan_mods_sorted_case_insensitive() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_mods_dir(tmp.path(), "inst"); - std::fs::write(dir.join("Zebra.jar"), b"PK\x03\x04").unwrap(); - std::fs::write(dir.join("alpha.jar"), b"PK\x03\x04").unwrap(); - std::fs::write(dir.join("Beta.jar"), b"PK\x03\x04").unwrap(); - let mods = scan_mods(tmp.path(), "inst"); - let names: Vec<&str> = mods.iter().map(|m| m.name.as_str()).collect(); - assert_eq!(names, vec!["alpha", "Beta", "Zebra"]); - } - - #[test] - fn toggle_entry_enable() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_mods_dir(tmp.path(), "inst"); - let disabled_path = dir.join("mymod.jar.disabled"); - std::fs::write(&disabled_path, b"PK\x03\x04").unwrap(); - - let entry = ContentEntry { - file_stem: "mymod".to_string(), - name: "mymod".to_string(), - description: String::new(), - enabled: false, - icon_bytes: None, - path: disabled_path.clone(), - icon_lines: None, - }; - - toggle_entry(&entry).unwrap(); - assert!(!disabled_path.exists()); - assert!(dir.join("mymod.jar").exists()); - } - - use std::io::Write as _; - - fn make_jar(dir: &Path, name: &str, entries: &[(&str, &[u8])]) { - let path = dir.join(name); - let file = std::fs::File::create(&path).unwrap(); - let mut zip = zip::ZipWriter::new(file); - let options: zip::write::FileOptions<()> = - zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Stored); - for (entry_name, data) in entries { - zip.start_file(*entry_name, options).unwrap(); - zip.write_all(data).unwrap(); - } - zip.finish().unwrap(); - } - - #[test] - fn scan_mods_reads_fabric_metadata() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_mods_dir(tmp.path(), "inst"); - let meta = r#"{"name":"Fabric Mod","description":"A fabric mod","icon":"icon.png"}"#; - make_jar( - &dir, - "fabric-mod.jar", - &[("fabric.mod.json", meta.as_bytes())], - ); - let mods = scan_mods(tmp.path(), "inst"); - assert_eq!(mods.len(), 1); - assert_eq!(mods[0].name, "Fabric Mod"); - assert_eq!(mods[0].description, "A fabric mod"); - } - - #[test] - fn scan_mods_reads_quilt_metadata() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_mods_dir(tmp.path(), "inst"); - let meta = - r#"{"quilt_loader":{"metadata":{"name":"Quilt Mod","description":"A quilt mod"}}}"#; - make_jar( - &dir, - "quilt-mod.jar", - &[("quilt.mod.json", meta.as_bytes())], - ); - let mods = scan_mods(tmp.path(), "inst"); - assert_eq!(mods.len(), 1); - assert_eq!(mods[0].name, "Quilt Mod"); - assert_eq!(mods[0].description, "A quilt mod"); - } - - #[test] - fn scan_mods_reads_forge_toml_metadata() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_mods_dir(tmp.path(), "inst"); - let meta = r#" -logoFile = "logo.png" - -[[mods]] -displayName = "Forge Mod" -description = "A forge mod" -"#; - make_jar( - &dir, - "forge-mod.jar", - &[("META-INF/mods.toml", meta.as_bytes())], - ); - let mods = scan_mods(tmp.path(), "inst"); - assert_eq!(mods.len(), 1); - assert_eq!(mods[0].name, "Forge Mod"); - assert_eq!(mods[0].description, "A forge mod"); - } - - #[test] - fn scan_mods_reads_neoforge_toml_metadata() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_mods_dir(tmp.path(), "inst"); - let meta = r#" -logoFile = "logo.png" - -[[mods]] -displayName = "NeoForge Mod" -description = "A neoforge mod" -"#; - make_jar( - &dir, - "neoforge-mod.jar", - &[("META-INF/neoforge.mods.toml", meta.as_bytes())], - ); - let mods = scan_mods(tmp.path(), "inst"); - assert_eq!(mods.len(), 1); - assert_eq!(mods[0].name, "NeoForge Mod"); - assert_eq!(mods[0].description, "A neoforge mod"); - } - - #[test] - fn scan_mods_reads_mcmod_info_array() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_mods_dir(tmp.path(), "inst"); - let meta = r#"[{"name":"Legacy Mod","description":"An old forge mod"}]"#; - make_jar(&dir, "legacy-mod.jar", &[("mcmod.info", meta.as_bytes())]); - let mods = scan_mods(tmp.path(), "inst"); - assert_eq!(mods.len(), 1); - assert_eq!(mods[0].name, "Legacy Mod"); - assert_eq!(mods[0].description, "An old forge mod"); - } - - #[test] - fn scan_mods_reads_mcmod_info_modlist() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_mods_dir(tmp.path(), "inst"); - let meta = r#"{"modList":[{"name":"Wrapped Mod","description":"Has modList wrapper"}]}"#; - make_jar(&dir, "wrapped-mod.jar", &[("mcmod.info", meta.as_bytes())]); - let mods = scan_mods(tmp.path(), "inst"); - assert_eq!(mods.len(), 1); - assert_eq!(mods[0].name, "Wrapped Mod"); - assert_eq!(mods[0].description, "Has modList wrapper"); - } - - #[test] - fn scan_mods_prefers_fabric_over_forge() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_mods_dir(tmp.path(), "inst"); - let fabric = r#"{"name":"Fabric Name","description":"fabric desc"}"#; - let forge = "[[mods]]\ndisplayName = \"Forge Name\"\ndescription = \"forge desc\"\n"; - make_jar( - &dir, - "multi.jar", - &[ - ("fabric.mod.json", fabric.as_bytes()), - ("META-INF/mods.toml", forge.as_bytes()), - ], - ); - let mods = scan_mods(tmp.path(), "inst"); - assert_eq!(mods[0].name, "Fabric Name"); - } - - #[test] - fn scan_mods_prefers_quilt_over_forge() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_mods_dir(tmp.path(), "inst"); - let quilt = r#"{"quilt_loader":{"id":"x","version":"1","metadata":{"name":"Quilt Name","description":"quilt desc"}}}"#; - let forge = "[[mods]]\ndisplayName = \"Forge Name\"\ndescription = \"forge desc\"\n"; - make_jar( - &dir, - "quilt-over-forge.jar", - &[ - ("quilt.mod.json", quilt.as_bytes()), - ("META-INF/mods.toml", forge.as_bytes()), - ], - ); - let mods = scan_mods(tmp.path(), "inst"); - assert_eq!(mods[0].name, "Quilt Name"); - } - - #[test] - fn scan_mods_prefers_forge_toml_over_mcmod_info() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_mods_dir(tmp.path(), "inst"); - let forge = "[[mods]]\ndisplayName = \"Forge Name\"\ndescription = \"forge desc\"\n"; - let mcmod = r#"[{"modid":"legacy","name":"Legacy Name","description":"legacy desc"}]"#; - make_jar( - &dir, - "forge-over-legacy.jar", - &[ - ("META-INF/mods.toml", forge.as_bytes()), - ("mcmod.info", mcmod.as_bytes()), - ], - ); - let mods = scan_mods(tmp.path(), "inst"); - assert_eq!(mods[0].name, "Forge Name"); - } - - // when both neoforge.mods.toml and mods.toml exist in the same jar (the - // shape a dual-format mod might ship), the neoforge one wins because - // read_forge_toml_meta tries it first via .or_else. - #[test] - fn scan_mods_prefers_neoforge_toml_over_forge_toml() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_mods_dir(tmp.path(), "inst"); - let neoforge = - "[[mods]]\ndisplayName = \"NeoForge Name\"\ndescription = \"neoforge desc\"\n"; - let forge = "[[mods]]\ndisplayName = \"Forge Name\"\ndescription = \"forge desc\"\n"; - make_jar( - &dir, - "neoforge-over-forge.jar", - &[ - ("META-INF/neoforge.mods.toml", neoforge.as_bytes()), - ("META-INF/mods.toml", forge.as_bytes()), - ], - ); - let mods = scan_mods(tmp.path(), "inst"); - assert_eq!(mods[0].name, "NeoForge Name"); - } - - // a mods.toml with logoFile but no [[mods]] array (e.g. a dependency-only - // library jar) should still surface as a scanned mod with empty name + - // description but with the icon resolved. - #[test] - fn scan_mods_reads_mods_toml_without_mods_array() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_mods_dir(tmp.path(), "inst"); - let mods_toml = "logoFile = \"icon.png\"\n"; - let png_bytes = b"\x89PNG fake icon"; - make_jar( - &dir, - "lib-only.jar", - &[ - ("META-INF/mods.toml", mods_toml.as_bytes()), - ("icon.png", png_bytes), - ], - ); - let mods = scan_mods(tmp.path(), "inst"); - assert_eq!(mods.len(), 1); - // file-stem fallback when metadata name is empty - covered here - // because no other test exercises an empty-name + present-logo combo. - assert_eq!(mods[0].name, "lib-only"); - assert_eq!(mods[0].icon_bytes.as_deref(), Some(png_bytes.as_slice())); - } - - #[test] - fn scan_mods_fallback_icon_paths() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_mods_dir(tmp.path(), "inst"); - let png_bytes = b"\x89PNG fake"; - make_jar(&dir, "no-meta.jar", &[("logo.png", png_bytes)]); - let mods = scan_mods(tmp.path(), "inst"); - assert_eq!(mods.len(), 1); - assert_eq!(mods[0].name, "no-meta"); - assert_eq!(mods[0].icon_bytes.as_deref(), Some(png_bytes.as_slice())); - } - - #[test] - fn icon_path_from_value_string() { - let val = serde_json::json!("assets/icon.png"); - assert_eq!(icon_path_from_value(&val), "assets/icon.png"); - } - - #[test] - fn icon_path_from_value_map() { - // serde_json::Map is a BTreeMap, so iteration is sorted by key. - // "128" sorts before "64" lexicographically, so the first value wins. - let val = serde_json::json!({"64": "icon_64.png", "128": "icon_128.png"}); - assert_eq!(icon_path_from_value(&val), "icon_128.png"); - } - - #[test] - fn icon_path_from_value_null() { - let val = serde_json::Value::Null; - assert_eq!(icon_path_from_value(&val), ""); - } - - #[test] - fn toggle_entry_disable() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_mods_dir(tmp.path(), "inst"); - let enabled_path = dir.join("mymod.jar"); - std::fs::write(&enabled_path, b"PK\x03\x04").unwrap(); - - let entry = ContentEntry { - file_stem: "mymod".to_string(), - name: "mymod".to_string(), - description: String::new(), - enabled: true, - icon_bytes: None, - path: enabled_path.clone(), - icon_lines: None, - }; - - toggle_entry(&entry).unwrap(); - assert!(!enabled_path.exists()); - assert!(dir.join("mymod.jar.disabled").exists()); - } -} +#[path = "../tests/content/mods.rs"] +mod tests; diff --git a/src/instance/content/packs.rs b/src/instance/content/packs.rs new file mode 100644 index 0000000..12a7de4 --- /dev/null +++ b/src/instance/content/packs.rs @@ -0,0 +1,126 @@ +// shared pack.mcmeta scanning for resource packs and shader packs. + +use std::path::Path; + +use serde::Deserialize; + +use super::entry::ContentEntry; +use super::{fallback_icon, make_icon_pixels}; + +#[derive(Deserialize, Default)] +struct PackMcMeta { + #[serde(default)] + pack: PackInfo, +} + +#[derive(Deserialize, Default)] +struct PackInfo { + #[serde(default)] + description: serde_json::Value, +} + +pub(crate) fn extract_description(value: &serde_json::Value) -> String { + match value { + serde_json::Value::String(text) => text.clone(), + serde_json::Value::Object(object) => object + .get("text") + .and_then(|value| value.as_str()) + .unwrap_or("") + .to_owned(), + serde_json::Value::Array(values) => values + .iter() + .filter_map(|value| match value { + serde_json::Value::String(text) => Some(text.as_str()), + serde_json::Value::Object(object) => { + object.get("text").and_then(|value| value.as_str()) + } + _ => None, + }) + .collect::>() + .join(""), + _ => String::new(), + } +} + +pub(crate) fn scan_one_pack(path: &Path, file_stem: &str, enabled: bool) -> ContentEntry { + let (description, icon_bytes) = if path.is_dir() { + read_metadata_from_dir(path) + } else { + read_metadata_from_zip(path) + }; + let icon_lines = icon_bytes + .as_ref() + .and_then(|bytes| make_icon_pixels(bytes, 6, 3)) + .or_else(|| Some(fallback_icon())); + + ContentEntry { + file_stem: file_stem.to_owned(), + name: file_stem.to_owned(), + source_slug: None, + installed_path: None, + provider_project: None, + world_details: None, + title_suffix: None, + footer_label: None, + footer_change: None, + description, + enabled, + icon_bytes, + provider_icon: false, + provider_description: false, + path: path.to_path_buf(), + icon_lines, + } +} + +pub(crate) fn scan_packs( + instances_dir: &Path, + instance_name: &str, + directory: &str, +) -> Vec { + let directory = instances_dir + .join(instance_name) + .join(crate::storage::MINECRAFT_DIR_NAME) + .join(directory); + let Ok(entries) = std::fs::read_dir(directory) else { + return Vec::new(); + }; + let mut packs = entries + .flatten() + .filter_map(|entry| { + let path = entry.path(); + let file_name = path.file_name()?.to_str()?; + let (enabled, file_stem) = if path.is_dir() { + super::parse_enabled_stem_dir(file_name) + } else { + super::parse_enabled_stem(file_name, ".zip")? + }; + Some(scan_one_pack(&path, &file_stem, enabled)) + }) + .collect::>(); + packs.sort_by_cached_key(|entry| entry.name.to_lowercase()); + packs +} + +fn read_metadata_from_zip(path: &Path) -> (String, Option>) { + let Some(mut archive) = super::open_zip(path) else { + return (String::new(), None); + }; + let description = archive + .by_name("pack.mcmeta") + .ok() + .and_then(|entry| serde_json::from_reader::<_, PackMcMeta>(entry).ok()) + .map(|meta| extract_description(&meta.pack.description)) + .unwrap_or_default(); + let icon_bytes = super::read_icon_from_zip(&mut archive); + (description, icon_bytes) +} + +fn read_metadata_from_dir(path: &Path) -> (String, Option>) { + let description = std::fs::read_to_string(path.join("pack.mcmeta")) + .ok() + .and_then(|content| serde_json::from_str::(&content).ok()) + .map(|meta| extract_description(&meta.pack.description)) + .unwrap_or_default(); + (description, std::fs::read(path.join("pack.png")).ok()) +} diff --git a/src/instance/content/provider.rs b/src/instance/content/provider.rs new file mode 100644 index 0000000..8fc72ee --- /dev/null +++ b/src/instance/content/provider.rs @@ -0,0 +1,425 @@ +use std::collections::HashMap; + +use async_trait::async_trait; + +use crate::instance::content::manifest::{ContentKind, FileFingerprint, ProviderProject}; +use crate::instance::{InstanceConfig, ModLoader}; +use crate::net::modrinth::{DiscoveryResults, VersionInfo}; + +#[derive(Debug, Clone)] +pub struct FingerprintQuery { + pub key: String, + pub kind: ContentKind, + pub fingerprint: FileFingerprint, +} + +#[derive(Debug, Clone)] +pub struct ResolvedFile { + pub key: String, + pub project: ProviderProject, +} + +#[async_trait] +pub trait ContentProvider: Send + Sync { + fn id(&self) -> &'static str; + + async fn search( + &self, + kind: ContentKind, + query: &str, + instance: &InstanceConfig, + offset: usize, + limit: usize, + ) -> Result; + + async fn search_modpacks( + &self, + query: &str, + offset: usize, + limit: usize, + ) -> Result; + + async fn resolve_files( + &self, + files: &[FingerprintQuery], + ) -> Result, crate::net::NetError>; + + async fn project( + &self, + project_id: &str, + ) -> Result; + + async fn compatible_versions( + &self, + project_id: &str, + kind: ContentKind, + game_version: &str, + loader: ModLoader, + ) -> Result, crate::net::NetError>; + + async fn version(&self, version_id: &str) -> Result; + + async fn icon(&self, url: &str) -> Result, crate::net::NetError>; + + async fn download_version( + &self, + version: &VersionInfo, + destination: &std::path::Path, + installed_path: Option<&std::path::Path>, + ) -> Result; +} + +#[derive(Clone)] +pub struct ModrinthProvider { + client: crate::net::HttpClient, +} + +impl ModrinthProvider { + pub fn new(client: crate::net::HttpClient) -> Self { + Self { client } + } +} + +#[async_trait] +impl ContentProvider for ModrinthProvider { + fn id(&self) -> &'static str { + "modrinth" + } + + async fn search( + &self, + kind: ContentKind, + query: &str, + instance: &InstanceConfig, + offset: usize, + limit: usize, + ) -> Result { + crate::net::modrinth::search_discovery( + &self.client, + kind, + query, + &instance.game_version, + instance.loader, + offset, + limit, + ) + .await + } + + async fn search_modpacks( + &self, + query: &str, + offset: usize, + limit: usize, + ) -> Result { + crate::net::modrinth::search_modpacks(&self.client, query, offset, limit).await + } + + async fn resolve_files( + &self, + files: &[FingerprintQuery], + ) -> Result, crate::net::NetError> { + let mut by_hash = HashMap::>::new(); + for file in files { + if let Some(hash) = file.fingerprint.hash("sha512") { + by_hash + .entry(hash.to_owned()) + .or_default() + .push(file.key.clone()); + } + } + let hashes = by_hash.keys().cloned().collect::>(); + let versions = + crate::net::modrinth::resolve_version_files(&self.client, &hashes, "sha512").await?; + Ok(versions + .into_iter() + .flat_map(|(hash, version)| { + let keys = by_hash.get(&hash).cloned().unwrap_or_default(); + if version.project_id.is_empty() { + return Vec::new(); + } + keys.into_iter() + .map(|key| ResolvedFile { + key, + project: ProviderProject { + provider: self.id().to_owned(), + project_id: version.project_id.clone(), + version_id: version.id.clone(), + }, + }) + .collect() + }) + .collect()) + } + + async fn project( + &self, + project_id: &str, + ) -> Result { + crate::net::modrinth::fetch_project(&self.client, project_id).await + } + + async fn compatible_versions( + &self, + project_id: &str, + kind: ContentKind, + game_version: &str, + loader: ModLoader, + ) -> Result, crate::net::NetError> { + crate::net::modrinth::fetch_content_versions( + &self.client, + project_id, + kind, + game_version, + loader, + ) + .await + } + + async fn version(&self, version_id: &str) -> Result { + crate::net::modrinth::fetch_version(&self.client, version_id).await + } + + async fn icon(&self, url: &str) -> Result, crate::net::NetError> { + self.client + .get_bytes_limited(url, crate::net::MAX_PROVIDER_ASSET_BYTES) + .await + } + + async fn download_version( + &self, + version: &VersionInfo, + destination: &std::path::Path, + installed_path: Option<&std::path::Path>, + ) -> Result { + if let Some(installed_path) = installed_path { + crate::net::modrinth::download_version_file_for_update( + &self.client, + version, + destination, + installed_path, + ) + .await + } else { + crate::net::modrinth::download_version_file(&self.client, version, destination).await + } + } +} + +#[derive(Clone)] +pub struct CurseForgeProvider { + client: crate::net::HttpClient, + api_key: String, +} + +impl CurseForgeProvider { + pub fn new(client: crate::net::HttpClient, api_key: impl Into) -> Self { + Self { + client, + api_key: api_key.into(), + } + } +} + +#[async_trait] +impl ContentProvider for CurseForgeProvider { + fn id(&self) -> &'static str { + "curseforge" + } + + async fn search( + &self, + kind: ContentKind, + query: &str, + instance: &InstanceConfig, + offset: usize, + limit: usize, + ) -> Result { + crate::net::curseforge::search_discovery( + &self.client, + &self.api_key, + kind, + query, + &instance.game_version, + instance.loader, + offset, + limit, + ) + .await + } + + async fn search_modpacks( + &self, + query: &str, + offset: usize, + limit: usize, + ) -> Result { + crate::net::curseforge::search_modpacks(&self.client, &self.api_key, query, offset, limit) + .await + } + + async fn resolve_files( + &self, + files: &[FingerprintQuery], + ) -> Result, crate::net::NetError> { + let mut by_fingerprint = HashMap::>::new(); + for file in files { + if let Some(fingerprint) = file + .fingerprint + .hash("curseforge") + .and_then(|value| value.parse().ok()) + { + by_fingerprint + .entry(fingerprint) + .or_default() + .push(file.key.clone()); + } + } + let fingerprints = by_fingerprint.keys().copied().collect::>(); + let matches = crate::net::curseforge::resolve_fingerprints( + &self.client, + &self.api_key, + &fingerprints, + ) + .await?; + Ok(matches + .into_iter() + .flat_map(|(fingerprint, project_id, version_id)| { + by_fingerprint + .get(&fingerprint) + .cloned() + .unwrap_or_default() + .into_iter() + .map(move |key| ResolvedFile { + key, + project: ProviderProject { + provider: self.id().to_owned(), + project_id: project_id.clone(), + version_id: version_id.clone(), + }, + }) + }) + .collect()) + } + + async fn project( + &self, + project_id: &str, + ) -> Result { + crate::net::curseforge::fetch_project(&self.client, &self.api_key, project_id).await + } + + async fn compatible_versions( + &self, + project_id: &str, + kind: ContentKind, + game_version: &str, + loader: ModLoader, + ) -> Result, crate::net::NetError> { + crate::net::curseforge::fetch_versions( + &self.client, + &self.api_key, + project_id, + game_version, + (kind == ContentKind::Mod).then_some(loader), + ) + .await + } + + async fn version(&self, version_id: &str) -> Result { + let id = version_id.parse::().map_err(|_| { + crate::net::NetError::Parse(format!("Invalid CurseForge file id '{version_id}'")) + })?; + crate::net::curseforge::fetch_file_versions(&self.client, &self.api_key, &[id]) + .await? + .into_iter() + .next() + .ok_or_else(|| { + crate::net::NetError::Parse(format!("CurseForge file '{version_id}' was not found")) + }) + } + + async fn icon(&self, url: &str) -> Result, crate::net::NetError> { + self.client + .get_bytes_limited(url, crate::net::MAX_PROVIDER_ASSET_BYTES) + .await + } + + async fn download_version( + &self, + version: &VersionInfo, + destination: &std::path::Path, + installed_path: Option<&std::path::Path>, + ) -> Result { + let mut version = version.clone(); + crate::net::curseforge::ensure_download_url(&self.client, &self.api_key, &mut version) + .await?; + if let Some(installed_path) = installed_path { + crate::net::modrinth::download_version_file_for_update( + &self.client, + &version, + destination, + installed_path, + ) + .await + } else { + crate::net::modrinth::download_version_file(&self.client, &version, destination).await + } + } +} + +pub struct ProviderRegistry { + providers: Vec>, +} + +pub(crate) fn has_newer_compatible_version( + versions: &[VersionInfo], + installed_version_id: &str, +) -> bool { + versions + .iter() + .position(|version| version.id == installed_version_id) + .is_some_and(|position| position > 0) +} + +impl ProviderRegistry { + pub(crate) fn new(providers: Vec>) -> Self { + Self { providers } + } + + pub fn modrinth(client: crate::net::HttpClient) -> Self { + Self::new(vec![Box::new(ModrinthProvider::new(client))]) + } + + pub fn configured(client: crate::net::HttpClient) -> Self { + let mut providers: Vec> = + vec![Box::new(ModrinthProvider::new(client.clone()))]; + if let Some(api_key) = crate::net::curseforge::api_key() { + providers.push(Box::new(CurseForgeProvider::new(client, api_key))); + } + Self::new(providers) + } + + pub fn providers(&self) -> &[Box] { + &self.providers + } + + pub fn preferred(&self, id: &str) -> Option<&dyn ContentProvider> { + self.providers + .iter() + .find(|provider| provider.id() == id) + .map(Box::as_ref) + .or_else(|| self.providers.first().map(Box::as_ref)) + } + + pub fn get(&self, id: &str) -> Option<&dyn ContentProvider> { + self.providers + .iter() + .find(|provider| provider.id() == id) + .map(Box::as_ref) + } +} + +#[cfg(test)] +#[path = "../tests/content/provider.rs"] +mod tests; diff --git a/src/instance/content/reconcile.rs b/src/instance/content/reconcile.rs new file mode 100644 index 0000000..c71832f --- /dev/null +++ b/src/instance/content/reconcile.rs @@ -0,0 +1,673 @@ +use std::collections::{HashMap, HashSet, VecDeque}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, LazyLock, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::feedback::progress::{ProgressTask, ProgressTaskHandle}; +use crate::instance::InstanceConfig; +use crate::instance::content::manifest::{ + ContentFileRecord, ContentKind, ContentManifest, FileFingerprint, ProviderProject, Resolution, + fingerprint, fingerprint_metadata, +}; +use crate::instance::content::provider::{FingerprintQuery, ProviderRegistry}; +use crate::storage::InstancePaths; + +#[derive(Debug)] +pub struct ReconcileResult { + pub instance_name: String, + pub instance_created: chrono::DateTime, + pub manifest: ContentManifest, + pub error: Option, +} + +pub static PENDING_RECONCILIATIONS: LazyLock>>> = + LazyLock::new(|| Arc::new(Mutex::new(Vec::new()))); + +#[derive(Clone)] +struct ReconcileJob { + instance: InstanceConfig, + instances_dir: PathBuf, + client: crate::net::HttpClient, +} + +#[derive(Default)] +struct ReconcileCoordinator { + queue: VecDeque, + scheduled: HashSet<(String, chrono::DateTime)>, + rerun: HashSet<(String, chrono::DateTime)>, + worker_running: bool, +} + +impl ReconcileCoordinator { + fn enqueue(&mut self, job: ReconcileJob, rerun_if_scheduled: bool) -> bool { + let instance = (job.instance.name.clone(), job.instance.created); + if !self.scheduled.insert(instance.clone()) { + if rerun_if_scheduled { + self.rerun.insert(instance); + } + return false; + } + self.queue.push_back(job); + if self.worker_running { + false + } else { + self.worker_running = true; + true + } + } +} + +static RECONCILE_COORDINATOR: LazyLock> = + LazyLock::new(|| Mutex::new(ReconcileCoordinator::default())); + +pub fn spawn(instance: InstanceConfig, instances_dir: PathBuf, client: crate::net::HttpClient) { + schedule(instance, instances_dir, client, false); +} + +pub fn spawn_after_change( + instance: InstanceConfig, + instances_dir: PathBuf, + client: crate::net::HttpClient, +) { + schedule(instance, instances_dir, client, true); +} + +fn schedule( + instance: InstanceConfig, + instances_dir: PathBuf, + client: crate::net::HttpClient, + rerun_if_scheduled: bool, +) { + let start_worker = { + let Ok(mut coordinator) = RECONCILE_COORDINATOR.lock() else { + return; + }; + coordinator.enqueue( + ReconcileJob { + instance, + instances_dir, + client, + }, + rerun_if_scheduled, + ) + }; + if start_worker { + tokio::spawn(reconcile_worker()); + } +} + +async fn reconcile_worker() { + let task = ProgressTask::start("Preparing content index"); + loop { + let job = { + let Ok(mut coordinator) = RECONCILE_COORDINATOR.lock() else { + task.finish(); + return; + }; + match coordinator.queue.pop_front() { + Some(job) => job, + None => { + coordinator.worker_running = false; + task.finish(); + return; + } + } + }; + let instance = (job.instance.name.clone(), job.instance.created); + let rerun_job = job.clone(); + let result = reconcile(job, &task).await; + if let Ok(mut results) = PENDING_RECONCILIATIONS.lock() { + results.retain(|pending| { + (pending.instance_name.as_str(), pending.instance_created) + != (instance.0.as_str(), instance.1) + }); + results.push(result); + } + if let Ok(mut coordinator) = RECONCILE_COORDINATOR.lock() { + if coordinator.rerun.remove(&instance) { + coordinator.queue.push_back(rerun_job); + } else { + coordinator.scheduled.remove(&instance); + } + } + crate::feedback::request_redraw(); + } +} + +async fn reconcile(job: ReconcileJob, task: &ProgressTask) -> ReconcileResult { + let ReconcileJob { + instance, + instances_dir, + client, + } = job; + let instance_name = instance.name; + let instance_created = instance.created; + task.set_action(format!("Checking content for {instance_name}")); + task.set_sub_action("Reading saved content index"); + task.set_progress(0, 1); + let paths = InstancePaths::new(instances_dir.join(&instance_name)); + let manifest_path = paths.content_manifest(); + let minecraft_dir = paths.minecraft(); + let retry_hours = crate::config::SETTINGS.content.unmatched_retry_hours; + let max_fingerprint_size_mib = crate::config::SETTINGS.content.max_fingerprint_size_mib; + let inventory_progress = task.handle(); + let inventory_minecraft_dir = minecraft_dir.clone(); + let inventory = tokio::task::spawn_blocking(move || { + reconcile_inventory( + &manifest_path, + &inventory_minecraft_dir, + retry_hours, + max_fingerprint_size_mib, + &inventory_progress, + ) + .map(|result| (result, manifest_path)) + }) + .await; + + match inventory { + Ok(Ok((mut inventory, manifest_path))) => { + let registry = ProviderRegistry::configured(client); + task.set_action(format!("Identifying content for {instance_name}")); + task.set_sub_action(format!("{} file(s) need matching", inventory.queries.len())); + if !inventory.queries.is_empty() { + task.set_progress(0, inventory.queries.len() as u64); + } + let resolution_result = + resolve_queries(®istry, &inventory.queries, &mut inventory.manifest).await; + if !inventory.queries.is_empty() { + task.set_progress( + inventory.queries.len() as u64, + inventory.queries.len() as u64, + ); + } + match resolution_result { + Ok(()) => match save_reconciled_manifest( + &manifest_path, + &minecraft_dir, + inventory.manifest, + ) { + Ok(manifest) => ReconcileResult { + instance_name, + instance_created, + manifest, + error: None, + }, + Err(error) => ReconcileResult { + instance_name, + instance_created, + manifest: ContentManifest::default(), + error: Some(error.to_string()), + }, + }, + Err(error) => { + let saved = save_reconciled_manifest( + &manifest_path, + &minecraft_dir, + inventory.manifest, + ); + ReconcileResult { + instance_name, + instance_created, + manifest: saved.unwrap_or_default(), + error: Some(error.to_string()), + } + } + } + } + Ok(Err(error)) => ReconcileResult { + instance_name, + instance_created, + manifest: ContentManifest::default(), + error: Some(error.to_string()), + }, + Err(error) => ReconcileResult { + instance_name, + instance_created, + manifest: ContentManifest::default(), + error: Some(error.to_string()), + }, + } +} + +fn save_reconciled_manifest( + manifest_path: &Path, + minecraft_dir: &Path, + reconciled: ContentManifest, +) -> Result { + let reconciled_paths = reconciled + .files + .iter() + .map(|record| record.relative_path.clone()) + .collect::>(); + ContentManifest::update(manifest_path, |current| { + current.files.retain(|record| { + reconciled_paths.contains(&record.relative_path) + || minecraft_dir.join(&record.relative_path).exists() + }); + for record in reconciled.files { + let keep_resolved = current + .record(&record.relative_path) + .is_some_and(|existing| { + existing.fingerprint == record.fingerprint + && existing.resolved_project().is_some_and(|current| { + record.resolved_project().is_none_or(|resolved| { + current.provider != resolved.provider + || current.project_id != resolved.project_id + }) + }) + }); + if !keep_resolved { + current.upsert(record); + } + } + Ok(current.clone()) + }) +} + +struct Inventory { + manifest: ContentManifest, + queries: Vec, +} + +trait InventoryProgress { + fn set_sub_action(&self, text: &str); + fn set_progress(&self, current: u64, total: u64); +} + +impl InventoryProgress for ProgressTaskHandle { + fn set_sub_action(&self, text: &str) { + ProgressTaskHandle::set_sub_action(self, text); + } + + fn set_progress(&self, current: u64, total: u64) { + ProgressTaskHandle::set_progress(self, current, total); + } +} + +fn reconcile_inventory( + manifest_path: &Path, + minecraft_dir: &Path, + unmatched_retry_hours: u64, + max_fingerprint_size_mib: u64, + task: &impl InventoryProgress, +) -> Result> { + let previous = ContentManifest::load(manifest_path)?; + let files = content_files(minecraft_dir)?; + let file_count = files.len() as u64; + if file_count == 0 { + task.set_sub_action("No local content files"); + task.set_progress(1, 1); + } else { + task.set_progress(0, file_count); + } + let now = chrono::Utc::now().timestamp(); + let retry_seconds = unmatched_retry_hours.saturating_mul(60 * 60) as i64; + let mut manifest = ContentManifest::default(); + let mut queries = Vec::new(); + + for (index, (kind, path)) in files.into_iter().enumerate() { + task.set_sub_action( + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("content"), + ); + let relative_path = path.strip_prefix(minecraft_dir)?.to_path_buf(); + let enabled = !path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(".disabled")); + let metadata = std::fs::metadata(&path)?; + let is_directory = metadata.is_dir(); + let metadata_fingerprint = if is_directory { + directory_fingerprint_metadata(&path)? + } else { + fingerprint_metadata(&path)? + }; + let modified_ns = metadata_fingerprint.modified_ns; + let existing = previous.record(&relative_path); + let unchanged = existing.is_some_and(|record| { + record.fingerprint.size == metadata_fingerprint.size + && record.fingerprint.modified_ns == modified_ns + }); + let oversized = !is_directory + && max_fingerprint_size_mib > 0 + && metadata.len() > max_fingerprint_size_mib.saturating_mul(1024 * 1024); + let mut record = if unchanged { + existing.cloned().unwrap() + } else if is_directory { + ContentFileRecord { + relative_path: relative_path.clone(), + kind, + enabled, + fingerprint: metadata_fingerprint, + resolution: Resolution::Unmatched { + checked_at: now, + providers: Vec::new(), + }, + provider_aliases: Vec::new(), + provider_checks: Vec::new(), + required_dependencies: Vec::new(), + automatic_dependency: false, + cleanup_eligible: false, + } + } else if oversized { + tracing::debug!( + "Skipping automatic provider matching for {} ({} bytes exceeds {} MiB limit)", + path.display(), + metadata.len(), + max_fingerprint_size_mib + ); + ContentFileRecord { + relative_path: relative_path.clone(), + kind, + enabled, + fingerprint: fingerprint_metadata(&path)?, + resolution: Resolution::Unmatched { + checked_at: now, + providers: Vec::new(), + }, + provider_aliases: Vec::new(), + provider_checks: Vec::new(), + required_dependencies: Vec::new(), + automatic_dependency: false, + cleanup_eligible: false, + } + } else { + ContentFileRecord { + relative_path: relative_path.clone(), + kind, + enabled, + fingerprint: fingerprint(&path)?, + resolution: Resolution::Pending, + provider_aliases: Vec::new(), + provider_checks: Vec::new(), + required_dependencies: Vec::new(), + automatic_dependency: false, + cleanup_eligible: false, + } + }; + record.kind = kind; + record.enabled = enabled; + + if oversized && matches!(record.resolution, Resolution::Pending) { + record.resolution = Resolution::Unmatched { + checked_at: now, + providers: Vec::new(), + }; + } + let provider_unchecked = ["modrinth", "curseforge"].into_iter().any(|provider| { + crate::config::SETTINGS + .content + .discovery_provider_enabled(provider) + && provider_was_not_checked(&record, provider) + }); + if !is_directory + && !oversized + && provider_unchecked + && record.fingerprint.hash("curseforge").is_none() + { + record.fingerprint = fingerprint(&path)?; + } + let should_query = !is_directory + && !oversized + && (provider_unchecked + || match &record.resolution { + Resolution::Pending => true, + Resolution::Unmatched { checked_at, .. } => { + now.saturating_sub(*checked_at) >= retry_seconds + } + Resolution::Resolved { .. } | Resolution::Ambiguous { .. } => false, + }); + if should_query { + if !matches!(&record.resolution, Resolution::Resolved { .. }) { + record.resolution = Resolution::Pending; + } + queries.push(FingerprintQuery { + key: relative_path.to_string_lossy().into_owned(), + kind, + fingerprint: record.fingerprint.clone(), + }); + } + manifest.upsert(record); + task.set_progress(index as u64 + 1, file_count); + } + Ok(Inventory { manifest, queries }) +} + +fn provider_was_not_checked(record: &ContentFileRecord, provider: &str) -> bool { + let identified = record + .resolved_project() + .into_iter() + .chain(record.provider_aliases.iter()) + .any(|project| project.provider == provider); + let checked_as_unmatched = matches!( + &record.resolution, + Resolution::Unmatched { providers, .. } + if providers.iter().any(|checked| checked == provider) + ); + !identified + && !checked_as_unmatched + && !record + .provider_checks + .iter() + .any(|checked| checked == provider) +} + +async fn resolve_queries( + registry: &ProviderRegistry, + queries: &[FingerprintQuery], + manifest: &mut ContentManifest, +) -> Result<(), crate::net::NetError> { + if queries.is_empty() { + return Ok(()); + } + let mut matches: HashMap> = HashMap::new(); + let mut checked = Vec::new(); + let mut last_error = None; + for provider in registry.providers() { + let provider_id = provider.id(); + match tokio::time::timeout( + std::time::Duration::from_secs(10), + provider.resolve_files(queries), + ) + .await + { + Ok(Ok(resolved_files)) => { + checked.push(provider_id.to_owned()); + for resolved in resolved_files { + matches + .entry(resolved.key) + .or_default() + .push(resolved.project); + } + } + Ok(Err(error)) => { + tracing::warn!( + "Skipping {provider_id} content matching after provider error: {error}" + ); + last_error = Some(error.to_string()); + } + Err(_) => { + tracing::warn!( + "Skipping {provider_id} content matching after the 10 second timeout" + ); + last_error = Some(format!("{provider_id} content matching timed out")); + } + } + } + if checked.is_empty() { + return Err(crate::net::NetError::TaskFailed(last_error.unwrap_or_else( + || "No content providers are available".to_owned(), + ))); + } + let query_keys = queries + .iter() + .map(|query| query.key.as_str()) + .collect::>(); + let checked_at = chrono::Utc::now().timestamp(); + for record in &mut manifest.files { + let key = record.relative_path.to_string_lossy(); + if !query_keys.contains(key.as_ref()) { + continue; + } + let installed = record.resolved_project().cloned(); + let mut candidates = matches.remove(key.as_ref()).unwrap_or_default(); + for alias in &record.provider_aliases { + if !candidates.iter().any(|candidate| { + candidate.provider == alias.provider && candidate.project_id == alias.project_id + }) { + candidates.push(alias.clone()); + } + } + let (resolution, aliases) = if let Some(installed) = installed { + let project = candidates + .iter() + .find(|candidate| { + candidate.provider == installed.provider + && candidate.project_id == installed.project_id + }) + .cloned() + .unwrap_or(installed); + candidates.retain(|candidate| { + candidate.provider != project.provider || candidate.project_id != project.project_id + }); + (Resolution::Resolved { project }, candidates) + } else { + match candidates.as_slice() { + [] => ( + Resolution::Unmatched { + checked_at, + providers: checked.clone(), + }, + Vec::new(), + ), + [project] => ( + Resolution::Resolved { + project: project.clone(), + }, + Vec::new(), + ), + _ if !crate::config::SETTINGS.content.ask_on_provider_conflict => { + let preferred = crate::config::SETTINGS.content.preferred_provider(); + if let Some(project) = candidates + .iter() + .find(|project| project.provider == preferred) + { + ( + Resolution::Resolved { + project: project.clone(), + }, + candidates + .iter() + .filter(|candidate| *candidate != project) + .cloned() + .collect(), + ) + } else { + (Resolution::Ambiguous { candidates }, Vec::new()) + } + } + _ => (Resolution::Ambiguous { candidates }, Vec::new()), + } + }; + record.resolution = resolution; + record.provider_aliases = aliases; + for provider in &checked { + if !record.provider_checks.contains(provider) { + record.provider_checks.push(provider.clone()); + } + } + } + Ok(()) +} + +fn content_files(minecraft_dir: &Path) -> std::io::Result> { + let mut files = Vec::new(); + for kind in [ + ContentKind::Mod, + ContentKind::ResourcePack, + ContentKind::Shader, + ] { + let directory = minecraft_dir.join(kind.directory()); + let Ok(entries) = std::fs::read_dir(directory) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if supported_content_path(kind, &path) { + files.push((kind, path)); + } + } + } + if let Ok(worlds) = std::fs::read_dir(minecraft_dir.join("saves")) { + for world in worlds.flatten().filter(|entry| entry.path().is_dir()) { + let Ok(entries) = std::fs::read_dir(world.path().join("datapacks")) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if supported_content_path(ContentKind::DataPack, &path) { + files.push((ContentKind::DataPack, path)); + } + } + } + } + files.sort_by(|left, right| left.1.cmp(&right.1)); + Ok(files) +} + +fn supported_content_path(kind: ContentKind, path: &Path) -> bool { + if path.is_dir() { + return matches!( + kind, + ContentKind::ResourcePack | ContentKind::Shader | ContentKind::DataPack + ); + } + if !path.is_file() { + return false; + } + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + return false; + }; + match kind { + ContentKind::Mod => name.ends_with(".jar") || name.ends_with(".jar.disabled"), + ContentKind::ResourcePack | ContentKind::Shader | ContentKind::DataPack => { + name.ends_with(".zip") || name.ends_with(".zip.disabled") + } + } +} + +fn directory_fingerprint_metadata(path: &Path) -> Result { + fn accumulate(path: &Path, size: &mut u64, modified_ns: &mut u128) -> std::io::Result<()> { + for entry in std::fs::read_dir(path)? { + let entry = entry?; + let metadata = entry.metadata()?; + let modified = metadata + .modified() + .unwrap_or(SystemTime::UNIX_EPOCH) + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + *modified_ns = (*modified_ns).max(modified); + if metadata.is_dir() { + accumulate(&entry.path(), size, modified_ns)?; + } else if metadata.is_file() { + *size = size.saturating_add(metadata.len()); + } + } + Ok(()) + } + + let mut size = 0; + let mut modified_ns = 0; + accumulate(path, &mut size, &mut modified_ns)?; + Ok(FileFingerprint { + size, + modified_ns, + hashes: Default::default(), + }) +} + +#[cfg(test)] +#[path = "../tests/content/reconcile.rs"] +mod tests; diff --git a/src/instance/content/resource_packs.rs b/src/instance/content/resource_packs.rs index a308426..4818571 100644 --- a/src/instance/content/resource_packs.rs +++ b/src/instance/content/resource_packs.rs @@ -1,164 +1,17 @@ -// resource pack scanning. packs can be either .zip files or plain directories, -// and metadata lives in pack.mcmeta (which mojang decided should have like 3 -// different ways to encode a description, because why not) +// resource pack scanner backed by the shared pack.mcmeta reader. use std::path::Path; -use serde::Deserialize; - -use super::mods::{ContentEntry, make_icon_pixels}; - -#[derive(Deserialize, Default)] -pub(crate) struct PackMcMeta { - #[serde(default)] - pub pack: PackInfo, -} - -#[derive(Deserialize, Default)] -pub(crate) struct PackInfo { - #[serde(default)] - pub description: serde_json::Value, -} - -// description can be a plain string, a chat component object with "text", -// or an array mixing both. thanks mojang, very cool. -pub(crate) fn extract_description(value: &serde_json::Value) -> String { - match value { - serde_json::Value::String(s) => s.clone(), - serde_json::Value::Object(obj) => obj - .get("text") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(), - serde_json::Value::Array(arr) => arr - .iter() - .filter_map(|v| match v { - serde_json::Value::String(s) => Some(s.as_str()), - serde_json::Value::Object(obj) => obj.get("text").and_then(|v| v.as_str()), - _ => None, - }) - .collect::>() - .join(""), - _ => String::new(), - } -} +use super::entry::ContentEntry; pub fn scan_one_resource_pack(path: &Path, file_stem: &str, enabled: bool) -> ContentEntry { - let is_dir = path.is_dir(); - let (name, description, icon_bytes) = if is_dir { - read_pack_metadata_from_dir(path) - } else { - read_pack_metadata_from_zip(path) - }; - - let icon_lines = icon_bytes - .as_ref() - .and_then(|bytes| make_icon_pixels(bytes, 6, 3)) - .or_else(|| Some(super::mods::fallback_icon())); - - let display_name = if name.is_empty() { - file_stem.to_owned() - } else { - name - }; - - ContentEntry { - file_stem: file_stem.to_owned(), - name: display_name, - description, - enabled, - icon_bytes, - path: path.to_path_buf(), - icon_lines, - } + super::packs::scan_one_pack(path, file_stem, enabled) } pub fn scan_resource_packs(instances_dir: &Path, instance_name: &str) -> Vec { - let packs_dir = instances_dir - .join(instance_name) - .join(".minecraft") - .join("resourcepacks"); - - let read_dir = match std::fs::read_dir(&packs_dir) { - Ok(rd) => rd, - Err(_) => return Vec::new(), - }; - - let mut entries = Vec::new(); - - for entry in read_dir.flatten() { - let path = entry.path(); - let file_name = match path.file_name().and_then(|n| n.to_str()) { - Some(n) => n.to_string(), - None => continue, - }; - - let (enabled, file_stem) = if path.is_dir() { - super::parse_enabled_stem_dir(&file_name) - } else if let Some(pair) = super::parse_enabled_stem(&file_name, ".zip") { - pair - } else { - continue; - }; - - entries.push(scan_one_resource_pack(&path, &file_stem, enabled)); - } - - entries.sort_by_cached_key(|e| e.name.to_lowercase()); - entries -} - -fn read_pack_metadata_from_zip(zip_path: &Path) -> (String, String, Option>) { - let Some(mut archive) = super::open_zip(zip_path) else { - return (String::new(), String::new(), None); - }; - let description = read_pack_description(&mut archive); - let icon_bytes = super::read_icon_from_zip(&mut archive); - (String::new(), description, icon_bytes) -} - -fn read_pack_description(archive: &mut zip::ZipArchive) -> String { - archive - .by_name("pack.mcmeta") - .ok() - .and_then(|entry| serde_json::from_reader::<_, PackMcMeta>(entry).ok()) - .map(|meta| extract_description(&meta.pack.description)) - .unwrap_or_default() -} - -fn read_pack_metadata_from_dir(dir: &Path) -> (String, String, Option>) { - let description = std::fs::read_to_string(dir.join("pack.mcmeta")) - .ok() - .and_then(|content| serde_json::from_str::(&content).ok()) - .map(|meta| extract_description(&meta.pack.description)) - .unwrap_or_default(); - - let icon_bytes = std::fs::read(dir.join("pack.png")).ok(); - - (String::new(), description, icon_bytes) + super::packs::scan_packs(instances_dir, instance_name, "resourcepacks") } #[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - // every case exercises a distinct match arm in extract_description. - // string + object{text} + object{no text} + array(string) + - // array(object) + array(mixed) + array(empty) + null + number + bool. - // mutating any arm to fall through to "" would fail at least one case. - #[rstest::rstest] - #[case::string(json!("Simple pack"), "Simple pack")] - #[case::object_with_text(json!({"text": "Hello world"}), "Hello world")] - #[case::object_without_text(json!({"color": "red"}), "")] - #[case::array_of_strings(json!(["Hello", " ", "world"]), "Hello world")] - #[case::array_of_objects(json!([{"text": "A"}, {"text": "B"}]), "AB")] - #[case::mixed_array(json!(["Prefix ", {"text": "suffix"}]), "Prefix suffix")] - #[case::empty_array(json!([]), "")] - #[case::null(serde_json::Value::Null, "")] - #[case::number(json!(42), "")] - #[case::bool(json!(true), "")] - fn extract_description_handles(#[case] input: serde_json::Value, #[case] expected: &str) { - assert_eq!(extract_description(&input), expected); - } -} +#[path = "../tests/content/resource_packs.rs"] +mod tests; diff --git a/src/instance/content/shaders.rs b/src/instance/content/shaders.rs index 0492085..0434d99 100644 --- a/src/instance/content/shaders.rs +++ b/src/instance/content/shaders.rs @@ -1,91 +1,13 @@ -// shader pack scanning. structurally almost identical to resource packs -// (zip or dir, pack.mcmeta for metadata, pack.png for icon) +// shader pack scanner backed by the shared pack.mcmeta reader. use std::path::Path; -use super::mods::{ContentEntry, make_icon_pixels}; -use super::resource_packs::{PackMcMeta, extract_description}; +use super::entry::ContentEntry; pub fn scan_one_shader(path: &Path, file_stem: &str, enabled: bool) -> ContentEntry { - let is_dir = path.is_dir(); - let (description, icon_bytes) = if is_dir { - read_shader_metadata_from_dir(path) - } else { - read_shader_metadata_from_zip(path) - }; - - let icon_lines = icon_bytes - .as_ref() - .and_then(|bytes| make_icon_pixels(bytes, 6, 3)); - - ContentEntry { - file_stem: file_stem.to_owned(), - name: file_stem.to_owned(), - description, - enabled, - icon_bytes, - path: path.to_path_buf(), - icon_lines, - } + super::packs::scan_one_pack(path, file_stem, enabled) } pub fn scan_shaders(instances_dir: &Path, instance_name: &str) -> Vec { - let shaders_dir = instances_dir - .join(instance_name) - .join(".minecraft") - .join("shaderpacks"); - - let read_dir = match std::fs::read_dir(&shaders_dir) { - Ok(rd) => rd, - Err(_) => return Vec::new(), - }; - - let mut entries = Vec::new(); - - for entry in read_dir.flatten() { - let path = entry.path(); - let file_name = match path.file_name().and_then(|n| n.to_str()) { - Some(n) => n.to_string(), - None => continue, - }; - - let (enabled, file_stem) = if path.is_dir() { - super::parse_enabled_stem_dir(&file_name) - } else if let Some(pair) = super::parse_enabled_stem(&file_name, ".zip") { - pair - } else { - continue; - }; - - entries.push(scan_one_shader(&path, &file_stem, enabled)); - } - - entries.sort_by_cached_key(|e| e.name.to_lowercase()); - entries -} - -fn read_shader_metadata_from_zip(zip_path: &Path) -> (String, Option>) { - let Some(mut archive) = super::open_zip(zip_path) else { - return (String::new(), None); - }; - let description = archive - .by_name("pack.mcmeta") - .ok() - .and_then(|entry| serde_json::from_reader::<_, PackMcMeta>(entry).ok()) - .map(|meta| extract_description(&meta.pack.description)) - .unwrap_or_default(); - let icon_bytes = super::read_icon_from_zip(&mut archive); - (description, icon_bytes) -} - -fn read_shader_metadata_from_dir(dir: &Path) -> (String, Option>) { - let description = std::fs::read_to_string(dir.join("pack.mcmeta")) - .ok() - .and_then(|content| serde_json::from_str::(&content).ok()) - .map(|meta| extract_description(&meta.pack.description)) - .unwrap_or_default(); - - let icon_bytes = std::fs::read(dir.join("pack.png")).ok(); - - (description, icon_bytes) + super::packs::scan_packs(instances_dir, instance_name, "shaderpacks") } diff --git a/src/instance/content/updates.rs b/src/instance/content/updates.rs new file mode 100644 index 0000000..ed3bcd0 --- /dev/null +++ b/src/instance/content/updates.rs @@ -0,0 +1,488 @@ +use std::sync::{Arc, LazyLock, Mutex}; + +use serde::{Deserialize, Serialize}; + +use crate::instance::{ContentKind, ContentManifest, InstanceConfig, ModLoader, ProviderProject}; +use crate::net::modrinth::VersionInfo; + +use super::dependencies::{DependencyPlan, InstallRoot}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AvailableUpdate { + pub installed: ProviderProject, + #[serde(default)] + pub current: Option, + pub target: VersionInfo, + pub kind: ContentKind, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateSnapshot { + pub game_version: String, + pub loader: ModLoader, + #[serde(default)] + pub inventory: Vec, + pub updates: Vec, + pub failures: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateCheckFailure { + pub installed: ProviderProject, + pub kind: ContentKind, + pub reason: String, +} + +pub struct PendingUpdateSnapshot { + pub instance_name: String, + pub instance_created: chrono::DateTime, + pub snapshot: UpdateSnapshot, +} + +#[derive(Debug, Clone)] +pub struct UpdateRequest { + pub title: String, + pub installed_path: std::path::PathBuf, + pub target_world: Option, + pub update: AvailableUpdate, +} + +#[derive(Debug, Clone)] +pub struct PlannedRootUpdate { + pub title: String, + pub installed_path: std::path::PathBuf, + pub current_version: String, + pub target: VersionInfo, +} + +#[derive(Debug, Clone)] +pub struct UpdateConflict { + pub title: String, + pub installed_path: std::path::PathBuf, + pub reason: String, +} + +#[derive(Debug, Clone)] +pub struct BulkUpdatePlan { + pub dependency_plan: DependencyPlan, + pub roots: Vec, + pub conflicts: Vec, +} + +pub static PENDING_UPDATE_SNAPSHOTS: LazyLock>>> = + LazyLock::new(|| Arc::new(Mutex::new(Vec::new()))); + +impl UpdateSnapshot { + pub fn load(path: &std::path::Path) -> Option { + std::fs::read(path) + .ok() + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + } + + pub fn applies_to(&self, instance: &InstanceConfig) -> bool { + self.game_version == instance.game_version && self.loader == instance.loader + } + + pub fn matches_manifest(&self, manifest: &ContentManifest) -> bool { + self.inventory == resolved_inventory(manifest) + } + + pub fn update_for(&self, installed: &ProviderProject) -> Option<&AvailableUpdate> { + self.updates.iter().find(|update| { + update.installed.provider == installed.provider + && update.installed.project_id == installed.project_id + && update.installed.version_id == installed.version_id + }) + } +} + +pub fn spawn(instance: InstanceConfig, manifest: ContentManifest, path: std::path::PathBuf) { + tokio::spawn(async move { + let snapshot = scan(&instance, &manifest).await; + if let Ok(bytes) = serde_json::to_vec_pretty(&snapshot) + && let Err(error) = crate::storage::write_atomic(&path, &bytes) + { + tracing::debug!("Could not cache content update snapshot: {error}"); + } + if let Ok(mut pending) = PENDING_UPDATE_SNAPSHOTS.lock() { + pending.push(PendingUpdateSnapshot { + instance_name: instance.name, + instance_created: instance.created, + snapshot, + }); + crate::feedback::request_redraw(); + } + }); +} + +pub async fn scan(instance: &InstanceConfig, manifest: &ContentManifest) -> UpdateSnapshot { + let mut projects = manifest + .files + .iter() + .filter_map(|record| { + record + .resolved_project() + .cloned() + .map(|project| (project, record.kind)) + }) + .collect::>(); + projects.sort_by(|left, right| { + (&left.0.provider, &left.0.project_id, &left.0.version_id).cmp(&( + &right.0.provider, + &right.0.project_id, + &right.0.version_id, + )) + }); + projects.dedup_by(|left, right| { + left.0.provider == right.0.provider + && left.0.project_id == right.0.project_id + && left.0.version_id == right.0.version_id + }); + let inventory = projects + .iter() + .map(|(project, _)| project.clone()) + .collect(); + + let slots = Arc::new(tokio::sync::Semaphore::new(8)); + let mut tasks = tokio::task::JoinSet::new(); + for (installed, kind) in projects { + let slots = slots.clone(); + let game_version = instance.game_version.clone(); + let loader = instance.loader; + tasks.spawn(async move { + let result = async { + let _permit = slots + .acquire_owned() + .await + .map_err(|error| error.to_string())?; + let registry = crate::instance::content::provider::ProviderRegistry::configured( + crate::net::HttpClient::new(), + ); + let provider = registry.get(&installed.provider).ok_or_else(|| { + format!("{} content provider is unavailable", installed.provider) + })?; + let versions = provider + .compatible_versions(&installed.project_id, kind, &game_version, loader) + .await + .map_err(|error| error.to_string())?; + let current = versions + .iter() + .find(|version| version.id == installed.version_id) + .cloned(); + let target = versions.first().cloned().filter(|_| { + crate::instance::content::provider::has_newer_compatible_version( + &versions, + &installed.version_id, + ) + }); + Ok::<_, String>(target.map(|target| (current, target))) + } + .await; + (installed, kind, result) + }); + } + + let mut updates = Vec::new(); + let mut failures = Vec::new(); + while let Some(result) = tasks.join_next().await { + match result { + Ok((installed, kind, Ok(Some((current, target))))) => updates.push(AvailableUpdate { + installed, + current, + target, + kind, + }), + Ok((_, _, Ok(None))) => {} + Ok((installed, kind, Err(reason))) => failures.push(UpdateCheckFailure { + installed, + kind, + reason, + }), + Err(error) => tracing::debug!("Content update task failed: {error}"), + } + } + updates.sort_by(|left, right| { + (&left.installed.provider, &left.installed.project_id) + .cmp(&(&right.installed.provider, &right.installed.project_id)) + }); + UpdateSnapshot { + game_version: instance.game_version.clone(), + loader: instance.loader, + inventory, + updates, + failures, + } +} + +fn resolved_inventory(manifest: &ContentManifest) -> Vec { + let mut inventory = manifest + .files + .iter() + .filter_map(|record| record.resolved_project().cloned()) + .collect::>(); + inventory.sort_by(|left, right| { + (&left.provider, &left.project_id, &left.version_id).cmp(&( + &right.provider, + &right.project_id, + &right.version_id, + )) + }); + inventory.dedup(); + inventory +} + +pub async fn plan_bulk( + instance: &InstanceConfig, + manifest: &ContentManifest, + minecraft_dir: &std::path::Path, + requests: Vec, + mut conflicts: Vec, +) -> BulkUpdatePlan { + let registry = crate::instance::content::provider::ProviderRegistry::configured( + crate::net::HttpClient::new(), + ); + let projected_manifest = project_updates(manifest, minecraft_dir, &requests); + let mut accepted = Vec::new(); + let mut roots = Vec::new(); + for request in requests { + let root = InstallRoot { + provider: request.update.installed.provider.clone(), + project_id: request.update.installed.project_id.clone(), + title: request.title.clone(), + version: request.update.target.clone(), + installed_path: Some(request.installed_path.clone()), + kind: request.update.kind, + target_world: request.target_world.clone(), + force_reinstall: false, + }; + let mut resolution_manifest = projected_manifest.clone(); + if let Ok(relative_path) = request.installed_path.strip_prefix(minecraft_dir) + && let Some(current) = manifest.record(relative_path) + && let Some(projected) = resolution_manifest + .files + .iter_mut() + .find(|record| record.relative_path == relative_path) + { + projected.resolution = current.resolution.clone(); + } + let plan = match super::dependencies::resolve( + ®istry, + &resolution_manifest, + minecraft_dir, + instance, + root, + ) + .await + { + Ok(plan) => plan, + Err(error) => { + conflicts.push(UpdateConflict { + title: request.title, + installed_path: request.installed_path, + reason: error.to_string(), + }); + continue; + } + }; + let mut proposed = accepted.clone(); + proposed.push(plan.clone()); + if let Err(error) = super::dependencies::merge(proposed) { + conflicts.push(UpdateConflict { + title: request.title, + installed_path: request.installed_path, + reason: error.to_string(), + }); + continue; + } + roots.push(PlannedRootUpdate { + title: request.title, + installed_path: request.installed_path, + current_version: request.update.current.as_ref().map_or_else( + || request.update.installed.version_id.clone(), + |version| version.version_number.clone(), + ), + target: request.update.target, + }); + accepted.push(plan); + } + BulkUpdatePlan { + dependency_plan: super::dependencies::merge(accepted).unwrap_or(DependencyPlan { + items: Vec::new(), + root_count: 0, + optional_dependencies: 0, + }), + roots, + conflicts, + } +} + +fn project_updates( + manifest: &ContentManifest, + minecraft_dir: &std::path::Path, + requests: &[UpdateRequest], +) -> ContentManifest { + let mut projected = manifest.clone(); + for request in requests { + let Ok(relative_path) = request.installed_path.strip_prefix(minecraft_dir) else { + continue; + }; + let Some(record) = projected + .files + .iter_mut() + .find(|record| record.relative_path == relative_path) + else { + continue; + }; + record.resolution = crate::instance::Resolution::Resolved { + project: ProviderProject { + provider: request.update.installed.provider.clone(), + project_id: request.update.installed.project_id.clone(), + version_id: request.update.target.id.clone(), + }, + }; + } + projected +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::net::modrinth::{VersionFile, VersionType}; + + fn version(id: &str) -> VersionInfo { + VersionInfo { + id: id.to_owned(), + project_id: "project".to_owned(), + name: id.to_owned(), + version_number: id.to_owned(), + game_versions: vec!["1.21.1".to_owned()], + loaders: vec!["fabric".to_owned()], + version_type: VersionType::Release, + dependencies: Vec::new(), + date_published: String::new(), + files: Vec::::new(), + } + } + + #[test] + fn cached_updates_match_the_exact_installed_version() { + let installed = ProviderProject { + provider: "modrinth".to_owned(), + project_id: "project".to_owned(), + version_id: "old".to_owned(), + }; + let snapshot = UpdateSnapshot { + game_version: "1.21.1".to_owned(), + loader: ModLoader::Fabric, + inventory: vec![installed.clone()], + updates: vec![AvailableUpdate { + installed: installed.clone(), + current: Some(version("old")), + target: version("new"), + kind: ContentKind::Mod, + }], + failures: Vec::new(), + }; + + assert!(snapshot.update_for(&installed).is_some()); + assert!( + snapshot + .update_for(&ProviderProject { + version_id: "different".to_owned(), + ..installed + }) + .is_none() + ); + } + + #[test] + fn cached_updates_require_the_same_content_inventory() { + let project = ProviderProject { + provider: "modrinth".to_owned(), + project_id: "project".to_owned(), + version_id: "old".to_owned(), + }; + let snapshot = UpdateSnapshot { + game_version: "1.21.1".to_owned(), + loader: ModLoader::Fabric, + inventory: vec![project.clone()], + updates: Vec::new(), + failures: Vec::new(), + }; + let mut manifest = ContentManifest::default(); + manifest.files.push(crate::instance::ContentFileRecord { + relative_path: "mods/example.jar".into(), + kind: ContentKind::Mod, + enabled: true, + fingerprint: crate::instance::FileFingerprint { + size: 0, + modified_ns: 0, + hashes: Default::default(), + }, + resolution: crate::instance::Resolution::Resolved { project }, + provider_aliases: Vec::new(), + provider_checks: vec!["modrinth".to_owned()], + required_dependencies: Vec::new(), + automatic_dependency: false, + cleanup_eligible: false, + }); + + assert!(snapshot.matches_manifest(&manifest)); + manifest.files[0].resolution = crate::instance::Resolution::Unmatched { + checked_at: 0, + providers: Vec::new(), + }; + assert!(!snapshot.matches_manifest(&manifest)); + } + + #[test] + fn bulk_planning_projects_other_selected_updates() { + let minecraft = std::path::Path::new("/instance/minecraft"); + let installed = ProviderProject { + provider: "modrinth".to_owned(), + project_id: "library".to_owned(), + version_id: "old".to_owned(), + }; + let mut manifest = ContentManifest::default(); + manifest.files.push(crate::instance::ContentFileRecord { + relative_path: "mods/library.jar".into(), + kind: ContentKind::Mod, + enabled: true, + fingerprint: crate::instance::FileFingerprint { + size: 1, + modified_ns: 1, + hashes: Default::default(), + }, + resolution: crate::instance::Resolution::Resolved { + project: installed.clone(), + }, + provider_aliases: Vec::new(), + provider_checks: vec!["modrinth".to_owned()], + required_dependencies: Vec::new(), + automatic_dependency: true, + cleanup_eligible: true, + }); + let requests = vec![UpdateRequest { + title: "Library".to_owned(), + installed_path: minecraft.join("mods/library.jar"), + target_world: None, + update: AvailableUpdate { + installed, + current: Some(version("old")), + target: version("new"), + kind: ContentKind::Mod, + }, + }]; + + let projected = project_updates(&manifest, minecraft, &requests); + + assert_eq!( + projected.files[0].resolved_project().unwrap().version_id, + "new" + ); + assert_eq!( + manifest.files[0].resolved_project().unwrap().version_id, + "old" + ); + } +} diff --git a/src/instance/content/worlds.rs b/src/instance/content/worlds.rs index eff69be..4097dbb 100644 --- a/src/instance/content/worlds.rs +++ b/src/instance/content/worlds.rs @@ -2,34 +2,88 @@ // their icon as icon.png. also computes an approximate size from top-level // files + region data so the user gets some sense of how chonky their world is. -use std::path::Path; +use std::{fs::File, path::Path}; -use super::mods::{ContentEntry, make_icon_pixels}; +use flate2::read::GzDecoder; +use serde::Deserialize; + +use super::entry::{ContentEntry, WorldDetails, WorldGameMode}; +use super::{fallback_icon_large, make_icon_pixels}; pub fn scan_one_world(path: &Path, file_stem: &str, enabled: bool) -> ContentEntry { let icon_bytes = std::fs::read(path.join("icon.png")).ok(); let icon_lines = icon_bytes .as_ref() .and_then(|bytes| make_icon_pixels(bytes, 12, 6)) - .or_else(|| Some(super::mods::fallback_icon_large())); - - let description = world_description(path); + .or_else(|| Some(fallback_icon_large())); + + let metadata = read_world_metadata(path); + let last_played = world_last_played(path, metadata.as_ref()); + let size = dir_size_approx(path); + let world_details = WorldDetails { + game_mode: metadata.as_ref().and_then(WorldMetadata::game_mode), + last_played, + minecraft_version: metadata + .as_ref() + .and_then(|metadata| metadata.version.as_ref()) + .and_then(|version| version.name.as_deref()) + .filter(|version| !version.trim().is_empty()) + .map(str::to_owned), + size: (size > 0).then(|| format_size(size)), + datapacks: datapack_names(path), + }; ContentEntry { - name: file_stem.to_owned(), + name: metadata + .as_ref() + .and_then(|metadata| metadata.level_name.as_deref()) + .filter(|name| !name.trim().is_empty()) + .unwrap_or(file_stem) + .to_owned(), file_stem: file_stem.to_owned(), - description, + source_slug: None, + installed_path: None, + provider_project: None, + world_details: Some(world_details), + title_suffix: None, + footer_label: None, + footer_change: None, + description: String::new(), enabled, icon_bytes, + provider_icon: false, + provider_description: false, path: path.to_path_buf(), icon_lines, } } +pub(crate) fn datapack_names(world: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(world.join("datapacks")) else { + return Vec::new(); + }; + let mut names = entries + .flatten() + .filter_map(|entry| { + let path = entry.path(); + let name = path.file_name()?.to_str()?; + if path.is_dir() { + Some(name.trim_end_matches(".disabled").to_owned()) + } else { + name.strip_suffix(".zip") + .or_else(|| name.strip_suffix(".zip.disabled")) + .map(str::to_owned) + } + }) + .collect::>(); + names.sort_by_key(|name| name.to_lowercase()); + names +} + pub fn scan_worlds(instances_dir: &Path, instance_name: &str) -> Vec { let saves_dir = instances_dir .join(instance_name) - .join(".minecraft") + .join(crate::storage::MINECRAFT_DIR_NAME) .join("saves"); let read_dir = match std::fs::read_dir(&saves_dir) { @@ -58,44 +112,80 @@ pub fn scan_worlds(instances_dir: &Path, instance_name: &str) -> Vec String { - let level_dat = world_dir.join("level.dat"); - - let created = world_dir - .metadata() - .ok() - .and_then(|m| m.created().ok().or_else(|| m.modified().ok())) - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_secs()); - - let modified = level_dat - .metadata() - .ok() - .and_then(|m| m.modified().ok()) - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_secs()); - - let dir_size = dir_size_approx(world_dir); +#[derive(Debug, Deserialize)] +struct LevelDat { + #[serde(rename = "Data")] + data: WorldMetadata, +} - let mut lines = Vec::new(); +#[derive(Debug, Deserialize)] +struct WorldMetadata { + #[serde(rename = "LevelName")] + level_name: Option, + #[serde(rename = "GameType")] + game_type: Option, + #[serde(rename = "hardcore")] + hardcore: Option, + #[serde(rename = "LastPlayed")] + last_played: Option, + #[serde(rename = "Version")] + version: Option, +} - if let Some(secs) = created - && let Some(dt) = chrono::DateTime::from_timestamp(secs as i64, 0) - { - lines.push(format!("Created: {}", dt.format("%Y-%m-%d %H:%M"))); - } +#[derive(Debug, Deserialize)] +struct WorldVersion { + #[serde(rename = "Name")] + name: Option, +} - if let Some(secs) = modified - && let Some(dt) = chrono::DateTime::from_timestamp(secs as i64, 0) - { - lines.push(format!("Played: {}", dt.format("%Y-%m-%d %H:%M"))); +impl WorldMetadata { + fn game_mode(&self) -> Option { + if self.hardcore.is_some_and(|hardcore| hardcore != 0) { + return Some(WorldGameMode::Hardcore); + } + Some(match self.game_type? { + 0 => WorldGameMode::Survival, + 1 => WorldGameMode::Creative, + 2 => WorldGameMode::Adventure, + 3 => WorldGameMode::Spectator, + _ => return None, + }) } +} - if dir_size > 0 { - lines.push(format!("Size: {}", format_size(dir_size))); +fn read_world_metadata(world_dir: &Path) -> Option { + let path = world_dir.join("level.dat"); + let file = File::open(&path).ok()?; + match fastnbt::from_reader::<_, LevelDat>(GzDecoder::new(file)) { + Ok(level) => Some(level.data), + Err(error) => { + tracing::debug!( + "Could not read world metadata from {}: {error}", + path.display() + ); + None + } } +} - lines.join("\n") +fn world_last_played( + world_dir: &Path, + metadata: Option<&WorldMetadata>, +) -> Option> { + let level_dat = world_dir.join("level.dat"); + metadata + .and_then(|metadata| metadata.last_played) + .filter(|millis| *millis > 0) + .map(|millis| millis / 1000) + .or_else(|| { + level_dat + .metadata() + .ok() + .and_then(|m| m.modified().ok()) + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs() as i64) + }) + .and_then(|seconds| chrono::DateTime::from_timestamp(seconds, 0)) } // only counts top-level files + region/ contents, not a full recursive walk. @@ -134,3 +224,7 @@ fn format_size(bytes: u64) -> String { format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) } } + +#[cfg(test)] +#[path = "../tests/content/worlds.rs"] +mod tests; diff --git a/src/instance/desktop.rs b/src/instance/desktop.rs index 08cc959..5ec802d 100644 --- a/src/instance/desktop.rs +++ b/src/instance/desktop.rs @@ -190,35 +190,5 @@ fn sanitize(name: &str) -> String { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn sanitize_keeps_alphanumeric() { - assert_eq!(sanitize("my-instance_123"), "my-instance_123"); - } - - #[test] - fn sanitize_replaces_special_chars() { - assert_eq!(sanitize("my instance!"), "my_instance_"); - assert_eq!(sanitize("path/traversal"), "path_traversal"); - } - - #[test] - #[cfg(target_os = "linux")] - fn build_content_linux() { - let content = build_content("TestPack", None); - assert!(content.contains("Name=Minecraft - TestPack")); - assert!(content.contains("Exec=rmcl instance launch \"TestPack\"")); - assert!(content.contains("Terminal=false")); - assert!(content.contains("Categories=Game;")); - } - - #[test] - #[cfg(target_os = "linux")] - fn build_content_linux_with_icon() { - let icon = PathBuf::from("/tmp/icon.png"); - let content = build_content("TestPack", Some(&icon)); - assert!(content.contains("Icon=/tmp/icon.png")); - } -} +#[path = "tests/desktop.rs"] +mod tests; diff --git a/src/instance/import/curseforge.rs b/src/instance/import/curseforge.rs new file mode 100644 index 0000000..b58f254 --- /dev/null +++ b/src/instance/import/curseforge.rs @@ -0,0 +1,282 @@ +// curseforge modpack archives: manifest.json references provider file ids, +// while the overrides directory contains configs and other bundled files. + +use std::path::Path; + +use serde::Deserialize; + +use crate::feedback::progress; +use crate::instance::{InstanceConfig, InstanceManager, ModLoader}; + +use super::{ImportSummary, PackFormat}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Manifest { + name: String, + version: String, + minecraft: Minecraft, + #[serde(default)] + files: Vec, + #[serde(default = "default_overrides")] + overrides: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Minecraft { + version: String, + #[serde(default)] + mod_loaders: Vec, +} + +#[derive(Debug, Deserialize)] +struct ManifestLoader { + id: String, + #[serde(default)] + primary: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ManifestFile { + #[serde(rename = "projectID")] + _project_id: u64, + #[serde(rename = "fileID")] + file_id: u64, + #[serde(default = "required_by_default")] + required: bool, +} + +fn default_overrides() -> String { + "overrides".to_owned() +} + +fn required_by_default() -> bool { + true +} + +fn parse(path: &Path) -> Result { + let file = std::fs::File::open(path).map_err(|error| error.to_string())?; + let mut archive = zip::ZipArchive::new(file).map_err(|error| error.to_string())?; + let entry = archive + .by_name("manifest.json") + .map_err(|_| "Missing manifest.json in CurseForge pack".to_owned())?; + serde_json::from_reader(entry).map_err(|error| format!("Invalid CurseForge manifest: {error}")) +} + +fn loader(manifest: &Manifest) -> (ModLoader, Option) { + let id = manifest + .minecraft + .mod_loaders + .iter() + .find(|loader| loader.primary) + .or_else(|| manifest.minecraft.mod_loaders.first()) + .map(|loader| loader.id.as_str()); + let Some(id) = id else { + return (ModLoader::Vanilla, None); + }; + let (name, version) = id.split_once('-').unwrap_or((id, "")); + let loader = match name.to_ascii_lowercase().as_str() { + "fabric" => ModLoader::Fabric, + "forge" => ModLoader::Forge, + "neoforge" => ModLoader::NeoForge, + "quilt" => ModLoader::Quilt, + _ => ModLoader::Vanilla, + }; + ( + loader, + (!version.is_empty() && loader != ModLoader::Vanilla).then(|| version.to_owned()), + ) +} + +pub fn build_summary(path: &Path) -> Result { + let manifest = parse(path)?; + let (loader, loader_version) = loader(&manifest); + let override_count = count_overrides(path, &manifest.overrides)?; + Ok(ImportSummary { + name: manifest.name, + pack_version: manifest.version, + game_version: manifest.minecraft.version, + loader, + loader_version, + mod_count: manifest.files.iter().filter(|file| file.required).count(), + override_count, + format: PackFormat::CurseForge, + archive_path: path.to_owned(), + source: None, + }) +} + +pub async fn execute_import( + summary: &ImportSummary, + manager: &InstanceManager, +) -> Result> { + let name = super::unique_instance_name(&summary.name, &manager.instances_dir); + progress::set_action(format!("Importing '{name}'...")); + progress::set_sub_action(format!("{} {}", summary.game_version, summary.loader)); + let config = manager + .create( + &name, + &summary.game_version, + summary.loader, + summary.loader_version.as_deref(), + ) + .await + .map_err(|error| -> Box { Box::new(error) })?; + let minecraft_dir = manager + .instances_dir + .join(&name) + .join(crate::storage::MINECRAFT_DIR_NAME); + let result = async { + let manifest = parse(&summary.archive_path)?; + download_files(&manifest, &minecraft_dir).await?; + extract_overrides(&summary.archive_path, &minecraft_dir, &manifest.overrides)?; + Ok::<(), Box>(()) + } + .await; + if let Err(error) = result { + super::cleanup_failed_import(manager, &name); + return Err(error); + } + progress::clear(); + Ok(config) +} + +async fn download_files( + manifest: &Manifest, + minecraft_dir: &Path, +) -> Result<(), Box> { + let api_key = + crate::net::curseforge::api_key().ok_or("CurseForge API key is not configured")?; + let client = crate::net::HttpClient::new(); + let ids = manifest + .files + .iter() + .filter(|file| file.required) + .map(|file| file.file_id) + .collect::>(); + let versions = crate::net::curseforge::fetch_file_versions(&client, api_key, &ids).await?; + if versions.len() != ids.len() { + return Err(format!( + "CurseForge returned {} of {} required pack files", + versions.len(), + ids.len() + ) + .into()); + } + let mods_dir = minecraft_dir.join("mods"); + tokio::fs::create_dir_all(&mods_dir).await?; + let total = versions.len(); + progress::set_action(format!("Downloading mods... 0/{total}")); + let completed = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let slots = std::sync::Arc::new(tokio::sync::Semaphore::new(10)); + let mut tasks = tokio::task::JoinSet::new(); + for mut version in versions { + let client = client.clone(); + let api_key = api_key.to_owned(); + let mods_dir = mods_dir.clone(); + let completed = completed.clone(); + let slots = slots.clone(); + tasks.spawn(async move { + let _permit = slots.acquire_owned().await?; + crate::net::curseforge::ensure_download_url(&client, &api_key, &mut version).await?; + crate::net::modrinth::download_version_file(&client, &version, &mods_dir).await?; + let finished = completed.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; + progress::set_action(format!("Downloading mods... {finished}/{total}")); + Ok::<(), Box>(()) + }); + } + while let Some(result) = tasks.join_next().await { + result??; + } + Ok(()) +} + +pub(super) async fn owned_files(path: &Path) -> Result, String> { + let manifest = parse(path)?; + let api_key = crate::net::curseforge::api_key() + .ok_or_else(|| "CurseForge API key is not configured".to_owned())?; + let ids = manifest + .files + .iter() + .filter(|file| file.required) + .map(|file| file.file_id) + .collect::>(); + let versions = + crate::net::curseforge::fetch_file_versions(&crate::net::HttpClient::new(), api_key, &ids) + .await + .map_err(|error| error.to_string())?; + if versions.len() != ids.len() { + return Err(format!( + "CurseForge returned {} of {} required pack files", + versions.len(), + ids.len() + )); + } + versions + .into_iter() + .map(|version| { + version + .files + .iter() + .find(|file| file.primary) + .or_else(|| version.files.first()) + .map(|file| std::path::PathBuf::from("mods").join(&file.filename)) + .ok_or_else(|| format!("CurseForge file '{}' has no artifact", version.id)) + }) + .collect() +} + +fn count_overrides(path: &Path, root: &str) -> Result { + let root = root.trim_matches('/'); + if root.is_empty() { + return Ok(0); + } + let file = std::fs::File::open(path).map_err(|error| error.to_string())?; + let archive = zip::ZipArchive::new(file).map_err(|error| error.to_string())?; + let prefix = format!("{root}/"); + Ok(archive + .file_names() + .filter(|name| name.starts_with(&prefix) && !name.ends_with('/')) + .count()) +} + +fn extract_overrides( + path: &Path, + minecraft_dir: &Path, + root: &str, +) -> Result<(), Box> { + progress::set_action("Extracting overrides..."); + let file = std::fs::File::open(path)?; + let mut archive = zip::ZipArchive::new(file)?; + let root = root.trim_matches('/'); + if root.is_empty() { + return Ok(()); + } + for index in 0..archive.len() { + let mut entry = archive.by_index(index)?; + let enclosed = entry.enclosed_name().ok_or("Unsafe override path")?; + let Ok(relative) = enclosed.strip_prefix(root) else { + continue; + }; + if relative.as_os_str().is_empty() { + continue; + } + let destination = minecraft_dir.join(relative); + if entry.is_dir() { + std::fs::create_dir_all(destination)?; + continue; + } + if let Some(parent) = destination.parent() { + std::fs::create_dir_all(parent)?; + } + let mut destination = std::fs::File::create(destination)?; + std::io::copy(&mut entry, &mut destination)?; + } + Ok(()) +} + +#[cfg(test)] +#[path = "../tests/import/curseforge.rs"] +mod tests; diff --git a/src/instance/import/mmc.rs b/src/instance/import/mmc.rs index f03a005..302a110 100644 --- a/src/instance/import/mmc.rs +++ b/src/instance/import/mmc.rs @@ -4,9 +4,9 @@ use std::path::Path; +use crate::feedback::progress; use crate::instance::manager::InstanceManager; use crate::instance::models::ModLoader; -use crate::tui::progress; use super::{ImportSummary, PackFormat}; use serde::Deserialize; @@ -87,6 +87,7 @@ pub fn build_summary(path: &Path) -> Result { override_count, format: PackFormat::Mmc, archive_path: path.to_path_buf(), + source: None, }) } @@ -114,8 +115,14 @@ pub async fn execute_import( .await .map_err(|e| -> Box { Box::new(e) })?; - let minecraft_dir = manager.instances_dir.join(&name).join(".minecraft"); - extract_mmc_archive(&summary.archive_path, &minecraft_dir)?; + let minecraft_dir = manager + .instances_dir + .join(&name) + .join(crate::storage::MINECRAFT_DIR_NAME); + if let Err(error) = extract_mmc_archive(&summary.archive_path, &minecraft_dir) { + super::cleanup_failed_import(manager, &name); + return Err(error); + } progress::clear(); tracing::info!( @@ -155,11 +162,25 @@ fn extract_mmc_archive( let mut entry = archive.by_index(i)?; let entry_name = entry.name().to_string(); - let Some(relative) = entry_name.strip_prefix(&minecraft_prefix) else { + if !entry_name.starts_with(&minecraft_prefix) { continue; - }; - - if relative.is_empty() || entry_name.ends_with('/') { + } + let enclosed = entry.enclosed_name().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("Unsafe archive path: {entry_name}"), + ) + })?; + let relative = enclosed + .strip_prefix(minecraft_prefix.trim_end_matches('/')) + .map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("Invalid archive path: {entry_name}"), + ) + })?; + + if relative.as_os_str().is_empty() || entry_name.ends_with('/') { std::fs::create_dir_all(minecraft_dir.join(relative))?; dirs += 1; continue; @@ -170,7 +191,10 @@ fn extract_mmc_archive( std::fs::create_dir_all(parent)?; } - let filename = relative.rsplit('/').next().unwrap_or(relative); + let filename = relative + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default(); progress::set_sub_action(filename.to_string()); let mut buf = Vec::new(); @@ -279,90 +303,5 @@ fn count_content_files(path: &Path) -> Result<(usize, usize), String> { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_mmc_pack_json() { - let json = r#"{ - "formatVersion": 1, - "components": [ - { - "uid": "net.minecraft", - "version": "1.7.10", - "cachedName": "Minecraft" - }, - { - "uid": "net.minecraftforge", - "version": "10.13.4.1614", - "cachedName": "Forge" - } - ] - }"#; - let pack: MmcPack = serde_json::from_str(json).unwrap(); - assert_eq!(pack.game_version(), Some("1.7.10".to_string())); - let (loader, version) = pack.loader(); - assert_eq!(loader, Some(ModLoader::Forge)); - assert_eq!(version, Some("10.13.4.1614".to_string())); - } - - #[test] - fn parse_mmc_pack_vanilla() { - let json = r#"{ - "formatVersion": 1, - "components": [ - {"uid": "net.minecraft", "version": "1.21.4"} - ] - }"#; - let pack: MmcPack = serde_json::from_str(json).unwrap(); - assert!(pack.loader().0.is_none()); - } - - // builds an in-memory mmc-style pack zip and verifies that - // extract_mmc_archive copies only the .minecraft/ subtree into the - // destination, preserving relative paths and skipping siblings. - #[test] - fn extract_mmc_archive_copies_minecraft_subtree() { - use std::io::Write; - - let tmp = tempfile::tempdir().unwrap(); - let archive_path = tmp.path().join("pack.zip"); - let dest = tmp.path().join("instance/.minecraft"); - std::fs::create_dir_all(&dest).unwrap(); - - // Pack/ is the prefix; only .minecraft/ entries should land in dest. - // mmc-style pack: a root dir "Pack/" wrapping the .minecraft tree - // plus a sibling mmc-pack.json that should NOT be extracted. - { - let file = std::fs::File::create(&archive_path).unwrap(); - let mut zip = zip::ZipWriter::new(file); - let opts: zip::write::SimpleFileOptions = Default::default(); - - zip.start_file("Pack/mmc-pack.json", opts).unwrap(); - zip.write_all(b"{}").unwrap(); - - zip.start_file("Pack/.minecraft/options.txt", opts).unwrap(); - zip.write_all(b"lang:en_us").unwrap(); - - zip.start_file("Pack/.minecraft/mods/test-mod.jar", opts) - .unwrap(); - zip.write_all(b"jar-bytes").unwrap(); - - zip.finish().unwrap(); - } - - extract_mmc_archive(&archive_path, &dest).expect("extract"); - - // .minecraft/ entries must have been copied with their relative paths - let options = std::fs::read(dest.join("options.txt")).expect("options.txt"); - assert_eq!(options, b"lang:en_us"); - let modjar = std::fs::read(dest.join("mods/test-mod.jar")).expect("mods/test-mod.jar"); - assert_eq!(modjar, b"jar-bytes"); - - // and the sibling outside .minecraft/ must not have been copied - assert!( - !dest.join("mmc-pack.json").exists(), - "mmc-pack.json should not land in the instance dir" - ); - } -} +#[path = "../tests/import/mmc.rs"] +mod tests; diff --git a/src/instance/import/mod.rs b/src/instance/import/mod.rs index 641bc67..306a95d 100644 --- a/src/instance/import/mod.rs +++ b/src/instance/import/mod.rs @@ -1,8 +1,10 @@ // modpack importing: parses user input, detects pack format from zip contents, // builds a summary, and delegates the actual import to format-specific modules. +pub mod curseforge; pub mod mmc; pub mod mrpack; +pub mod refresh; use std::path::{Path, PathBuf}; @@ -11,6 +13,7 @@ use crate::instance::models::ModLoader; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PackFormat { + CurseForge, Mrpack, Mmc, } @@ -69,10 +72,11 @@ pub struct ImportSummary { pub override_count: usize, pub format: PackFormat, pub archive_path: PathBuf, + pub source: Option, } // peeks inside a zip to figure out what format it is. -// checks for modrinth.index.json first, then mmc-pack.json. +// checks provider manifests first, then mmc-pack.json. pub fn detect_format(path: &Path) -> Result { tracing::debug!("Detecting modpack format for {}", path.display()); let file = @@ -85,6 +89,11 @@ pub fn detect_format(path: &Path) -> Result { return Ok(PackFormat::Mrpack); } + if archive.file_names().any(|name| name == "manifest.json") { + tracing::debug!("Detected CurseForge archive: {}", path.display()); + return Ok(PackFormat::CurseForge); + } + // mmc-pack.json can be at root or one directory deep if archive .file_names() @@ -95,7 +104,10 @@ pub fn detect_format(path: &Path) -> Result { } tracing::warn!("Unknown modpack archive format: {}", path.display()); - Err("Unknown pack format: no modrinth.index.json or mmc-pack.json found".to_string()) + Err( + "Unknown pack format: no modrinth.index.json, manifest.json, or mmc-pack.json found" + .to_string(), + ) } pub fn build_summary(path: &Path) -> Result { @@ -105,6 +117,7 @@ pub fn build_summary(path: &Path) -> Result { } let format = detect_format(path)?; let summary = match format { + PackFormat::CurseForge => curseforge::build_summary(path), PackFormat::Mrpack => mrpack::build_summary(path), PackFormat::Mmc => mmc::build_summary(path), }?; @@ -164,170 +177,106 @@ pub async fn execute_import( summary.name, summary.archive_path.display() ); - match summary.format { + let mut config = match summary.format { + PackFormat::CurseForge => curseforge::execute_import(summary, manager).await, PackFormat::Mrpack => mrpack::execute_import(summary, manager).await, PackFormat::Mmc => mmc::execute_import(summary, manager).await, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::Write; - - fn make_pack_zip(tmp: &Path, name: &str, entries: &[(&str, &[u8])]) -> std::path::PathBuf { - let path = tmp.join(name); - let file = std::fs::File::create(&path).unwrap(); - let mut zip = zip::ZipWriter::new(file); - let opts: zip::write::SimpleFileOptions = Default::default(); - for (filename, bytes) in entries { - zip.start_file(*filename, opts).unwrap(); - zip.write_all(bytes).unwrap(); + }?; + if summary.source.is_some() { + config.modpack_source = summary.source.clone(); + manager.save(&config)?; + let state = match owned_files(summary).await { + Ok(files) => refresh::PackState { + source: summary.source.clone().expect("source checked above"), + files, + }, + Err(error) => { + cleanup_failed_import(manager, &config.name); + return Err(error.into()); + } + }; + if let Err(error) = state.save(&crate::storage::InstancePaths::new( + manager.instances_dir.join(&config.name), + )) { + cleanup_failed_import(manager, &config.name); + return Err(error.into()); } - zip.finish().unwrap(); - path - } - - #[test] - fn detect_format_recognises_mrpack() { - let tmp = tempfile::tempdir().unwrap(); - let path = make_pack_zip(tmp.path(), "pack.mrpack", &[("modrinth.index.json", b"{}")]); - assert_eq!(detect_format(&path), Ok(PackFormat::Mrpack)); - } - - #[test] - fn detect_format_recognises_mmc_flat() { - // mmc-pack.json at the zip root - the flat layout that some mmc - // archives use. - let tmp = tempfile::tempdir().unwrap(); - let path = make_pack_zip(tmp.path(), "pack.zip", &[("mmc-pack.json", b"{}")]); - assert_eq!(detect_format(&path), Ok(PackFormat::Mmc)); - } - - #[test] - fn detect_format_recognises_mmc_nested() { - // mmc-pack.json one directory deep - the more common layout where - // the archive wraps everything in a named directory. - let tmp = tempfile::tempdir().unwrap(); - let path = make_pack_zip(tmp.path(), "pack.zip", &[("MyPack/mmc-pack.json", b"{}")]); - assert_eq!(detect_format(&path), Ok(PackFormat::Mmc)); - } - - #[test] - fn detect_format_prefers_mrpack_when_both_markers_present() { - // a zip with both markers should resolve to Mrpack since the - // detector checks modrinth.index.json first. - let tmp = tempfile::tempdir().unwrap(); - let path = make_pack_zip( - tmp.path(), - "weird.zip", - &[("modrinth.index.json", b"{}"), ("mmc-pack.json", b"{}")], - ); - assert_eq!(detect_format(&path), Ok(PackFormat::Mrpack)); - } - - #[test] - fn detect_format_errors_on_unknown_archive() { - let tmp = tempfile::tempdir().unwrap(); - let path = make_pack_zip(tmp.path(), "random.zip", &[("readme.txt", b"hello")]); - let err = detect_format(&path).unwrap_err(); - assert!( - err.contains("Unknown pack format"), - "expected unknown format error, got: {err}" - ); - } - - #[test] - fn detect_format_errors_on_missing_file() { - let err = detect_format(Path::new("/nonexistent/pack.zip")).unwrap_err(); - assert!(err.contains("Cannot open"), "got: {err}"); - } - - #[test] - fn unique_name_no_collision() { - let tmp = tempfile::tempdir().unwrap(); - let name = unique_instance_name("TestPack", tmp.path()); - assert_eq!(name, "TestPack"); - } - - #[test] - fn unique_name_with_collision() { - let tmp = tempfile::tempdir().unwrap(); - let dir = tmp.path().join("TestPack"); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("instance.json"), "{}").unwrap(); - let name = unique_instance_name("TestPack", tmp.path()); - assert_eq!(name, "TestPack (2)"); } + Ok(config) +} - #[test] - fn unique_name_multiple_collisions() { - let tmp = tempfile::tempdir().unwrap(); - for suffix in ["", " (2)", " (3)"] { - let dir = tmp.path().join(format!("TestPack{suffix}")); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("instance.json"), "{}").unwrap(); +pub async fn provider_versions( + source: &crate::instance::ProviderProject, +) -> Result, crate::net::NetError> { + let client = crate::net::HttpClient::new(); + match source.provider.as_str() { + "curseforge" => { + let api_key = crate::net::curseforge::api_key().ok_or_else(|| { + crate::net::NetError::Parse("CurseForge API key is not configured".to_owned()) + })?; + crate::net::curseforge::fetch_versions(&client, api_key, &source.project_id, "", None) + .await } - let name = unique_instance_name("TestPack", tmp.path()); - assert_eq!(name, "TestPack (4)"); - } - - #[test] - fn parse_project_url() { - assert_eq!( - parse_import_input("https://modrinth.com/modpack/fabulously-optimized"), - ImportInput::ProjectSlug("fabulously-optimized".to_string()) - ); - } - - #[test] - fn parse_version_url() { - assert_eq!( - parse_import_input("https://modrinth.com/modpack/fabulously-optimized/version/abc123"), - ImportInput::VersionId { - slug: "fabulously-optimized".to_string(), - version_id: "abc123".to_string(), - } - ); - } - - #[test] - fn parse_local_mrpack() { - assert_eq!( - parse_import_input("/home/user/pack.mrpack"), - ImportInput::LocalFile("/home/user/pack.mrpack".to_string()) - ); - } - - #[test] - fn parse_local_zip() { - assert_eq!( - parse_import_input("GT_New_Horizons.zip"), - ImportInput::LocalFile("GT_New_Horizons.zip".to_string()) - ); + "modrinth" => crate::net::modrinth::fetch_versions(&client, &source.project_id).await, + provider => Err(crate::net::NetError::Parse(format!( + "Unsupported modpack provider '{provider}'" + ))), } +} - #[test] - fn parse_tilde_path() { - assert_eq!( - parse_import_input("~/Downloads/pack.mrpack"), - ImportInput::LocalFile("~/Downloads/pack.mrpack".to_string()) - ); - } +pub async fn download_provider_summary( + source: &crate::instance::ProviderProject, + version: &crate::net::modrinth::VersionInfo, + temporary_dir: &Path, +) -> Result { + tokio::fs::create_dir_all(temporary_dir).await?; + let registry = crate::instance::content::provider::ProviderRegistry::configured( + crate::net::HttpClient::new(), + ); + let provider = registry.get(&source.provider).ok_or_else(|| { + crate::net::NetError::Parse(format!( + "{} content provider is unavailable", + source.provider + )) + })?; + let archive = match provider + .download_version(version, temporary_dir, None) + .await? + { + crate::net::modrinth::DownloadOutcome::Downloaded(path) + | crate::net::modrinth::DownloadOutcome::SkippedExisting(path) => path, + }; + let mut summary = build_summary(&archive).map_err(crate::net::NetError::Parse)?; + summary.source = Some(crate::instance::ProviderProject { + provider: source.provider.clone(), + project_id: source.project_id.clone(), + version_id: version.id.clone(), + }); + Ok(summary) +} - #[test] - fn parse_bare_slug() { - assert_eq!( - parse_import_input("fabulously-optimized"), - ImportInput::ProjectSlug("fabulously-optimized".to_string()) - ); +async fn owned_files(summary: &ImportSummary) -> Result, String> { + match summary.format { + PackFormat::Mrpack => mrpack::owned_files(&summary.archive_path), + PackFormat::CurseForge => curseforge::owned_files(&summary.archive_path).await, + PackFormat::Mmc => Err("Managed updates are unavailable for MultiMC packs".to_owned()), } +} - #[test] - fn parse_input_trims_whitespace() { - assert_eq!( - parse_import_input(" fabulously-optimized "), - ImportInput::ProjectSlug("fabulously-optimized".to_string()) +fn cleanup_failed_import(manager: &InstanceManager, name: &str) { + crate::feedback::progress::clear(); + let instance_dir = manager.instances_dir.join(name); + if let Err(error) = std::fs::remove_dir_all(&instance_dir) + && error.kind() != std::io::ErrorKind::NotFound + { + tracing::warn!( + "Failed to clean up incomplete imported instance {}: {}", + instance_dir.display(), + error ); } } + +#[cfg(test)] +#[path = "../tests/import/format_detection.rs"] +mod tests; diff --git a/src/instance/import/mrpack.rs b/src/instance/import/mrpack.rs index 9680230..bf43ac8 100644 --- a/src/instance/import/mrpack.rs +++ b/src/instance/import/mrpack.rs @@ -1,17 +1,97 @@ // modrinth .mrpack import: parse the manifest, download all the mods, // and extract config/resource overrides from the zip -use std::path::Path; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use serde::Deserialize; + +use crate::feedback::progress; +use crate::instance::content::manifest::{ + ContentFileRecord, ContentKind, ContentManifest, ProviderProject, Resolution, fingerprint, +}; use crate::instance::manager::InstanceManager; use crate::instance::models::ModLoader; -use crate::net::modrinth::MrpackIndex; -use crate::tui::progress; +use crate::storage::InstancePaths; use super::{ImportSummary, PackFormat}; +#[derive(Debug, Clone, Deserialize)] +pub struct MrpackIndex { + #[serde(rename = "formatVersion")] + pub format_version: u32, + pub game: String, + #[serde(rename = "versionId")] + pub version_id: String, + pub name: String, + #[serde(default)] + pub dependencies: HashMap, + #[serde(default)] + pub files: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct MrpackFile { + pub path: String, + #[serde(default)] + pub hashes: HashMap, + pub downloads: Vec, + #[serde(rename = "fileSize")] + pub file_size: u64, +} + +// .mrpack is just a zip with modrinth.index.json at the root +pub fn parse_mrpack(path: &Path) -> Result { + tracing::debug!("Parsing .mrpack manifest from {}", path.display()); + let file = std::fs::File::open(path).map_err(|e| format!("Cannot open .mrpack: {e}"))?; + let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("Invalid ZIP: {e}"))?; + let entry = archive + .by_name("modrinth.index.json") + .map_err(|_| "Missing modrinth.index.json in .mrpack".to_string())?; + let index: MrpackIndex = + serde_json::from_reader(entry).map_err(|e| format!("Invalid manifest JSON: {e}"))?; + tracing::debug!( + "Parsed .mrpack '{}' version_id={} files={} deps={}", + index.name, + index.version_id, + index.files.len(), + index.dependencies.len() + ); + Ok(index) +} + +// mrpack dependencies use keys like "fabric-loader", "forge", etc. +// checks in priority order and returns the first match. +pub fn loader_from_dependencies( + deps: &HashMap, +) -> (Option, Option) { + let loaders = [ + ("fabric-loader", ModLoader::Fabric), + ("forge", ModLoader::Forge), + ("neoforge", ModLoader::NeoForge), + ("quilt-loader", ModLoader::Quilt), + ]; + for (key, loader) in &loaders { + if let Some(version) = deps.get(*key) { + tracing::trace!( + "Resolved Modrinth loader dependency {}={} as {}", + key, + version, + loader + ); + return (Some(*loader), Some(version.clone())); + } + } + tracing::trace!("No Modrinth loader dependency found; treating pack as vanilla"); + (None, None) +} + +pub fn game_version_from_dependencies(deps: &HashMap) -> Option { + deps.get("minecraft").cloned() +} + pub fn build_summary(path: &Path) -> Result { - let index = crate::net::modrinth::parse_mrpack(path)?; + let index = parse_mrpack(path)?; tracing::debug!( "Parsed .mrpack '{}' version_id={} files={} deps={}", index.name, @@ -20,11 +100,10 @@ pub fn build_summary(path: &Path) -> Result { index.dependencies.len() ); - let game_version = crate::net::modrinth::game_version_from_dependencies(&index.dependencies) + let game_version = game_version_from_dependencies(&index.dependencies) .ok_or_else(|| "Manifest missing minecraft dependency".to_string())?; - let (loader_opt, loader_version) = - crate::net::modrinth::loader_from_dependencies(&index.dependencies); + let (loader_opt, loader_version) = loader_from_dependencies(&index.dependencies); let loader = loader_opt.unwrap_or(ModLoader::Vanilla); let override_count = count_overrides(path).unwrap_or(0); @@ -46,6 +125,7 @@ pub fn build_summary(path: &Path) -> Result { override_count, format: PackFormat::Mrpack, archive_path: path.to_path_buf(), + source: None, }) } @@ -87,14 +167,27 @@ pub async fn execute_import( .await .map_err(|e| -> Box { Box::new(e) })?; - let minecraft_dir = manager.instances_dir.join(&name).join(".minecraft"); - - let index = crate::net::modrinth::parse_mrpack(&summary.archive_path) - .map_err(|e| -> Box { e.into() })?; - - download_mod_files(&index, &minecraft_dir).await?; - - extract_overrides(&summary.archive_path, &minecraft_dir)?; + let minecraft_dir = manager + .instances_dir + .join(&name) + .join(crate::storage::MINECRAFT_DIR_NAME); + + let result: Result<(), Box> = async { + let index = parse_mrpack(&summary.archive_path) + .map_err(|e| -> Box { e.into() })?; + download_mod_files(&index, &minecraft_dir).await?; + extract_overrides(&summary.archive_path, &minecraft_dir)?; + seed_content_manifest( + &index, + &InstancePaths::new(manager.instances_dir.join(&name)), + )?; + Ok(()) + } + .await; + if let Err(error) = result { + super::cleanup_failed_import(manager, &name); + return Err(error); + } progress::clear(); tracing::info!("Imported Modrinth pack '{}' as '{}'", summary.name, name); @@ -131,31 +224,39 @@ async fn download_mod_files( for _ in 0..max_concurrent { if let Some(file) = file_iter.next() { let client = client.clone(); - let dest = minecraft_dir.join(&file.path); - let url = file.downloads.first().cloned().unwrap_or_default(); - if url.is_empty() { - tracing::warn!(".mrpack file '{}' has no download URL", file.path); - } + let relative = safe_mrpack_path(&file.path)?; + let dest = minecraft_dir.join(relative); + let url = file.downloads.first().cloned().ok_or_else(|| { + crate::net::NetError::Parse(format!( + ".mrpack file '{}' has no download URL", + file.path + )) + })?; let filename = file .path .rsplit('/') .next() .unwrap_or(&file.path) .to_string(); + let hashes = file.hashes.clone(); + let file_size = file.file_size; let completed = completed.clone(); tasks.spawn(async move { if let Some(parent) = dest.parent() - && let Err(e) = tokio::fs::create_dir_all(parent).await + && let Err(error) = tokio::fs::create_dir_all(parent).await { - tracing::warn!( - "Failed to create mod download directory {}: {}", - parent.display(), - e - ); + return Err(crate::net::NetError::from(error)); } progress::set_sub_action(filename); tracing::trace!("Downloading .mrpack file to {}", dest.display()); crate::net::download_file(&client, &url, &dest, |_, _| {}).await?; + if !verify_mrpack_file(&dest, file_size, &hashes)? { + let _ = tokio::fs::remove_file(&dest).await; + return Err(crate::net::NetError::Parse(format!( + "Downloaded .mrpack file '{}' failed its size or hash verification", + dest.display() + ))); + } let done = completed.fetch_add(1, Ordering::Relaxed) + 1; progress::set_action(format!("Downloading mods... {done}/{total}")); Ok::<(), crate::net::NetError>(()) @@ -170,31 +271,36 @@ async fn download_mod_files( .map_err(|e| -> Box { Box::new(e) })?; } let client = client.clone(); - let dest = minecraft_dir.join(&file.path); - let url = file.downloads.first().cloned().unwrap_or_default(); - if url.is_empty() { - tracing::warn!(".mrpack file '{}' has no download URL", file.path); - } + let relative = safe_mrpack_path(&file.path)?; + let dest = minecraft_dir.join(relative); + let url = file.downloads.first().cloned().ok_or_else(|| { + crate::net::NetError::Parse(format!(".mrpack file '{}' has no download URL", file.path)) + })?; let filename = file .path .rsplit('/') .next() .unwrap_or(&file.path) .to_string(); + let hashes = file.hashes.clone(); + let file_size = file.file_size; let completed = completed.clone(); tasks.spawn(async move { if let Some(parent) = dest.parent() - && let Err(e) = tokio::fs::create_dir_all(parent).await + && let Err(error) = tokio::fs::create_dir_all(parent).await { - tracing::warn!( - "Failed to create mod download directory {}: {}", - parent.display(), - e - ); + return Err(crate::net::NetError::from(error)); } progress::set_sub_action(filename); tracing::trace!("Downloading .mrpack file to {}", dest.display()); crate::net::download_file(&client, &url, &dest, |_, _| {}).await?; + if !verify_mrpack_file(&dest, file_size, &hashes)? { + let _ = tokio::fs::remove_file(&dest).await; + return Err(crate::net::NetError::Parse(format!( + "Downloaded .mrpack file '{}' failed its size or hash verification", + dest.display() + ))); + } let done = completed.fetch_add(1, Ordering::Relaxed) + 1; progress::set_action(format!("Downloading mods... {done}/{total}")); Ok::<(), crate::net::NetError>(()) @@ -210,6 +316,128 @@ async fn download_mod_files( Ok(()) } +fn safe_mrpack_path(path: &str) -> Result { + let path = Path::new(path); + if path.as_os_str().is_empty() + || path + .components() + .any(|component| !matches!(component, std::path::Component::Normal(_))) + { + return Err(crate::net::NetError::Parse(format!( + "Unsafe .mrpack file path '{}'", + path.display() + ))); + } + Ok(path.to_owned()) +} + +pub(super) fn owned_files(path: &Path) -> Result, String> { + parse_mrpack(path)? + .files + .into_iter() + .map(|file| safe_mrpack_path(&file.path).map_err(|error| error.to_string())) + .collect() +} + +fn verify_mrpack_file( + path: &Path, + expected_size: u64, + expected_hashes: &HashMap, +) -> Result { + let fingerprint = fingerprint(path)?; + if fingerprint.size != expected_size { + return Ok(false); + } + Ok(["sha512", "sha1"].into_iter().all(|algorithm| { + expected_hashes.get(algorithm).is_none_or(|expected| { + fingerprint + .hash(algorithm) + .is_some_and(|actual| actual.eq_ignore_ascii_case(expected)) + }) + })) +} + +fn seed_content_manifest( + index: &MrpackIndex, + paths: &InstancePaths, +) -> Result<(), Box> { + let mut manifest = ContentManifest::default(); + for file in &index.files { + let relative_path = Path::new(&file.path); + let Some(kind) = mrpack_content_kind(relative_path) else { + continue; + }; + let Some((project_id, version_id)) = file.downloads.iter().find_map(|url| { + let path = url.strip_prefix("https://cdn.modrinth.com/data/")?; + let (project_id, path) = path.split_once("/versions/")?; + let version_id = path.split('/').next()?; + (!project_id.is_empty() && !version_id.is_empty()) + .then_some((project_id.to_owned(), version_id.to_owned())) + }) else { + continue; + }; + let Some(expected_sha512) = file.hashes.get("sha512") else { + continue; + }; + let file_fingerprint = fingerprint(&paths.minecraft().join(relative_path))?; + if file_fingerprint + .hash("sha512") + .is_none_or(|actual| !actual.eq_ignore_ascii_case(expected_sha512)) + { + tracing::warn!( + "Skipping stale Modrinth identity for '{}' because its downloaded hash changed", + file.path + ); + continue; + } + manifest.upsert(ContentFileRecord { + relative_path: relative_path.to_owned(), + kind, + enabled: true, + fingerprint: file_fingerprint, + resolution: Resolution::Resolved { + project: ProviderProject { + provider: "modrinth".to_owned(), + project_id, + version_id, + }, + }, + provider_aliases: Vec::new(), + provider_checks: vec!["modrinth".to_owned()], + required_dependencies: Vec::new(), + automatic_dependency: false, + cleanup_eligible: false, + }); + } + let seeded = manifest.files.len(); + manifest.save(&paths.content_manifest())?; + tracing::debug!( + "Seeded {} exact Modrinth content record(s) from .mrpack '{}'", + seeded, + index.name + ); + Ok(()) +} + +fn mrpack_content_kind(path: &Path) -> Option { + use std::path::Component; + + if path + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + return None; + } + let directory = path.components().next()?.as_os_str().to_str()?; + let name = path.file_name()?.to_str()?.to_ascii_lowercase(); + match (directory, name.as_str()) { + ("mods", name) if name.ends_with(".jar") => Some(ContentKind::Mod), + ("resourcepacks", name) if name.ends_with(".zip") => Some(ContentKind::ResourcePack), + ("shaderpacks", name) if name.ends_with(".zip") => Some(ContentKind::Shader), + _ => None, + } +} + fn extract_overrides( mrpack_path: &Path, minecraft_dir: &Path, @@ -228,15 +456,27 @@ fn extract_overrides( let mut entry = archive.by_index(i)?; let entry_name = entry.name().to_string(); - let relative = entry_name - .strip_prefix("overrides/") - .or_else(|| entry_name.strip_prefix("client-overrides/")); - - let Some(relative) = relative else { + let root = if entry_name.starts_with("overrides/") { + "overrides" + } else if entry_name.starts_with("client-overrides/") { + "client-overrides" + } else { continue; }; - - if relative.is_empty() || entry_name.ends_with('/') { + let enclosed = entry.enclosed_name().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("Unsafe override path: {entry_name}"), + ) + })?; + let relative = enclosed.strip_prefix(root).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("Invalid override path: {entry_name}"), + ) + })?; + + if relative.as_os_str().is_empty() || entry_name.ends_with('/') { let dir = minecraft_dir.join(relative); std::fs::create_dir_all(dir)?; dirs += 1; @@ -268,3 +508,7 @@ fn extract_overrides( ); Ok(()) } + +#[cfg(test)] +#[path = "../tests/import/mrpack.rs"] +mod tests; diff --git a/src/instance/import/refresh.rs b/src/instance/import/refresh.rs new file mode 100644 index 0000000..00e766d --- /dev/null +++ b/src/instance/import/refresh.rs @@ -0,0 +1,393 @@ +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::instance::{InstanceConfig, InstanceManager, ProviderProject}; +use crate::net::modrinth::VersionInfo; +use crate::storage::InstancePaths; + +use super::ImportSummary; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PackState { + pub source: ProviderProject, + pub files: Vec, +} + +impl PackState { + pub fn load(paths: &InstancePaths) -> Option { + std::fs::read(paths.modpack_state()) + .ok() + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + } + + pub fn save(&self, paths: &InstancePaths) -> Result<(), String> { + let bytes = serde_json::to_vec_pretty(self).map_err(|error| error.to_string())?; + crate::storage::write_atomic(&paths.modpack_state(), &bytes) + .map_err(|error| error.to_string()) + } +} + +pub struct RefreshPlan { + pub instance: InstanceConfig, + pub summary: ImportSummary, + pub current_version: String, + pub target_version: String, + pub conflicts: Vec, + stage_root: PathBuf, + staged_instance: PathBuf, + old_owned: HashSet, + new_owned: HashSet, +} + +impl Drop for RefreshPlan { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.stage_root); + } +} + +pub async fn prepare( + manager: &InstanceManager, + instance: &InstanceConfig, + target: VersionInfo, +) -> Result { + if crate::instance::runtime::get(&instance.name) + .is_some_and(|state| !matches!(state, crate::instance::runtime::RunState::Crashed(_))) + { + return Err("Stop the instance before changing its modpack".to_owned()); + } + let source = instance + .modpack_source + .clone() + .ok_or_else(|| "This instance is not linked to a modpack provider".to_owned())?; + let registry = crate::instance::content::provider::ProviderRegistry::configured( + crate::net::HttpClient::new(), + ); + let provider = registry + .get(&source.provider) + .ok_or_else(|| format!("{} content provider is unavailable", source.provider))?; + let current = provider + .version(&source.version_id) + .await + .map_err(|error| error.to_string())?; + let live = manager.instances_dir.join(&instance.name); + let needed = directory_size(&live) + .saturating_add(target.files.iter().map(|file| file.size).sum::()) + .saturating_add(64 * 1024 * 1024); + let available = fs2::available_space(&manager.instances_dir).map_err(|e| e.to_string())?; + if available < needed { + return Err(format!( + "Not enough free space to stage this modpack change (need about {}, available {})", + format_bytes(needed), + format_bytes(available) + )); + } + + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|error| error.to_string())? + .as_nanos(); + let stage_root = manager.instances_dir.join(format!(".rmcl-refresh-{nonce}")); + let archives = stage_root.join("archives"); + let result = async { + let summary = super::download_provider_summary(&source, &target, &archives.join("target")) + .await + .map_err(|error| error.to_string())?; + let old_owned = match PackState::load(&InstancePaths::new(&live)) + .filter(|state| state.source == source) + { + Some(state) => state.files.into_iter().collect(), + None => reconstruct_owned_files(&source, ¤t, &archives.join("current")).await?, + }; + + let staging_manager = InstanceManager::new(&stage_root, &manager.meta_dir); + let staged_config = super::execute_import(&summary, &staging_manager) + .await + .map_err(|error| error.to_string())?; + let imported = stage_root.join(&staged_config.name); + let new_owned = PackState::load(&InstancePaths::new(&imported)) + .ok_or_else(|| "The staged pack did not record its owned files".to_owned())? + .files + .into_iter() + .collect::>(); + let staged_instance = stage_root.join(&instance.name); + if imported != staged_instance { + std::fs::rename(&imported, &staged_instance).map_err(|error| error.to_string())?; + } + + let mut config = instance.clone(); + config.game_version = summary.game_version.clone(); + config.loader = summary.loader; + config.loader_version = summary.loader_version.clone(); + config.modpack_source = summary.source.clone(); + let staged_manager = InstanceManager::new(&stage_root, &manager.meta_dir); + staged_manager + .save(&config) + .map_err(|error| error.to_string())?; + + let conflicts = user_file_collisions( + &InstancePaths::new(&live).minecraft(), + &old_owned, + &new_owned, + )?; + Ok::<_, String>(RefreshPlan { + instance: config, + current_version: current.version_number, + target_version: target.version_number.clone(), + summary, + conflicts, + stage_root: stage_root.clone(), + staged_instance, + old_owned, + new_owned, + }) + } + .await; + if result.is_err() { + let _ = std::fs::remove_dir_all(&stage_root); + } + result +} + +pub fn apply( + plan: RefreshPlan, + replace_conflicts: &HashSet, +) -> Result { + let live = plan + .stage_root + .parent() + .ok_or_else(|| "Invalid refresh staging path".to_owned())? + .join(&plan.instance.name); + let old_minecraft = InstancePaths::new(&live).minecraft(); + let staged_minecraft = InstancePaths::new(&plan.staged_instance).minecraft(); + preserve_user_files( + &old_minecraft, + &staged_minecraft, + &old_minecraft, + &plan.old_owned, + &plan.new_owned, + replace_conflicts, + )?; + + let backup = live.with_file_name(format!(".{}.rmcl-backup", plan.instance.name)); + if backup.exists() { + return Err(format!( + "A previous update backup still exists at '{}'", + backup.display() + )); + } + std::fs::rename(&live, &backup).map_err(|error| error.to_string())?; + if let Err(error) = std::fs::rename(&plan.staged_instance, &live) { + let _ = std::fs::rename(&backup, &live); + return Err(format!("Could not activate the staged update: {error}")); + } + if let Err(error) = std::fs::remove_dir_all(&backup) { + tracing::warn!("Could not remove successful modpack update backup: {error}"); + } + Ok(plan.instance.clone()) +} + +async fn reconstruct_owned_files( + source: &ProviderProject, + version: &VersionInfo, + temporary_dir: &Path, +) -> Result, String> { + let summary = super::download_provider_summary(source, version, temporary_dir) + .await + .map_err(|error| error.to_string())?; + Ok(super::owned_files(&summary).await?.into_iter().collect()) +} + +pub fn recover_interrupted(instances_dir: &Path) { + let Ok(entries) = std::fs::read_dir(instances_dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let Some(name) = entry.file_name().to_str().map(str::to_owned) else { + continue; + }; + if name.starts_with(".rmcl-refresh-") { + let _ = std::fs::remove_dir_all(path); + continue; + } + let Some(instance_name) = name + .strip_prefix('.') + .and_then(|name| name.strip_suffix(".rmcl-backup")) + else { + continue; + }; + let live = instances_dir.join(instance_name); + if live.join("instance.json").is_file() { + let _ = std::fs::remove_dir_all(path); + } else if let Err(error) = std::fs::rename(&path, &live) { + tracing::warn!( + "Could not restore interrupted modpack update backup '{}': {error}", + path.display() + ); + } + } +} + +fn user_file_collisions( + minecraft: &Path, + old_owned: &HashSet, + new_owned: &HashSet, +) -> Result, String> { + let mut collisions = Vec::new(); + collect_files(minecraft, minecraft, &mut |relative, _| { + if !old_owned.contains(relative) && new_owned.contains(relative) { + collisions.push(relative.to_owned()); + } + Ok(()) + })?; + collisions.sort(); + Ok(collisions) +} + +fn preserve_user_files( + source: &Path, + destination: &Path, + root: &Path, + old_owned: &HashSet, + new_owned: &HashSet, + replace_conflicts: &HashSet, +) -> Result<(), String> { + for entry in std::fs::read_dir(source).map_err(|error| error.to_string())? { + let entry = entry.map_err(|error| error.to_string())?; + let path = entry.path(); + let relative = path.strip_prefix(root).map_err(|error| error.to_string())?; + let metadata = std::fs::symlink_metadata(&path).map_err(|error| error.to_string())?; + if metadata.file_type().is_symlink() { + return Err(format!( + "Cannot safely preserve symbolic link '{}'", + path.display() + )); + } + if metadata.is_dir() { + let target = destination.join(entry.file_name()); + std::fs::create_dir_all(&target).map_err(|error| error.to_string())?; + preserve_user_files( + &path, + &target, + root, + old_owned, + new_owned, + replace_conflicts, + )?; + } else if !(old_owned.contains(relative) + || new_owned.contains(relative) && replace_conflicts.contains(relative)) + { + let target = destination.join(entry.file_name()); + std::fs::copy(&path, target).map_err(|error| error.to_string())?; + } + } + Ok(()) +} + +fn collect_files( + directory: &Path, + root: &Path, + visit: &mut impl FnMut(&Path, &Path) -> Result<(), String>, +) -> Result<(), String> { + if !directory.exists() { + return Ok(()); + } + for entry in std::fs::read_dir(directory).map_err(|error| error.to_string())? { + let entry = entry.map_err(|error| error.to_string())?; + let path = entry.path(); + let metadata = std::fs::symlink_metadata(&path).map_err(|error| error.to_string())?; + if metadata.is_dir() { + collect_files(&path, root, visit)?; + } else if metadata.is_file() { + visit( + path.strip_prefix(root).map_err(|error| error.to_string())?, + &path, + )?; + } + } + Ok(()) +} + +fn directory_size(path: &Path) -> u64 { + let mut size = 0; + let _ = collect_files(path, path, &mut |_, file| { + size += file.metadata().map(|metadata| metadata.len()).unwrap_or(0); + Ok(()) + }); + size +} + +fn format_bytes(bytes: u64) -> String { + if bytes >= 1024 * 1024 * 1024 { + format!("{:.1} GB", bytes as f64 / (1024 * 1024 * 1024) as f64) + } else { + format!("{:.1} MB", bytes as f64 / (1024 * 1024) as f64) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preservation_replaces_pack_files_and_keeps_user_files() { + let temp = tempfile::tempdir().unwrap(); + let old = temp.path().join("old"); + let new = temp.path().join("new"); + std::fs::create_dir_all(old.join("mods")).unwrap(); + std::fs::create_dir_all(new.join("mods")).unwrap(); + std::fs::write(old.join("mods/pack.jar"), "old").unwrap(); + std::fs::write(old.join("mods/user.jar"), "user").unwrap(); + std::fs::write(new.join("mods/pack.jar"), "new").unwrap(); + std::fs::write(new.join("mods/user.jar"), "pack collision").unwrap(); + let owned = HashSet::from([PathBuf::from("mods/pack.jar")]); + let target_owned = HashSet::from([ + PathBuf::from("mods/pack.jar"), + PathBuf::from("mods/user.jar"), + ]); + + preserve_user_files(&old, &new, &old, &owned, &target_owned, &HashSet::new()).unwrap(); + + assert_eq!( + std::fs::read_to_string(new.join("mods/pack.jar")).unwrap(), + "new" + ); + assert_eq!( + std::fs::read_to_string(new.join("mods/user.jar")).unwrap(), + "user" + ); + + std::fs::write(new.join("mods/user.jar"), "pack collision").unwrap(); + preserve_user_files( + &old, + &new, + &old, + &owned, + &target_owned, + &HashSet::from([PathBuf::from("mods/user.jar")]), + ) + .unwrap(); + assert_eq!( + std::fs::read_to_string(new.join("mods/user.jar")).unwrap(), + "pack collision" + ); + } + + #[test] + fn interrupted_swap_restores_the_backup_and_removes_staging() { + let temp = tempfile::tempdir().unwrap(); + let backup = temp.path().join(".Pack.rmcl-backup"); + let staging = temp.path().join(".rmcl-refresh-1"); + std::fs::create_dir_all(&backup).unwrap(); + std::fs::write(backup.join("instance.json"), "{}").unwrap(); + std::fs::create_dir_all(&staging).unwrap(); + + recover_interrupted(temp.path()); + + assert!(temp.path().join("Pack/instance.json").is_file()); + assert!(!backup.exists()); + assert!(!staging.exists()); + } +} diff --git a/src/instance/java.rs b/src/instance/java.rs new file mode 100644 index 0000000..adbe7c8 --- /dev/null +++ b/src/instance/java.rs @@ -0,0 +1,31 @@ +// java runtime discovery shared by launching, loader installation, and settings. + +#[must_use] +pub fn detect_java_path() -> String { + if let Ok(java_home) = std::env::var("JAVA_HOME") { + let java_name = if cfg!(windows) { "java.exe" } else { "java" }; + let bin = std::path::Path::new(&java_home).join("bin").join(java_name); + if bin.exists() { + tracing::trace!("Detected Java from JAVA_HOME: {}", bin.display()); + return bin.to_string_lossy().to_string(); + } + tracing::warn!( + "JAVA_HOME is set to {}, but {} does not exist", + java_home, + bin.display() + ); + } + match which::which("java") { + Ok(path) => { + tracing::trace!("Detected Java from PATH: {}", path.display()); + path.to_string_lossy().to_string() + } + Err(e) => { + tracing::warn!( + "Could not find java on PATH, falling back to literal 'java': {}", + e + ); + "java".to_string() + } + } +} diff --git a/src/instance/launch/mod.rs b/src/instance/launch/mod.rs index fa3be58..7ddf9ca 100644 --- a/src/instance/launch/mod.rs +++ b/src/instance/launch/mod.rs @@ -11,8 +11,8 @@ use thiserror::Error; use crate::auth::AccountType; use crate::instance::models::{InstanceConfig, ModLoader}; -use crate::launch_profile::model::LaunchProfile; -use crate::launch_profile::rules::{self, FeatureSet, RuleContext}; +use crate::launch_profile::model::{Argument, LaunchProfile}; +use crate::launch_profile::rules::{self, FeatureSet, RuleAction, RuleContext}; use crate::launch_profile::templates::TemplateContext; use crate::launch_profile::{render, resolve, system}; @@ -249,7 +249,7 @@ async fn migrate_legacy_loader_profile_if_needed( }; let installer_json_path = instance_dir - .join(".minecraft") + .join(crate::storage::MINECRAFT_DIR_NAME) .join("versions") .join(&version_dir) .join(format!("{version_dir}.json")); @@ -306,6 +306,46 @@ pub struct LaunchInvocation { pub working_dir: PathBuf, } +pub fn supports_quick_play(meta_dir: &Path, game_version: &str) -> bool { + let path = crate::storage::MetadataPaths::new(meta_dir) + .versions() + .join(game_version) + .join("meta.json"); + std::fs::read(path) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .is_some_and(|profile| profile_supports_quick_play(&profile)) +} + +fn profile_supports_quick_play(profile: &LaunchProfile) -> bool { + profile.arguments.as_ref().is_some_and(|arguments| { + arguments.game.iter().any(|argument| { + let Argument::Conditional { rules, .. } = argument else { + return false; + }; + rules.iter().any(|rule| { + rule.action == RuleAction::Allow + && rule + .features + .as_ref() + .is_some_and(|features| features.is_quick_play_singleplayer == Some(true)) + }) + }) + }) +} + +fn validate_quick_play_world(minecraft_dir: &Path, world: &str) -> Result<(), LaunchError> { + let mut components = Path::new(world).components(); + let is_single_name = matches!(components.next(), Some(std::path::Component::Normal(_))) + && components.next().is_none(); + if !is_single_name || !minecraft_dir.join("saves").join(world).is_dir() { + return Err(LaunchError::Parse( + "Selected Quick Play world is not a valid save".to_owned(), + )); + } + Ok(()) +} + // builds a fully-resolved java invocation for the given instance. reads // meta.json and the loader profile from disk, migrates legacy formats if // needed (may hit Mojang to refetch), resolves inheritsFrom, applies @@ -316,12 +356,14 @@ pub async fn build_launch_invocation( instances_dir: &Path, meta_dir: &Path, auth: &LaunchAuth<'_>, + quick_play_world: Option<&str>, ) -> Result { let instance_dir = instances_dir.join(&config.name); - let minecraft_dir = instance_dir.join(".minecraft"); + let minecraft_dir = instance_dir.join(crate::storage::MINECRAFT_DIR_NAME); - let meta_path = meta_dir - .join("versions") + let metadata_paths = crate::storage::MetadataPaths::new(meta_dir); + let meta_path = metadata_paths + .versions() .join(&config.game_version) .join("meta.json"); if !meta_path.exists() { @@ -333,7 +375,14 @@ pub async fn build_launch_invocation( None => meta, }; - let current_features = FeatureSet::default(); + if let Some(world) = quick_play_world { + validate_quick_play_world(&minecraft_dir, world)?; + } + + let current_features = FeatureSet { + is_quick_play_singleplayer: quick_play_world.map(|_| true), + ..Default::default() + }; let host_os_version = system::mojang_os_version(); let rule_ctx = RuleContext { os_name: system::mojang_os_name(), @@ -348,7 +397,7 @@ pub async fn build_launch_invocation( .map(|ai| ai.id.clone()) .unwrap_or_default(); - let lib_dir = meta_dir.join("libraries"); + let lib_dir = metadata_paths.libraries(); let lv = config.loader_version.as_deref().unwrap_or("unknown"); let profile_filename = match config.loader { @@ -364,7 +413,7 @@ pub async fn build_launch_invocation( // the vanilla meta migration above ensured is fresh on disk). when no // loader is configured we use the already-loaded vanilla meta directly. let merged_profile: LaunchProfile = if let Some(filename) = &profile_filename { - let profile_path = meta_dir.join("loader-profiles").join(filename); + let profile_path = metadata_paths.loader_profiles().join(filename); if !profile_path.exists() { return Err(LaunchError::MetaNotFound( profile_path.display().to_string(), @@ -403,6 +452,10 @@ pub async fn build_launch_invocation( .clone() .ok_or_else(|| LaunchError::Parse("merged profile missing mainClass".into()))?; + if quick_play_world.is_some() && !profile_supports_quick_play(&merged_profile) { + return Err(LaunchError::NotSupported("Quick Play".to_owned())); + } + // rebuild the classpath from the merged profile. vanilla-style libraries // have `downloads.artifact.path` set and live in meta_dir/libraries/. // loader-style libraries only have a maven coordinate; for forge/neoforge, @@ -432,8 +485,9 @@ pub async fn build_launch_invocation( .as_ref() .and_then(|d| d.artifact.as_ref()) .map(|a| PathBuf::from(&a.path)) - .or_else(|| crate::net::maven_coord_to_path(&lib.name).map(PathBuf::from)) - { + .or_else(|| { + crate::instance::loader::maven::maven_coord_to_path(&lib.name).map(PathBuf::from) + }) { Some(p) => p, None => continue, }; @@ -453,8 +507,8 @@ pub async fn build_launch_invocation( } classpath.push( - meta_dir - .join("versions") + metadata_paths + .versions() .join(&config.game_version) .join(format!("{}.jar", config.game_version)), ); @@ -486,7 +540,7 @@ pub async fn build_launch_invocation( .effective_java_path() .map(str::to_owned) }) - .unwrap_or_else(crate::net::detect_java_path); + .unwrap_or_else(crate::instance::java::detect_java_path); check_java_version( &java, @@ -497,9 +551,9 @@ pub async fn build_launch_invocation( ) .await?; - let assets_root = meta_dir.join("assets"); - let natives_dir = meta_dir - .join("versions") + let assets_root = metadata_paths.assets(); + let natives_dir = metadata_paths + .versions() .join(&config.game_version) .join("natives"); let version_type = merged_profile.type_.as_deref().unwrap_or("release"); @@ -522,6 +576,7 @@ pub async fn build_launch_invocation( launcher_name: "rmcl", launcher_version: env!("CARGO_PKG_VERSION"), clientid: "0", + quick_play_singleplayer: quick_play_world, }; let (upstream_jvm_args, game_args) = @@ -555,6 +610,7 @@ pub async fn launch( config: &InstanceConfig, instances_dir: &Path, meta_dir: &Path, + quick_play_world: Option<&str>, ) -> Result<(), LaunchError> { let name = config.name.clone(); @@ -612,7 +668,8 @@ pub async fn launch( user_type, }; - let invocation = build_launch_invocation(config, instances_dir, meta_dir, &auth).await?; + let invocation = + build_launch_invocation(config, instances_dir, meta_dir, &auth, quick_play_world).await?; tracing::debug!( "[{}] Prepared launch invocation: working_dir={} classpath_entries={} jvm_args={} extra_args={} game_args={} main_class={}", name, @@ -631,8 +688,8 @@ pub async fn launch( )?; let (kill_tx, kill_rx) = tokio::sync::oneshot::channel::<()>(); - crate::running::register_kill(&name, kill_tx); - crate::running::set_state(&name, crate::running::RunState::Starting); + crate::instance::runtime::register_kill(&name, kill_tx); + crate::instance::runtime::set_state(&name, crate::instance::runtime::RunState::Starting); tracing::info!( "[{}] Starting Minecraft ({} {})", name, @@ -667,17 +724,17 @@ pub async fn launch( let mut child = match cmd.spawn() { Ok(c) => c, Err(e) => { - crate::running::cleanup_kill_sender(&name); - crate::running::remove(&name); + crate::instance::runtime::cleanup_kill_sender(&name); + crate::instance::runtime::remove(&name); tracing::error!("[{}] Failed to spawn Minecraft process: {}", name, e); return Err(LaunchError::Io(e)); } }; tracing::debug!("[{}] Spawned Minecraft process", name); - crate::running::set_state(&name, crate::running::RunState::Running); + crate::instance::runtime::set_state(&name, crate::instance::runtime::RunState::Running); - let log_file_path = crate::instance::log_files::create_log_file(instances_dir, &name); + let log_file_path = crate::instance::logs::files::create_log_file(instances_dir, &name); match &log_file_path { Some(path) => tracing::debug!( "[{}] Writing Minecraft process log to {}", @@ -802,15 +859,18 @@ pub async fn launch( tracing::warn!("Failed to sync config for '{}': {}", name_for_task, e); } if code == Some(0) || killed_by_user { - crate::running::remove(&name_for_task); + crate::instance::runtime::remove(&name_for_task); tracing::debug!( "[{}] Cleared running state after normal exit (killed_by_user={})", name_for_task, killed_by_user ); } else { - crate::running::set_state(&name_for_task, crate::running::RunState::Crashed(code)); - crate::tui::error_buffer::push_error(crate::tui::error_buffer::ErrorEvent { + crate::instance::runtime::set_state( + &name_for_task, + crate::instance::runtime::RunState::Crashed(code), + ); + crate::feedback::errors::push_error(crate::feedback::errors::ErrorEvent { id: 0, level: tracing::Level::ERROR, message: match code { @@ -831,8 +891,8 @@ pub async fn launch( e ); } - crate::running::push_last_played(&name_for_task, chrono::Utc::now()); - crate::running::cleanup_kill_sender(&name_for_task); + crate::instance::runtime::push_last_played(&name_for_task, chrono::Utc::now()); + crate::instance::runtime::cleanup_kill_sender(&name_for_task); }); Ok(()) @@ -860,241 +920,9 @@ fn emit_parsed_instance_log( tracing::trace!(target: "mc_instance", "[{}] {}", instance_name, text); } } - crate::instance_logs::push_event(instance_name, event); + crate::instance::logs::live::push_event(instance_name, event); } #[cfg(test)] -mod tests { - use super::*; - - #[rstest::rstest] - #[case("openjdk version \"25.0.3\" 2026-04-21", Some(25))] - #[case("openjdk version \"21.0.11\" 2026-04-21", Some(21))] - #[case("java version \"1.8.0_402\"", Some(8))] - #[case("garbage", None)] - fn parse_java_major_version_handles_common_outputs( - #[case] output: &str, - #[case] expected: Option, - ) { - assert_eq!(parse_java_major_version(output), expected); - } - - #[test] - fn build_game_args_renders_upstream_arguments() { - use crate::launch_profile::model::{Argument, Arguments, LaunchProfile}; - use crate::launch_profile::rules::{FeatureSet, RuleContext}; - use TemplateContext; - use std::path::PathBuf; - - let lib = PathBuf::from("/m/libraries"); - let nat = PathBuf::from("/m/natives"); - let game_dir = PathBuf::from("/i/.minecraft"); - let assets = PathBuf::from("/m/assets"); - - let template_ctx = TemplateContext { - library_directory: &lib, - classpath_separator: ":", - version_name: "1.20.1", - natives_directory: &nat, - classpath: "a.jar:b.jar", - game_directory: &game_dir, - assets_root: &assets, - assets_index_name: "5", - auth_player_name: "Player", - auth_uuid: "00000000-0000-0000-0000-000000000000", - auth_access_token: "token", - auth_xuid: "0", - user_type: "msa", - user_properties: "{}", - launcher_name: "rmcl", - launcher_version: "test", - clientid: "0", - version_type: "release", - }; - let features = FeatureSet::default(); - let rule_ctx = RuleContext { - os_name: "linux", - os_version: "6.0", - arch: "x86_64", - features: &features, - }; - - let profile = LaunchProfile { - id: "1.20.1".into(), - inherits_from: None, - main_class: Some("net.minecraft.client.main.Main".into()), - libraries: Vec::new(), - arguments: Some(Arguments { - game: vec![ - Argument::Literal("--username".into()), - Argument::Literal("${auth_player_name}".into()), - ], - jvm: vec![Argument::Literal( - "-Djava.library.path=${natives_directory}".into(), - )], - }), - ..Default::default() - }; - - let (jvm, game_args) = build_game_args(&profile, &rule_ctx, &template_ctx).unwrap(); - assert_eq!(jvm, vec!["-Djava.library.path=/m/natives"]); - assert_eq!(game_args, vec!["--username", "Player"]); - } - - // exercises the early-return branch of migrate_legacy_meta_if_needed. - // a profile with either arguments or minecraftArguments is not legacy - // and must produce Ok(None) without touching the network. covers both - // shapes in one parameterised test so a regression that drops one of - // the two predicate conditions is caught. - #[rstest::rstest] - #[case::modern_arguments(true, false)] - #[case::legacy_minecraft_arguments(false, true)] - #[tokio::test] - async fn migrate_legacy_meta_skips_when_arguments_present( - #[case] modern: bool, - #[case] legacy: bool, - ) { - use crate::launch_profile::model::{Arguments, LaunchProfile}; - use tempfile::TempDir; - - let tmp = TempDir::new().unwrap(); - let meta_path = tmp.path().join("meta.json"); - std::fs::write(&meta_path, b"{}").unwrap(); - - let profile = LaunchProfile { - id: "1.20.1".into(), - main_class: Some("net.test.Main".into()), - arguments: modern.then(Arguments::default), - minecraft_arguments: legacy.then(|| "--username Player".into()), - ..Default::default() - }; - - let result = migrate_legacy_meta_if_needed(&meta_path, &profile, "1.20.1").await; - assert!( - matches!(result, Ok(None)), - "expected Ok(None) for non-legacy profile, got {result:?}" - ); - } - - // each loader maps to a distinct directory-naming branch. one rstest - // exercises every variant so a regression that misorders the match - // arms in installer_version_dir_name is caught. - #[rstest::rstest] - #[case::forge(ModLoader::Forge, "1.20.1", "47.2.0", Some("1.20.1-forge-47.2.0"))] - #[case::neoforge(ModLoader::NeoForge, "1.21.1", "21.1.0", Some("neoforge-21.1.0"))] - #[case::vanilla(ModLoader::Vanilla, "1.20.1", "v", None)] - #[case::fabric(ModLoader::Fabric, "1.20.1", "0.14.21", None)] - #[case::quilt(ModLoader::Quilt, "1.20.1", "0.20.0", None)] - fn installer_version_dir_name_per_loader( - #[case] loader: ModLoader, - #[case] game_version: &str, - #[case] loader_version: &str, - #[case] expected: Option<&str>, - ) { - assert_eq!( - installer_version_dir_name(loader, game_version, loader_version), - expected.map(str::to_owned) - ); - } - - // exercises the modern-profile early-return in - // migrate_legacy_loader_profile_if_needed. any of inheritsFrom, - // arguments, minecraftArguments present (or game_arguments absent) - // means "not legacy" and the function must return Ok(None) without - // touching the installer JSON path. - #[tokio::test] - async fn migrate_legacy_loader_profile_skips_modern_with_inherits_from() { - use LaunchProfile; - use chrono::Utc; - use tempfile::TempDir; - - let tmp = TempDir::new().unwrap(); - let instance_dir = tmp.path().join("instance"); - std::fs::create_dir_all(&instance_dir).unwrap(); - let profile_path = tmp.path().join("forge-1.20.1-47.2.0.json"); - std::fs::write(&profile_path, b"{}").unwrap(); - - let modern = LaunchProfile { - id: "1.20.1-forge-47.2.0".into(), - inherits_from: Some("1.20.1".into()), - main_class: Some("cpw.mods.bootstraplauncher.BootstrapLauncher".into()), - ..Default::default() - }; - - let config = InstanceConfig { - name: "test".into(), - game_version: "1.20.1".into(), - loader: ModLoader::Forge, - loader_version: Some("47.2.0".into()), - created: Utc::now(), - last_played: None, - java_path: None, - memory_max: None, - memory_min: None, - jvm_args: Vec::new(), - resolution: None, - config_sync_profile: None, - }; - - let result = - migrate_legacy_loader_profile_if_needed(&profile_path, &modern, &config, &instance_dir) - .await; - assert!( - matches!(result, Ok(None)), - "expected Ok(None), got {result:?}" - ); - } - - #[tokio::test] - async fn migrate_legacy_loader_profile_skips_fabric() { - // a fresh upstream Fabric profile happens to match the "legacy" - // shape (no inheritsFrom, no arguments, no minecraftArguments). - // make sure the migration helper recognises this is Fabric and - // returns Ok(None) instead of erroring with "reinstall Fabric". - use LaunchProfile; - use chrono::Utc; - use tempfile::TempDir; - - let tmp = TempDir::new().unwrap(); - let instance_dir = tmp.path().join("instance"); - std::fs::create_dir_all(&instance_dir).unwrap(); - let profile_path = tmp.path().join("fabric-1.20.1-0.14.21.json"); - std::fs::write(&profile_path, b"{}").unwrap(); - - let upstream_fabric_shape = LaunchProfile { - id: "fabric-loader-0.14.21-1.20.1".into(), - inherits_from: None, - main_class: Some("net.fabricmc.loader.impl.launch.knot.KnotClient".into()), - libraries: Vec::new(), - ..Default::default() - }; - - let config = InstanceConfig { - name: "test".into(), - game_version: "1.20.1".into(), - loader: ModLoader::Fabric, - loader_version: Some("0.14.21".into()), - created: Utc::now(), - last_played: None, - java_path: None, - memory_max: None, - memory_min: None, - jvm_args: Vec::new(), - resolution: None, - config_sync_profile: None, - }; - - let result = migrate_legacy_loader_profile_if_needed( - &profile_path, - &upstream_fabric_shape, - &config, - &instance_dir, - ) - .await; - - assert!( - matches!(result, Ok(None)), - "expected Ok(None), got {result:?}" - ); - } -} +#[path = "../tests/launch/pipeline.rs"] +mod tests; diff --git a/src/instance/launch/parser.rs b/src/instance/launch/parser.rs index adb0769..5e04b0a 100644 --- a/src/instance/launch/parser.rs +++ b/src/instance/launch/parser.rs @@ -319,107 +319,5 @@ fn level_priority(level: LogLevel) -> u8 { } #[cfg(test)] -mod tests { - use super::*; - - fn parse_all(lines: &[(LogStream, &str)]) -> Vec { - let mut parser = MinecraftLogParser::new(); - let mut events = Vec::new(); - for (stream, line) in lines { - events.extend(parser.push_line(*stream, *line)); - } - events.extend(parser.flush()); - events - } - - #[test] - fn classifies_minecraft_headers() { - let events = parse_all(&[ - (LogStream::Stdout, "[Render thread/INFO]: hello"), - (LogStream::Stdout, "[Render thread/WARN]: careful"), - (LogStream::Stdout, "[Render thread/ERROR]: broken"), - (LogStream::Stdout, "[Render thread/DEBUG]: noisy"), - (LogStream::Stdout, "[Render thread/TRACE]: tiny"), - ]); - - assert_eq!(events.len(), 5); - assert_eq!(events[0].level, LogLevel::Info); - assert_eq!(events[1].level, LogLevel::Warn); - assert_eq!(events[2].level, LogLevel::Error); - assert_eq!(events[3].level, LogLevel::Debug); - assert_eq!(events[4].level, LogLevel::Trace); - } - - #[test] - fn explicit_stderr_info_stays_info() { - let events = parse_all(&[(LogStream::Stderr, "[Render thread/INFO]: hello")]); - - assert_eq!(events[0].level, LogLevel::Info); - } - - #[test] - fn unstructured_stderr_falls_back_to_error() { - let events = parse_all(&[(LogStream::Stderr, "native library failed")]); - - assert_eq!(events[0].level, LogLevel::Error); - } - - #[test] - fn groups_java_stacktrace() { - let events = parse_all(&[ - ( - LogStream::Stderr, - "Exception in thread \"main\" java.lang.RuntimeException: boom", - ), - ( - LogStream::Stderr, - "\tat net.minecraft.client.Main.main(Main.java:42)", - ), - ( - LogStream::Stderr, - "Caused by: java.lang.IllegalStateException: bad", - ), - (LogStream::Stderr, "\tat example.Mod.load(Mod.java:7)"), - (LogStream::Stdout, "[Render thread/INFO]: after"), - ]); - - assert_eq!(events.len(), 2); - assert_eq!(events[0].level, LogLevel::Error); - assert_eq!(events[0].lines.len(), 4); - assert!( - events[0] - .java - .as_ref() - .is_some_and(|java| java.has_stacktrace) - ); - assert_eq!(events[1].level, LogLevel::Info); - } - - #[test] - fn groups_jvm_startup_failure_burst() { - let events = parse_all(&[ - (LogStream::Stderr, "Unrecognized option: --bad"), - ( - LogStream::Stderr, - "Could not create the Java Virtual Machine.", - ), - ( - LogStream::Stderr, - "A fatal exception has occurred. Program will exit.", - ), - ]); - - assert_eq!(events.len(), 1); - assert_eq!(events[0].level, LogLevel::Error); - assert_eq!(events[0].lines.len(), 3); - } - - #[test] - fn colored_header_classifies_but_keeps_original_text() { - let line = "\u{1b}[31m[Render thread/ERROR]: red\u{1b}[0m"; - let events = parse_all(&[(LogStream::Stdout, line)]); - - assert_eq!(events[0].level, LogLevel::Error); - assert_eq!(events[0].lines[0], line); - } -} +#[path = "../tests/launch/parser.rs"] +mod tests; diff --git a/src/instance/launch/patches.rs b/src/instance/launch/patches.rs index 52f40df..cc50cf2 100644 --- a/src/instance/launch/patches.rs +++ b/src/instance/launch/patches.rs @@ -326,140 +326,5 @@ async fn replace_log4j_fixed(lib_dir: &Path, classpath: &mut [PathBuf]) { } #[cfg(test)] -mod tests { - use super::*; - use std::io::Write; - - // builds an in-memory zip with the given entries, writes it to tmp, and - // returns the path. shared by parse_add_opens tests. - fn make_zip(tmp: &Path, name: &str, entries: &[(&str, &[u8])]) -> PathBuf { - let path = tmp.join(name); - let file = std::fs::File::create(&path).unwrap(); - let mut zip = zip::ZipWriter::new(file); - let opts: zip::write::SimpleFileOptions = Default::default(); - for (filename, bytes) in entries { - zip.start_file(*filename, opts).unwrap(); - zip.write_all(bytes).unwrap(); - } - zip.finish().unwrap(); - path - } - - #[test] - fn strip_replaced_libs_removes_dominated_prefixes() { - let mut classpath = vec![ - PathBuf::from("/libs/launchwrapper-1.12.jar"), - PathBuf::from("/libs/asm-all-5.0.3.jar"), - PathBuf::from("/libs/lwjgl-2.9.4.jar"), - PathBuf::from("/libs/lwjgl_util-2.9.4.jar"), - PathBuf::from("/libs/commons-compress-1.4.1.jar"), - PathBuf::from("/libs/commons-io-2.4.jar"), - PathBuf::from("/libs/guava-15.0.jar"), - // these stay - PathBuf::from("/libs/log4j-core-2.0.jar"), - PathBuf::from("/libs/guava-21.0.jar"), - ]; - strip_replaced_libs(&mut classpath); - assert_eq!( - classpath, - vec![ - PathBuf::from("/libs/log4j-core-2.0.jar"), - PathBuf::from("/libs/guava-21.0.jar"), - ] - ); - } - - #[test] - fn strip_replaced_libs_keeps_unrelated_entries() { - let mut classpath = vec![ - PathBuf::from("/libs/log4j-core-2.0.jar"), - PathBuf::from("/libs/mixin-0.8.5.jar"), - ]; - let original = classpath.clone(); - strip_replaced_libs(&mut classpath); - assert_eq!(classpath, original); - } - - #[test] - fn parse_add_opens_extracts_module_args() { - let tmp = tempfile::tempdir().unwrap(); - let manifest = - b"Manifest-Version: 1.0\nAdd-Opens: java.base/java.lang java.base/java.util\n"; - let zip_path = make_zip( - tmp.path(), - "patches.zip", - &[("META-INF/MANIFEST.MF", manifest)], - ); - let args = parse_add_opens(&zip_path).expect("parsed"); - assert_eq!( - args, - vec![ - "--add-opens", - "java.base/java.lang=ALL-UNNAMED", - "--add-opens", - "java.base/java.util=ALL-UNNAMED", - ] - ); - } - - #[test] - fn parse_add_opens_handles_continuation_lines() { - // jar manifests wrap long lines: the line ends with a trailing space, - // then the next physical line starts with a leading space marker. - // when joined per the MANIFEST.MF spec, the trailing space remains - // and the leading space marker is consumed, so the two values stay - // separated by exactly one space when split_whitespace runs. - let tmp = tempfile::tempdir().unwrap(); - let manifest = - b"Manifest-Version: 1.0\nAdd-Opens: java.base/java.lang \n java.base/sun.security.util\n"; - let zip_path = make_zip( - tmp.path(), - "patches-continuation.zip", - &[("META-INF/MANIFEST.MF", manifest)], - ); - let args = parse_add_opens(&zip_path).expect("parsed"); - assert_eq!( - args, - vec![ - "--add-opens", - "java.base/java.lang=ALL-UNNAMED", - "--add-opens", - "java.base/sun.security.util=ALL-UNNAMED", - ] - ); - } - - #[test] - fn parse_add_opens_returns_none_when_manifest_missing() { - let tmp = tempfile::tempdir().unwrap(); - let zip_path = make_zip(tmp.path(), "no-manifest.zip", &[("other.txt", b"x")]); - assert!(parse_add_opens(&zip_path).is_none()); - } - - #[test] - fn parse_add_opens_returns_none_for_missing_file() { - assert!(parse_add_opens(Path::new("/nonexistent/x.zip")).is_none()); - } - - #[test] - fn add_lwjgl3_inserts_only_jars_that_exist() { - let tmp = tempfile::tempdir().unwrap(); - let lib_dir = tmp.path(); - - // pre-create only the lwjgl core jar; everything else absent so the - // function should skip them silently. proves we don't insert paths - // for jars that aren't there. - let core_jar = lib_dir.join("org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3.jar"); - std::fs::create_dir_all(core_jar.parent().unwrap()).unwrap(); - std::fs::write(&core_jar, b"jar").unwrap(); - - let mut classpath = vec![PathBuf::from("/leading/forge-patches.jar")]; - add_lwjgl3(lib_dir, &mut classpath); - - // forge-patches stays at index 0; lwjgl core is inserted at index 1. - // none of the other modules existed so nothing else was added. - assert_eq!(classpath[0], PathBuf::from("/leading/forge-patches.jar")); - assert_eq!(classpath[1], core_jar); - assert_eq!(classpath.len(), 2); - } -} +#[path = "../tests/launch/patches.rs"] +mod tests; diff --git a/src/instance/loader/forge.rs b/src/instance/loader/forge.rs index f608ef2..85ad18a 100644 --- a/src/instance/loader/forge.rs +++ b/src/instance/loader/forge.rs @@ -6,9 +6,10 @@ use std::path::Path; use async_trait::async_trait; -use super::{GameVersion, InstallError, ModLoaderInstaller}; +use super::{GameVersion, InstallError, InstallerError, ModLoaderInstaller}; +use crate::feedback::progress::{set_action, set_sub_action}; use crate::instance::models::ModLoader; -use crate::net::{HttpClient, NetError, forge as forge_api}; +use crate::net::{HttpClient, NetError, download_file, forge as forge_api}; pub struct ForgeInstaller; @@ -45,7 +46,9 @@ impl ModLoaderInstaller for ForgeInstaller { instance_dir: &Path, meta_dir: &Path, ) -> Result<(), InstallError> { - let installer_jar = instance_dir.join(".minecraft").join("forge-installer.jar"); + let installer_jar = instance_dir + .join(crate::storage::MINECRAFT_DIR_NAME) + .join("forge-installer.jar"); tracing::info!( "Installing Forge {} for Minecraft {}", loader_version, @@ -58,16 +61,12 @@ impl ModLoaderInstaller for ForgeInstaller { let profile_filename = format!("forge-{game_version}-{loader_version}.json"); - if forge_api::has_legacy_install_profile(&installer_jar) { + if has_legacy_install_profile(&installer_jar) { // old forge: no --installClient support, extract directly from jar tracing::debug!("Forge installer uses legacy install_profile.json path"); - if let Err(e) = forge_api::install_forge_from_profile( - client, - &installer_jar, - meta_dir, - &profile_filename, - ) - .await + if let Err(e) = + install_forge_from_profile(client, &installer_jar, meta_dir, &profile_filename) + .await { let _ = tokio::fs::remove_file(&installer_jar).await; return Err(e); @@ -78,11 +77,9 @@ impl ModLoaderInstaller for ForgeInstaller { .paths .effective_java_path() .map(str::to_owned) - .unwrap_or_else(crate::net::detect_java_path); + .unwrap_or_else(crate::instance::java::detect_java_path); tracing::debug!("Running Forge installer with Java {}", java_path); - if let Err(e) = - forge_api::run_forge_installer(&installer_jar, instance_dir, &java_path).await - { + if let Err(e) = run_forge_installer(&installer_jar, instance_dir, &java_path).await { let _ = tokio::fs::remove_file(&installer_jar).await; return Err(InstallError::Installer(e)); } @@ -105,6 +102,246 @@ impl ModLoaderInstaller for ForgeInstaller { } } +pub async fn run_forge_installer( + installer_path: &Path, + instance_dir: &Path, + java_path: &str, +) -> Result<(), InstallerError> { + use tokio::process::Command; + + set_action("Running Forge installer..."); + + let output = match Command::new(java_path) + .arg("-jar") + .arg(installer_path) + .arg("--installClient") + .current_dir(instance_dir.join(crate::storage::MINECRAFT_DIR_NAME)) + .output() + .await + { + Ok(o) => o, + Err(e) => { + tracing::debug!( + "Failed to spawn Forge installer {} with Java {}: {}", + installer_path.display(), + java_path, + e + ); + return Err(InstallerError::Io(e)); + } + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let detail = if stderr.trim().is_empty() { + format!("exit code {:?}", output.status.code()) + } else { + stderr.lines().last().unwrap_or("unknown error").to_string() + }; + tracing::debug!( + "Forge installer {} failed with status {:?}: {}", + installer_path.display(), + output.status.code(), + detail + ); + return Err(InstallerError::ProcessFailed(detail)); + } + + tracing::debug!("Forge installer completed successfully"); + Ok(()) +} + +// old forge installers have an install_profile.json with a "versionInfo" key +// containing everything needed. modern ones don't have this structure. +pub(crate) fn has_legacy_install_profile(installer_path: &Path) -> bool { + let file = match std::fs::File::open(installer_path) { + Ok(f) => f, + Err(_) => return false, + }; + let mut archive = match zip::ZipArchive::new(file) { + Ok(a) => a, + Err(_) => return false, + }; + let entry = match archive.by_name("install_profile.json") { + Ok(e) => e, + Err(_) => return false, + }; + let value: serde_json::Value = match serde_json::from_reader(entry) { + Ok(v) => v, + Err(_) => return false, + }; + value.get("versionInfo").is_some() +} + +// handles old-style forge installation by extracting the universal jar and +// library info directly from the installer, bypassing the GUI-only installer +pub(crate) async fn install_forge_from_profile( + client: &HttpClient, + installer_path: &Path, + meta_dir: &Path, + profile_filename: &str, +) -> Result<(), InstallError> { + use std::io::Read; + + set_action("Installing legacy Forge from profile..."); + tracing::debug!( + "Installing legacy Forge from {} into {}", + installer_path.display(), + meta_dir.display() + ); + + let file = std::fs::File::open(installer_path) + .map_err(|e| InstallError::Installer(InstallerError::Io(e)))?; + let mut archive = zip::ZipArchive::new(file).map_err(|e| { + InstallError::Installer(InstallerError::Profile(format!( + "Failed to open installer as ZIP: {e}" + ))) + })?; + + let profile_data: serde_json::Value = { + let entry = archive.by_name("install_profile.json").map_err(|e| { + InstallError::Installer(InstallerError::Profile(format!( + "install_profile.json not found in installer: {e}" + ))) + })?; + serde_json::from_reader(entry).map_err(|e| { + InstallError::Installer(InstallerError::Profile(format!( + "Failed to parse install_profile.json: {e}" + ))) + })? + }; + + let version_info = profile_data.get("versionInfo").ok_or_else(|| { + InstallError::Installer(InstallerError::Profile( + "install_profile.json missing versionInfo".into(), + )) + })?; + let install_info = profile_data.get("install").ok_or_else(|| { + InstallError::Installer(InstallerError::Profile( + "install_profile.json missing install section".into(), + )) + })?; + + let libraries = version_info + .get("libraries") + .and_then(|v| v.as_array()) + .ok_or_else(|| { + InstallError::Installer(InstallerError::Profile( + "missing versionInfo.libraries".into(), + )) + })?; + + let file_path = install_info + .get("filePath") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + InstallError::Installer(InstallerError::Profile("missing install.filePath".into())) + })?; + + let install_path_coord = install_info + .get("path") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + InstallError::Installer(InstallerError::Profile("missing install.path".into())) + })?; + + // extract the universal jar to the correct maven location + let universal_maven_path = crate::instance::loader::maven::maven_coord_to_path( + install_path_coord, + ) + .ok_or_else(|| { + InstallError::Installer(InstallerError::Profile(format!( + "Invalid maven coord in install.path: {install_path_coord}" + ))) + })?; + + set_sub_action("Extracting universal JAR..."); + let universal_dest = crate::storage::MetadataPaths::new(meta_dir) + .libraries() + .join(&universal_maven_path); + if let Some(parent) = universal_dest.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| InstallError::Installer(InstallerError::Io(e)))?; + } + + { + let mut entry = archive.by_name(file_path).map_err(|e| { + InstallError::Installer(InstallerError::Profile(format!( + "Universal JAR '{file_path}' not found in installer: {e}" + ))) + })?; + let mut buf = Vec::new(); + entry + .read_to_end(&mut buf) + .map_err(|e| InstallError::Installer(InstallerError::Io(e)))?; + std::fs::write(&universal_dest, &buf) + .map_err(|e| InstallError::Installer(InstallerError::Io(e)))?; + tracing::debug!( + "Extracted legacy Forge universal JAR to {} ({} bytes)", + universal_dest.display(), + buf.len() + ); + } + + // download libraries needed by this forge version. libs with a url field + // are forge-hosted, libs without one are typically from mojang's library + // server. old forge versions reference libs like launchwrapper that aren't + // in mojang's modern version metadata, so we fetch those too. + let libraries_dir = crate::storage::MetadataPaths::new(meta_dir).libraries(); + for lib in libraries { + let name = lib.get("name").and_then(|v| v.as_str()).unwrap_or_default(); + + let maven_path = match crate::instance::loader::maven::maven_coord_to_path(name) { + Some(p) => p, + None => { + return Err(InstallError::Installer(InstallerError::Profile(format!( + "Invalid Maven coordinate: {name}" + )))); + } + }; + + let dest = libraries_dir.join(&maven_path); + if dest.exists() { + tracing::trace!("Legacy Forge library already cached: {}", name); + continue; + } + + let base_url = lib + .get("url") + .and_then(|v| v.as_str()) + .unwrap_or("https://libraries.minecraft.net/") + .trim_end_matches('/'); + let download_url = format!("{base_url}/{maven_path}"); + + set_sub_action(name); + tracing::debug!("Downloading legacy Forge library {}", name); + download_file(client, &download_url, &dest, |_, _| {}).await?; + } + + set_action("Saving Forge profile..."); + // write the installer's versionInfo as compact JSON. it already has the + // mainClass, the full library list (with name + url for forge-hosted + // libs), and minecraftArguments (the legacy --tweakClass etc). the + // launch flow parses this as a LaunchProfile and - if there's no + // inheritsFrom field - implicitly inherits from the configured game + // version so vanilla libraries layer in via resolve(). + // + // we use serde_json::to_vec (not the pretty-print variant via + // save_profile_json) so the written file is content-faithful: every + // field present in the installer's versionInfo round-trips. key order + // and whitespace may differ from the original installer JSON because + // the source is a serde_json::Value (which doesn't preserve order), + // but no field is silently dropped. + let serialized = serde_json::to_vec(version_info).map_err(|e| { + InstallError::Installer(InstallerError::Profile(format!( + "Failed to serialize Forge profile: {e}" + ))) + })?; + crate::instance::loader::save_profile_bytes(meta_dir, profile_filename, &serialized) + .map_err(|e| InstallError::Installer(InstallerError::Io(e)))?; + Ok(()) +} + fn save_forge_profile( instance_dir: &Path, meta_dir: &Path, @@ -115,3 +352,7 @@ fn save_forge_profile( let profile_filename = format!("forge-{game_version}-{loader_version}.json"); super::save_installer_profile(instance_dir, meta_dir, &version_dir_name, &profile_filename) } + +#[cfg(test)] +#[path = "../tests/loader/forge.rs"] +mod tests; diff --git a/src/instance/loader/maven.rs b/src/instance/loader/maven.rs new file mode 100644 index 0000000..7e93d6b --- /dev/null +++ b/src/instance/loader/maven.rs @@ -0,0 +1,27 @@ +// maven coordinate handling shared by loader installation and launch profiles. + +#[must_use] +pub fn maven_coord_to_path(coord: &str) -> Option { + let parts: Vec<&str> = coord.split(':').collect(); + match parts.as_slice() { + [group, artifact, version] => { + let group_path = group.replace('.', "/"); + Some(format!( + "{}/{}/{}/{}-{}.jar", + group_path, artifact, version, artifact, version + )) + } + [group, artifact, version, classifier] => { + let group_path = group.replace('.', "/"); + Some(format!( + "{}/{}/{}/{}-{}-{}.jar", + group_path, artifact, version, artifact, version, classifier + )) + } + _ => None, + } +} + +#[cfg(test)] +#[path = "../tests/loader/maven.rs"] +mod tests; diff --git a/src/instance/loader/mod.rs b/src/instance/loader/mod.rs index 82a0c5f..18ed166 100644 --- a/src/instance/loader/mod.rs +++ b/src/instance/loader/mod.rs @@ -4,8 +4,9 @@ // though (fabric/quilt just download jars, forge/neoforge run a whole java installer). mod fabric; -mod forge; -mod neoforge; +pub mod forge; +pub mod maven; +pub mod neoforge; mod quilt; mod vanilla; @@ -74,7 +75,7 @@ pub(crate) fn save_profile_bytes( filename: &str, bytes: &[u8], ) -> std::io::Result<()> { - let profiles_dir = meta_dir.join("loader-profiles"); + let profiles_dir = crate::storage::MetadataPaths::new(meta_dir).loader_profiles(); std::fs::create_dir_all(&profiles_dir)?; std::fs::write(profiles_dir.join(filename), bytes) } @@ -92,7 +93,7 @@ pub(crate) fn save_installer_profile( profile_filename: &str, ) -> Result<(), InstallerError> { let ver_json_path = instance_dir - .join(".minecraft") + .join(crate::storage::MINECRAFT_DIR_NAME) .join("versions") .join(version_dir_name) .join(format!("{version_dir_name}.json")); @@ -115,7 +116,7 @@ pub(crate) fn save_installer_profile( ); let raw = std::fs::read(&ver_json_path)?; - let profiles_dir = meta_dir.join("loader-profiles"); + let profiles_dir = crate::storage::MetadataPaths::new(meta_dir).loader_profiles(); std::fs::create_dir_all(&profiles_dir)?; let profile_path = profiles_dir.join(profile_filename); std::fs::write(&profile_path, &raw)?; @@ -138,159 +139,5 @@ pub fn get_installer(loader: ModLoader) -> Box Result<(), InstallError> { let installer_jar = instance_dir - .join(".minecraft") + .join(crate::storage::MINECRAFT_DIR_NAME) .join("neoforge-installer.jar"); tracing::info!("Installing NeoForge {}", loader_version); tracing::debug!("NeoForge installer path: {}", installer_jar.display()); @@ -56,11 +57,9 @@ impl ModLoaderInstaller for NeoForgeInstaller { .paths .effective_java_path() .map(str::to_owned) - .unwrap_or_else(crate::net::detect_java_path); + .unwrap_or_else(crate::instance::java::detect_java_path); tracing::debug!("Running NeoForge installer with Java {}", java_path); - if let Err(e) = - neoforge_api::run_neoforge_installer(&installer_jar, instance_dir, &java_path).await - { + if let Err(e) = run_neoforge_installer(&installer_jar, instance_dir, &java_path).await { let _ = tokio::fs::remove_file(&installer_jar).await; return Err(InstallError::Installer(e)); } @@ -77,6 +76,55 @@ impl ModLoaderInstaller for NeoForgeInstaller { } } +pub async fn run_neoforge_installer( + installer_path: &Path, + instance_dir: &Path, + java_path: &str, +) -> Result<(), InstallerError> { + use tokio::process::Command; + + set_action("Running NeoForge installer..."); + + let output = match Command::new(java_path) + .arg(format!("-Duser.home={}", instance_dir.display())) + .arg("-jar") + .arg(installer_path) + .arg("--installClient") + .current_dir(instance_dir.join(crate::storage::MINECRAFT_DIR_NAME)) + .output() + .await + { + Ok(o) => o, + Err(e) => { + tracing::debug!( + "Failed to spawn NeoForge installer {} with Java {}: {}", + installer_path.display(), + java_path, + e + ); + return Err(InstallerError::Io(e)); + } + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let detail = stderr.lines().last().unwrap_or("").trim(); + tracing::debug!( + "NeoForge installer {} failed with status {:?}: {}", + installer_path.display(), + output.status.code(), + detail + ); + return Err(InstallerError::ProcessFailed(format!( + "NeoForge installer exited with {:?}", + output.status.code() + ))); + } + + tracing::debug!("NeoForge installer completed successfully"); + Ok(()) +} + fn save_neoforge_profile( instance_dir: &Path, meta_dir: &Path, diff --git a/src/instance/loader/vanilla.rs b/src/instance/loader/vanilla.rs index df57433..aeed8e5 100644 --- a/src/instance/loader/vanilla.rs +++ b/src/instance/loader/vanilla.rs @@ -20,14 +20,7 @@ impl ModLoaderInstaller for VanillaInstaller { async fn get_game_versions(&self, client: &HttpClient) -> Result, NetError> { let manifest = mojang::fetch_version_manifest(client).await?; - Ok(manifest - .versions - .into_iter() - .map(|v| GameVersion { - id: v.id, - stable: v.version_type == "release", - }) - .collect()) + Ok(game_versions_from_manifest(manifest)) } async fn get_versions( @@ -49,3 +42,18 @@ impl ModLoaderInstaller for VanillaInstaller { Ok(()) } } + +fn game_versions_from_manifest(manifest: mojang::VersionManifest) -> Vec { + manifest + .versions + .into_iter() + .map(|version| GameVersion { + id: version.id, + stable: version.version_type == "release", + }) + .collect() +} + +#[cfg(test)] +#[path = "../tests/loader/vanilla.rs"] +mod tests; diff --git a/src/instance/log_files.rs b/src/instance/logs/files.rs similarity index 81% rename from src/instance/log_files.rs rename to src/instance/logs/files.rs index 858f6ff..dd24ad2 100644 --- a/src/instance/log_files.rs +++ b/src/instance/logs/files.rs @@ -1,4 +1,4 @@ -// per-launch log files stored under .minecraft/logs/launches/ +// per-launch log files stored under minecraft/logs/launches/ // each launch gets its own timestamped file so you can go back and see what // crashed last tuesday at 3am @@ -13,7 +13,7 @@ pub struct LogFileEntry { pub fn log_dir(instances_dir: &Path, instance_name: &str) -> PathBuf { instances_dir .join(instance_name) - .join(".minecraft") + .join(crate::storage::MINECRAFT_DIR_NAME) .join("logs") .join("launches") } @@ -58,15 +58,5 @@ pub fn read_log_file(path: &Path) -> Vec { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn log_dir_builds_correct_path() { - let p = log_dir(Path::new("/instances"), "my-world"); - assert_eq!( - p, - PathBuf::from("/instances/my-world/.minecraft/logs/launches") - ); - } -} +#[path = "../tests/logs/files.rs"] +mod tests; diff --git a/src/instance_logs.rs b/src/instance/logs/live.rs similarity index 61% rename from src/instance_logs.rs rename to src/instance/logs/live.rs index f0038e8..dfe72c1 100644 --- a/src/instance_logs.rs +++ b/src/instance/logs/live.rs @@ -68,52 +68,5 @@ pub fn clear(name: &str) { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn push_and_get_all() { - let name = "test_push_get"; - push(name, "line1"); - push(name, "line2"); - let lines = get_all(name); - assert!(lines.contains(&"line1".to_string())); - assert!(lines.contains(&"line2".to_string())); - } - - #[test] - fn get_all_missing_instance_returns_empty() { - let lines = get_all("nonexistent_instance_xyz"); - assert!(lines.is_empty()); - } - - #[test] - fn clear_removes_instance() { - let name = "test_clear"; - push(name, "data"); - assert!(!get_all(name).is_empty()); - clear(name); - assert!(get_all(name).is_empty()); - } - - #[test] - fn clear_nonexistent_is_noop() { - clear("never_existed_xyz"); - } - - #[test] - fn buffer_respects_max_lines() { - let name = "test_max_lines"; - for i in 0..(MAX_LINES + 100) { - push(name, format!("line-{i}")); - } - let lines = get_all(name); - assert_eq!(lines.len(), MAX_LINES); - assert!( - lines - .last() - .unwrap() - .contains(&format!("{}", MAX_LINES + 99)) - ); - } -} +#[path = "../tests/logs/live.rs"] +mod tests; diff --git a/src/instance/logs/mod.rs b/src/instance/logs/mod.rs new file mode 100644 index 0000000..57fac3a --- /dev/null +++ b/src/instance/logs/mod.rs @@ -0,0 +1,2 @@ +pub mod files; +pub mod live; diff --git a/src/instance/manager.rs b/src/instance/manager.rs index 0998194..0162680 100644 --- a/src/instance/manager.rs +++ b/src/instance/manager.rs @@ -126,7 +126,7 @@ impl InstanceManager { loader_version: Option<&str>, instance_dir: &std::path::Path, ) -> Result { - let minecraft_dir = instance_dir.join(".minecraft"); + let minecraft_dir = instance_dir.join(crate::storage::MINECRAFT_DIR_NAME); tracing::debug!( "Preparing Minecraft directory for '{}': {}", name, @@ -149,11 +149,12 @@ impl InstanceManager { ); } + let metadata_paths = crate::storage::MetadataPaths::new(&self.meta_dir); for meta_subdir in &[ - self.meta_dir.join("versions"), - self.meta_dir.join("libraries"), - self.meta_dir.join("assets").join("objects"), - self.meta_dir.join("assets").join("indexes"), + metadata_paths.versions(), + metadata_paths.libraries(), + metadata_paths.assets().join("objects"), + metadata_paths.assets().join("indexes"), ] { std::fs::create_dir_all(meta_subdir)?; tracing::trace!("Ensured metadata directory {}", meta_subdir.display()); @@ -198,9 +199,8 @@ impl InstanceManager { crate::net::mojang::download_client_jar(&self.client, &version_meta, &self.meta_dir) .await?; - let meta_json_path = self - .meta_dir - .join("versions") + let meta_json_path = metadata_paths + .versions() .join(game_version) .join("meta.json"); if let Some(parent) = meta_json_path.parent() @@ -272,15 +272,112 @@ impl InstanceManager { jvm_args: vec![], resolution: None, config_sync_profile: None, + modpack_source: None, }; self.save(&config)?; tracing::info!("Created instance '{}'", name); - crate::tui::progress::clear(); + crate::feedback::progress::clear(); Ok(config) } + pub async fn repair_runtime_cache(&self, config: &InstanceConfig) -> Result<(), InstanceError> { + let task = crate::feedback::progress::ProgressTask::start(format!( + "Rebuilding runtime for '{}'", + config.name + )); + task.set_sub_action(format!("Minecraft {}", config.game_version)); + let metadata_paths = crate::storage::MetadataPaths::new(&self.meta_dir); + for directory in [ + metadata_paths.versions(), + metadata_paths.libraries(), + metadata_paths.assets().join("objects"), + metadata_paths.assets().join("indexes"), + metadata_paths.loader_profiles(), + ] { + std::fs::create_dir_all(directory)?; + } + + let meta_path = metadata_paths + .versions() + .join(&config.game_version) + .join("meta.json"); + let (version_meta, raw_meta) = if meta_path.exists() { + let raw = std::fs::read(&meta_path)?; + let parsed = serde_json::from_slice(&raw).map_err(|error| { + InstanceError::InvalidName(format!( + "Cached metadata for Minecraft {} is invalid: {error}", + config.game_version + )) + })?; + (parsed, raw) + } else { + let manifest = crate::net::mojang::fetch_version_manifest(&self.client).await?; + let version_entry = manifest + .versions + .iter() + .find(|version| version.id == config.game_version) + .ok_or_else(|| { + InstanceError::InvalidName(format!( + "Minecraft version '{}' is no longer present in Mojang's manifest", + config.game_version + )) + })?; + crate::net::mojang::fetch_version_meta_with_raw(&self.client, version_entry).await? + }; + crate::net::mojang::download_client_jar(&self.client, &version_meta, &self.meta_dir) + .await?; + if let Some(parent) = meta_path.parent() { + std::fs::create_dir_all(parent)?; + } + if !meta_path.exists() { + crate::storage::write_atomic(&meta_path, &raw_meta)?; + } + crate::net::mojang::download_libraries(&self.client, &version_meta, &self.meta_dir).await?; + crate::net::mojang::download_assets(&self.client, &version_meta, &self.meta_dir).await?; + + if config.loader != ModLoader::Vanilla { + let loader_version = config.loader_version.as_deref().ok_or_else(|| { + InstanceError::InvalidName(format!( + "Instance '{}' has no {} loader version", + config.name, config.loader + )) + })?; + let profile_name = match config.loader { + ModLoader::Fabric => { + format!("fabric-{}-{loader_version}.json", config.game_version) + } + ModLoader::Quilt => { + format!("quilt-{}-{loader_version}.json", config.game_version) + } + ModLoader::Forge => { + format!("forge-{}-{loader_version}.json", config.game_version) + } + ModLoader::NeoForge => format!("neoforge-{loader_version}.json"), + ModLoader::Vanilla => unreachable!(), + }; + if !metadata_paths.loader_profiles().join(profile_name).exists() { + task.set_sub_action(format!("{} {}", config.loader, loader_version)); + crate::instance::loader::get_installer(config.loader) + .install( + &self.client, + &config.game_version, + loader_version, + &self.instances_dir.join(&config.name), + &self.meta_dir, + ) + .await + .map_err(|error| match error { + InstallError::Download(error) => InstanceError::Download(error), + InstallError::Installer(error) => InstanceError::InstallerError(error), + })?; + } + } + task.finish(); + Ok(()) + } + pub fn delete(&self, name: &str) -> Result<(), InstanceError> { let instance_dir = self.instances_dir.join(name); if !instance_dir.exists() { @@ -493,171 +590,5 @@ fn validate_name(name: &str) -> Result<(), InstanceError> { } #[cfg(test)] -mod tests { - use super::*; - use crate::instance::models::ModLoader; - use tempfile::TempDir; - - // tmp owns the temp directory; its Drop impl cleans up everything when - // the test ends. the returned InstanceManager points at tmp.path() so - // tests can join("name") off of either to refer to the same locations. - fn test_manager() -> (InstanceManager, TempDir) { - let tmp = tempfile::tempdir().unwrap(); - let meta = tmp.path().join("meta"); - std::fs::create_dir_all(&meta).unwrap(); - (InstanceManager::new(tmp.path().to_path_buf(), meta), tmp) - } - - fn dummy_config(name: &str) -> InstanceConfig { - InstanceConfig { - name: name.to_string(), - game_version: "1.20.1".to_string(), - loader: ModLoader::Vanilla, - loader_version: None, - created: chrono::Utc::now(), - last_played: None, - java_path: None, - memory_max: None, - memory_min: None, - jvm_args: vec![], - resolution: None, - config_sync_profile: None, - } - } - - #[test] - fn validate_name_accepts_safe_names() { - assert!(validate_name("my-instance").is_ok()); - assert!(validate_name("test_world").is_ok()); - } - - #[test] - fn validate_name_rejects_empty_traversal_and_hidden() { - assert!(validate_name("").is_err()); - assert!(validate_name("path/traversal").is_err()); - assert!(validate_name(".hidden").is_err()); - } - - #[test] - fn delete_missing_instance_returns_not_found() { - let (manager, _tmp) = test_manager(); - let result = manager.delete("ghost-instance"); - assert!(matches!(result, Err(InstanceError::NotFound(_)))); - } - - #[test] - fn save_then_load_all_round_trips_config() { - let (manager, tmp) = test_manager(); - std::fs::create_dir_all(tmp.path().join("test-save")).unwrap(); - manager.save(&dummy_config("test-save")).expect("save"); - - let all = manager.load_all(); - assert_eq!(all.len(), 1); - assert_eq!(all[0].name, "test-save"); - assert_eq!(all[0].game_version, "1.20.1"); - } - - #[test] - fn load_all_accepts_numeric_memory() { - let (manager, tmp) = test_manager(); - let instance_dir = tmp.path().join("test-memory"); - std::fs::create_dir_all(&instance_dir).unwrap(); - std::fs::write( - instance_dir.join("instance.json"), - r#"{ - "name": "test-memory", - "game_version": "1.7.10", - "loader": "forge", - "loader_version": "10.13.4.1614", - "created": "2026-04-20T18:04:25.567993893Z", - "memory_max": 8, - "memory_min": 512 -}"#, - ) - .expect("write config"); - - let all = manager.load_all(); - assert_eq!(all.len(), 1); - assert_eq!(all[0].memory_max.as_deref(), Some("8G")); - assert_eq!(all[0].memory_min.as_deref(), Some("512M")); - } - - #[test] - fn load_one_missing_returns_not_found() { - let (manager, _tmp) = test_manager(); - let result = manager.load_one("ghost-instance"); - assert!(matches!(result, Err(InstanceError::NotFound(_)))); - } - - #[test] - fn rename_moves_dir_and_updates_config_name() { - let (manager, tmp) = test_manager(); - let old_dir = tmp.path().join("old-name"); - std::fs::create_dir_all(&old_dir).unwrap(); - manager.save(&dummy_config("old-name")).expect("save"); - - manager.rename("old-name", "new-name").expect("rename"); - - assert!(!old_dir.exists(), "old dir should be gone"); - let new_dir = tmp.path().join("new-name"); - assert!(new_dir.exists(), "new dir should exist"); - let reloaded = manager.load_one("new-name").expect("load_one new-name"); - assert_eq!(reloaded.name, "new-name"); - } - - #[test] - fn rename_to_same_name_is_noop() { - let (manager, tmp) = test_manager(); - let dir = tmp.path().join("same"); - std::fs::create_dir_all(&dir).unwrap(); - manager.save(&dummy_config("same")).expect("save"); - manager.rename("same", "same").expect("noop rename"); - assert!(dir.exists()); - } - - #[test] - fn rename_empty_target_rejects() { - let (manager, tmp) = test_manager(); - std::fs::create_dir_all(tmp.path().join("orig")).unwrap(); - manager.save(&dummy_config("orig")).expect("save"); - let err = manager.rename("orig", " ").unwrap_err(); - assert!(matches!(err, InstanceError::InvalidName(_))); - } - - #[test] - fn rename_missing_source_errors() { - let (manager, _tmp) = test_manager(); - let err = manager.rename("ghost", "anything").unwrap_err(); - assert!(matches!(err, InstanceError::NotFound(_))); - } - - #[test] - fn rename_target_exists_errors() { - let (manager, tmp) = test_manager(); - std::fs::create_dir_all(tmp.path().join("source")).unwrap(); - std::fs::create_dir_all(tmp.path().join("collision")).unwrap(); - manager.save(&dummy_config("source")).expect("save src"); - manager.save(&dummy_config("collision")).expect("save dst"); - let err = manager.rename("source", "collision").unwrap_err(); - assert!(matches!(err, InstanceError::AlreadyExists(_))); - } - - #[test] - fn touch_last_played_updates_field() { - let (manager, tmp) = test_manager(); - std::fs::create_dir_all(tmp.path().join("ticker")).unwrap(); - manager.save(&dummy_config("ticker")).expect("save"); - assert!(manager.load_one("ticker").unwrap().last_played.is_none()); - - manager.touch_last_played("ticker").expect("touch"); - let reloaded = manager.load_one("ticker").unwrap(); - let stamp = reloaded - .last_played - .expect("last_played should be Some now"); - let age = chrono::Utc::now() - stamp; - assert!( - age.num_seconds().abs() < 5, - "last_played should be roughly now, got age {age:?}" - ); - } -} +#[path = "tests/manager.rs"] +mod tests; diff --git a/src/instance/mod.rs b/src/instance/mod.rs index b916dcf..74a0251 100644 --- a/src/instance/mod.rs +++ b/src/instance/mod.rs @@ -5,16 +5,21 @@ pub mod config_sync; pub mod content; pub mod desktop; pub mod import; +pub mod java; pub mod launch; pub mod loader; -pub mod log_files; +pub mod logs; pub mod manager; pub mod models; +pub mod runtime; pub mod screenshots; +pub use content::manifest::{ + ContentFileRecord, ContentKind, ContentManifest, FileFingerprint, ProviderProject, Resolution, +}; pub use content::{ - ContentEntry, scan_mods, scan_one_mod, scan_one_resource_pack, scan_one_shader, scan_one_world, - scan_resource_packs, scan_shaders, scan_worlds, toggle_entry, + scan_mods, scan_one_datapack, scan_one_mod, scan_one_resource_pack, scan_one_shader, + scan_one_world, scan_resource_packs, scan_shaders, scan_worlds, }; pub use launch::LaunchError; pub use loader::{GameVersion, ModLoaderInstaller, VanillaInstaller, get_installer}; diff --git a/src/instance/models.rs b/src/instance/models.rs index 05b2273..2881d44 100644 --- a/src/instance/models.rs +++ b/src/instance/models.rs @@ -49,6 +49,8 @@ pub struct InstanceConfig { pub resolution: Option<(u32, u32)>, #[serde(default)] pub config_sync_profile: Option, + #[serde(default)] + pub modpack_source: Option, } pub fn normalize_memory_value(raw: &str) -> Option { @@ -122,84 +124,5 @@ fn memory_number_to_string(value: u64) -> Option { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn instance_config_roundtrips_through_json() { - let config = InstanceConfig { - name: "test".to_string(), - game_version: "1.20.1".to_string(), - loader: ModLoader::Fabric, - loader_version: Some("0.15.0".to_string()), - created: Utc::now(), - last_played: None, - java_path: None, - memory_max: Some("4G".to_string()), - memory_min: Some("512M".to_string()), - jvm_args: vec![], - resolution: Some((1920, 1080)), - config_sync_profile: None, - }; - let json = serde_json::to_string_pretty(&config).expect("serialize"); - let parsed: InstanceConfig = serde_json::from_str(&json).expect("deserialize"); - assert_eq!(parsed.name, config.name); - assert_eq!(parsed.game_version, config.game_version); - assert_eq!(parsed.loader, config.loader); - assert_eq!(parsed.resolution, config.resolution); - } - - #[test] - fn instance_config_accepts_numeric_memory() { - let json = r#" - { - "name": "test", - "game_version": "1.7.10", - "loader": "forge", - "loader_version": "10.13.4.1614", - "created": "2026-04-20T18:04:25.567993893Z", - "memory_max": 8, - "memory_min": 512 - } - "#; - let parsed: InstanceConfig = serde_json::from_str(json).expect("deserialize"); - assert_eq!(parsed.memory_max.as_deref(), Some("8G")); - assert_eq!(parsed.memory_min.as_deref(), Some("512M")); - } - - #[test] - fn normalize_memory_value_handles_bare_numbers() { - assert_eq!(normalize_memory_value("8").as_deref(), Some("8G")); - assert_eq!(normalize_memory_value("4096").as_deref(), Some("4096M")); - assert_eq!(normalize_memory_value("8G").as_deref(), Some("8G")); - assert_eq!(normalize_memory_value("2048m").as_deref(), Some("2048M")); - assert_eq!(normalize_memory_value(""), None); - } - - #[test] - fn instance_config_ignores_invalid_memory_values() { - let json = r#" - { - "name": "test", - "game_version": "1.7.10", - "loader": "forge", - "loader_version": "10.13.4.1614", - "created": "2026-04-20T18:04:25.567993893Z", - "memory_max": ["8G"], - "memory_min": "8GB" - } - "#; - let parsed: InstanceConfig = serde_json::from_str(json).expect("deserialize"); - assert_eq!(parsed.memory_max, None); - assert_eq!(parsed.memory_min, None); - } - - #[test] - fn normalize_memory_value_rejects_invalid_values() { - assert_eq!(normalize_memory_value("0"), None); - assert_eq!(normalize_memory_value("-1"), None); - assert_eq!(normalize_memory_value("1.5G"), None); - assert_eq!(normalize_memory_value("8GB"), None); - assert_eq!(normalize_memory_value("banana"), None); - } -} +#[path = "tests/models.rs"] +mod tests; diff --git a/src/running.rs b/src/instance/runtime.rs similarity index 54% rename from src/running.rs rename to src/instance/runtime.rs index cffab0f..c1d1055 100644 --- a/src/running.rs +++ b/src/instance/runtime.rs @@ -33,14 +33,14 @@ pub static KILL_SENDERS: LazyLock = pub fn set_state(name: &str, state: RunState) { if let Ok(mut map) = RUNNING.lock() { map.insert(name.to_string(), state); - crate::tui::request_redraw(); + crate::feedback::request_redraw(); } } pub fn remove(name: &str) { if let Ok(mut map) = RUNNING.lock() { map.remove(name); - crate::tui::request_redraw(); + crate::feedback::request_redraw(); } } @@ -73,7 +73,7 @@ pub fn has_active() -> bool { pub fn push_last_played(name: &str, time: DateTime) { if let Ok(mut q) = PENDING_LAST_PLAYED.lock() { q.push((name.to_string(), time)); - crate::tui::request_redraw(); + crate::feedback::request_redraw(); } } @@ -108,80 +108,5 @@ pub fn cleanup_kill_sender(name: &str) { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn set_and_get_state() { - set_state("run_test_1", RunState::Starting); - assert_eq!(get("run_test_1"), Some(RunState::Starting)); - } - - #[test] - fn get_missing_returns_none() { - assert_eq!(get("run_never_set_xyz"), None); - } - - #[test] - fn remove_clears_state() { - set_state("run_test_2", RunState::Running); - remove("run_test_2"); - assert_eq!(get("run_test_2"), None); - } - - #[test] - fn set_state_overwrites() { - set_state("run_test_3", RunState::Starting); - set_state("run_test_3", RunState::Running); - assert_eq!(get("run_test_3"), Some(RunState::Running)); - } - - #[test] - fn all_returns_entries() { - set_state("run_test_all_a", RunState::Running); - let entries = all(); - assert!(entries.iter().any(|(k, _)| k == "run_test_all_a")); - } - - #[test] - fn crashed_state_stores_exit_code() { - set_state("run_test_crash", RunState::Crashed(Some(1))); - assert_eq!(get("run_test_crash"), Some(RunState::Crashed(Some(1)))); - } - - #[test] - fn push_and_drain_last_played() { - let time = Utc::now(); - push_last_played("run_test_lp", time); - let drained = drain_last_played(); - assert!(drained.iter().any(|(k, _)| k == "run_test_lp")); - } - - // Removed drain_empty_returns_empty: it relied on no other test pushing - // to LAST_PLAYED between the two drain calls, which races with the - // parallel push_and_drain_last_played test. The drain semantics are - // already covered by push_and_drain_last_played, which asserts a - // specific entry is present, and the empty-result path is exercised - // implicitly any time drain runs after that test's cleanup. - - #[test] - fn send_kill_returns_false_for_missing() { - assert!(!send_kill("run_never_registered_xyz")); - } - - #[test] - fn register_and_send_kill() { - let (tx, mut rx) = tokio::sync::oneshot::channel::<()>(); - register_kill("run_test_kill", tx); - assert!(send_kill("run_test_kill")); - let _ = rx.try_recv(); - } - - #[test] - fn cleanup_kill_sender_removes() { - let (tx, _rx) = tokio::sync::oneshot::channel::<()>(); - register_kill("run_test_cleanup", tx); - cleanup_kill_sender("run_test_cleanup"); - assert!(!send_kill("run_test_cleanup")); - } -} +#[path = "tests/runtime.rs"] +mod tests; diff --git a/src/instance/screenshots.rs b/src/instance/screenshots.rs index 044a247..d71e854 100644 --- a/src/instance/screenshots.rs +++ b/src/instance/screenshots.rs @@ -14,7 +14,7 @@ pub struct ScreenshotEntry { pub fn scan_screenshots(instances_dir: &Path, instance_name: &str) -> Vec { let dir = instances_dir .join(instance_name) - .join(".minecraft") + .join(crate::storage::MINECRAFT_DIR_NAME) .join("screenshots"); let read_dir = match std::fs::read_dir(&dir) { diff --git a/src/instance/tests/config_sync.rs b/src/instance/tests/config_sync.rs new file mode 100644 index 0000000..34903f0 --- /dev/null +++ b/src/instance/tests/config_sync.rs @@ -0,0 +1,309 @@ +use super::*; + +#[test] +fn first_prepare_seeds_shared_config() { + let tmp = tempfile::tempdir().unwrap(); + let meta = tmp.path().join("meta"); + let minecraft = tmp.path().join("instance/minecraft"); + create_profile(&meta, "main").unwrap(); + std::fs::create_dir_all(minecraft.join("config/nested")).unwrap(); + std::fs::write(minecraft.join("options.txt"), "local-options").unwrap(); + std::fs::write(minecraft.join("optionsshaders.txt"), "shader-options").unwrap(); + std::fs::write(minecraft.join("config/options.txt"), "local-config").unwrap(); + std::fs::write(minecraft.join("config/nested/mod.toml"), "nested").unwrap(); + + assert!(prepare(Some("main"), &meta, &minecraft).unwrap()); + + assert_eq!( + std::fs::read_to_string(meta.join("state/profiles/main/options.txt")).unwrap(), + "local-options" + ); + assert_eq!( + std::fs::read_to_string(meta.join("state/profiles/main/optionsshaders.txt")).unwrap(), + "shader-options" + ); + assert_eq!( + std::fs::read_to_string(meta.join("state/profiles/main/config/options.txt")).unwrap(), + "local-config" + ); + assert_eq!( + std::fs::read_to_string(meta.join("state/profiles/main/config/nested/mod.toml")).unwrap(), + "nested" + ); +} + +#[test] +fn prepare_mirrors_shared_config_into_instance() { + let tmp = tempfile::tempdir().unwrap(); + let meta = tmp.path().join("meta"); + let minecraft = tmp.path().join("instance/minecraft"); + std::fs::create_dir_all(meta.join("state/profiles/main/config")).unwrap(); + std::fs::create_dir_all(minecraft.join("config")).unwrap(); + std::fs::write( + meta.join("state/profiles/main/options.txt"), + "shared-options", + ) + .unwrap(); + std::fs::write( + meta.join("state/profiles/main/config/shared.toml"), + "shared", + ) + .unwrap(); + std::fs::write(minecraft.join("options.txt"), "stale-options").unwrap(); + std::fs::write(minecraft.join("config/local.toml"), "stale").unwrap(); + + assert!(prepare(Some("main"), &meta, &minecraft).unwrap()); + + assert_eq!( + std::fs::read_to_string(minecraft.join("options.txt")).unwrap(), + "shared-options" + ); + assert_eq!( + std::fs::read_to_string(minecraft.join("config/shared.toml")).unwrap(), + "shared" + ); + assert!(!minecraft.join("config/local.toml").exists()); +} + +#[test] +fn finish_mirrors_instance_config_back_to_shared() { + let tmp = tempfile::tempdir().unwrap(); + let meta = tmp.path().join("meta"); + let minecraft = tmp.path().join("instance/minecraft"); + std::fs::create_dir_all(meta.join("state/profiles/main/config")).unwrap(); + std::fs::write(meta.join("state/profiles/main/config/old.toml"), "old").unwrap(); + std::fs::create_dir_all(minecraft.join("config")).unwrap(); + std::fs::write(minecraft.join("options.txt"), "new-options").unwrap(); + std::fs::write(minecraft.join("config/new.toml"), "new").unwrap(); + + finish(Some("main"), &meta, &minecraft).unwrap(); + + assert_eq!( + std::fs::read_to_string(meta.join("state/profiles/main/options.txt")).unwrap(), + "new-options" + ); + assert_eq!( + std::fs::read_to_string(meta.join("state/profiles/main/config/new.toml")).unwrap(), + "new" + ); + assert!(!meta.join("state/profiles/main/config/old.toml").exists()); +} + +#[test] +fn prepare_releases_lock_for_another_instance() { + let tmp = tempfile::tempdir().unwrap(); + let meta = tmp.path().join("meta"); + let minecraft = tmp.path().join("instance/minecraft"); + create_profile(&meta, "main").unwrap(); + std::fs::create_dir_all(minecraft.join("config")).unwrap(); + + assert!(prepare(Some("main"), &meta, &minecraft).unwrap()); + let second = tmp.path().join("second/minecraft"); + std::fs::create_dir_all(second.join("config")).unwrap(); + + assert!(prepare(Some("main"), &meta, &second).unwrap()); +} + +#[test] +fn profile_rejects_path_traversal() { + let tmp = tempfile::tempdir().unwrap(); + let err = prepare(Some("../bad"), tmp.path(), tmp.path()).unwrap_err(); + + assert!(matches!(err, ConfigSyncError::InvalidProfile(_))); +} + +#[test] +fn profile_rejects_builtin_names() { + for profile in [ + "none", + "default", + "local", + "instance default", + "local default", + ] { + let err = validate_profile(profile).unwrap_err(); + assert!(matches!(err, ConfigSyncError::InvalidProfile(_))); + } +} + +#[test] +fn prepare_ignores_deleted_profile() { + let tmp = tempfile::tempdir().unwrap(); + let meta = tmp.path().join("meta"); + let minecraft = tmp.path().join("instance/minecraft"); + std::fs::create_dir_all(minecraft.join("config")).unwrap(); + + let lock = prepare(Some("deleted"), &meta, &minecraft).unwrap(); + + assert!(!lock); + assert!(!meta.join("state/profiles/deleted").exists()); +} + +#[test] +fn create_profile_trims_and_lists_profiles() { + let tmp = tempfile::tempdir().unwrap(); + + let profile = create_profile(tmp.path(), " main ").unwrap(); + let profiles = list_profiles(tmp.path()).unwrap(); + + assert_eq!(profile, "main"); + assert_eq!(profiles, vec!["main"]); +} + +#[test] +fn delete_profile_removes_profile_dir() { + let tmp = tempfile::tempdir().unwrap(); + create_profile(tmp.path(), "main").unwrap(); + + delete_profile(tmp.path(), "main").unwrap(); + + assert!(list_profiles(tmp.path()).unwrap().is_empty()); +} + +#[test] +fn switch_to_profile_backs_up_local_config_and_restores_none() { + let tmp = tempfile::tempdir().unwrap(); + let meta = tmp.path().join("meta"); + let instance = tmp.path().join("instance"); + let minecraft = instance.join(crate::storage::MINECRAFT_DIR_NAME); + std::fs::create_dir_all(minecraft.join("config")).unwrap(); + std::fs::write(minecraft.join("options.txt"), "local-options").unwrap(); + std::fs::write(minecraft.join("config/local.txt"), "local").unwrap(); + + let selected = switch_profile("inst", None, Some("main"), &meta, &instance).unwrap(); + assert_eq!(selected.as_deref(), Some("main")); + assert_eq!( + std::fs::read_to_string(instance.join("rmcl/content/config/options.txt")).unwrap(), + "local-options" + ); + assert_eq!( + std::fs::read_to_string(instance.join("rmcl/content/config/config/local.txt")).unwrap(), + "local" + ); + + std::fs::write(minecraft.join("options.txt"), "shared-options").unwrap(); + std::fs::write(minecraft.join("config/shared.txt"), "shared").unwrap(); + let selected = switch_profile("inst", Some("main"), None, &meta, &instance).unwrap(); + + assert_eq!(selected, None); + assert_eq!( + std::fs::read_to_string(meta.join("state/profiles/main/options.txt")).unwrap(), + "shared-options" + ); + assert_eq!( + std::fs::read_to_string(meta.join("state/profiles/main/config/shared.txt")).unwrap(), + "shared" + ); + assert_eq!( + std::fs::read_to_string(minecraft.join("options.txt")).unwrap(), + "local-options" + ); + assert_eq!( + std::fs::read_to_string(minecraft.join("config/local.txt")).unwrap(), + "local" + ); + assert!(!minecraft.join("config/shared.txt").exists()); +} + +#[test] +fn switch_from_deleted_profile_restores_local_without_recreating_profile() { + let tmp = tempfile::tempdir().unwrap(); + let meta = tmp.path().join("meta"); + let instance = tmp.path().join("instance"); + let minecraft = instance.join(crate::storage::MINECRAFT_DIR_NAME); + std::fs::create_dir_all(minecraft.join("config")).unwrap(); + std::fs::create_dir_all(instance.join("rmcl/content/config/config")).unwrap(); + std::fs::write(minecraft.join("options.txt"), "deleted-profile-options").unwrap(); + std::fs::write( + instance.join("rmcl/content/config/options.txt"), + "local-options", + ) + .unwrap(); + std::fs::write( + instance.join("rmcl/content/config/config/local.txt"), + "local", + ) + .unwrap(); + + let selected = switch_profile("inst", Some("deleted"), None, &meta, &instance).unwrap(); + + assert_eq!(selected, None); + assert_eq!( + std::fs::read_to_string(minecraft.join("options.txt")).unwrap(), + "local-options" + ); + assert_eq!( + std::fs::read_to_string(minecraft.join("config/local.txt")).unwrap(), + "local" + ); + assert!(!meta.join("state/profiles/deleted").exists()); +} + +#[test] +fn switch_between_profiles_saves_old_and_loads_new() { + let tmp = tempfile::tempdir().unwrap(); + let meta = tmp.path().join("meta"); + let instance = tmp.path().join("instance"); + let minecraft = instance.join(crate::storage::MINECRAFT_DIR_NAME); + std::fs::create_dir_all(minecraft.join("config")).unwrap(); + std::fs::write(minecraft.join("options.txt"), "changed-a-options").unwrap(); + std::fs::write(minecraft.join("config/a.txt"), "changed-a").unwrap(); + create_profile(&meta, "a").unwrap(); + std::fs::create_dir_all(meta.join("state/profiles/b/config")).unwrap(); + std::fs::write( + meta.join("state/profiles/b/options.txt"), + "profile-b-options", + ) + .unwrap(); + std::fs::write(meta.join("state/profiles/b/config/b.txt"), "profile-b").unwrap(); + + let selected = switch_profile("inst", Some("a"), Some("b"), &meta, &instance).unwrap(); + + assert_eq!(selected.as_deref(), Some("b")); + assert_eq!( + std::fs::read_to_string(meta.join("state/profiles/a/options.txt")).unwrap(), + "changed-a-options" + ); + assert_eq!( + std::fs::read_to_string(meta.join("state/profiles/a/config/a.txt")).unwrap(), + "changed-a" + ); + assert_eq!( + std::fs::read_to_string(minecraft.join("options.txt")).unwrap(), + "profile-b-options" + ); + assert_eq!( + std::fs::read_to_string(minecraft.join("config/b.txt")).unwrap(), + "profile-b" + ); + assert!(!minecraft.join("config/a.txt").exists()); +} + +#[test] +fn second_instance_uses_profile_options_saved_by_first_instance() { + let tmp = tempfile::tempdir().unwrap(); + let meta = tmp.path().join("meta"); + let first = tmp.path().join("first/minecraft"); + let second_instance = tmp.path().join("second"); + let second = second_instance.join(crate::storage::MINECRAFT_DIR_NAME); + std::fs::create_dir_all(first.join("config")).unwrap(); + std::fs::create_dir_all(second.join("config")).unwrap(); + std::fs::write(first.join("options.txt"), "first-default").unwrap(); + std::fs::write(second.join("options.txt"), "second-local").unwrap(); + create_profile(&meta, "main").unwrap(); + + assert!(prepare(Some("main"), &meta, &first).unwrap()); + std::fs::write(first.join("options.txt"), "changed-in-main").unwrap(); + finish(Some("main"), &meta, &first).unwrap(); + + switch_profile("second", None, Some("main"), &meta, &second_instance).unwrap(); + + assert_eq!( + std::fs::read_to_string(second.join("options.txt")).unwrap(), + "changed-in-main" + ); + assert_eq!( + std::fs::read_to_string(second_instance.join("rmcl/content/config/options.txt")).unwrap(), + "second-local" + ); +} diff --git a/src/instance/tests/content/dependencies.rs b/src/instance/tests/content/dependencies.rs new file mode 100644 index 0000000..ef6e2ce --- /dev/null +++ b/src/instance/tests/content/dependencies.rs @@ -0,0 +1,1072 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use async_trait::async_trait; +use chrono::Utc; + +use super::*; +use crate::instance::content::provider::{FingerprintQuery, ResolvedFile}; +use crate::instance::{FileFingerprint, ModLoader, Resolution}; +use crate::net::modrinth::{ + DependencyType, DiscoveryResults, ProjectInfo, VersionDependency, VersionType, +}; + +#[test] +fn concurrent_installs_use_distinct_staging_directories() { + let minecraft = Path::new("/instance/minecraft"); + assert_ne!(staging_directory(minecraft), staging_directory(minecraft)); +} + +#[test] +fn merge_keeps_replacement_when_a_dependency_is_also_an_update_root() { + let selected = version( + "library-new", + "library", + VersionType::Release, + "2026-02-01", + Vec::new(), + ); + let item = |replacement: bool| PlannedInstall { + provider: "modrinth".to_owned(), + project_id: "library".to_owned(), + title: "Library".to_owned(), + version: selected.clone(), + installed_path: Some(PathBuf::from("/minecraft/mods/library.jar")), + kind: ContentKind::Mod, + destination: PathBuf::from("/minecraft/mods"), + provider_aliases: Vec::new(), + required_dependencies: Vec::new(), + automatic_dependency: !replacement, + cleanup_eligible: !replacement, + replacement, + }; + + let merged = merge(vec![ + DependencyPlan { + items: vec![item(false)], + root_count: 0, + optional_dependencies: 0, + }, + DependencyPlan { + items: vec![item(true)], + root_count: 1, + optional_dependencies: 0, + }, + ]) + .unwrap(); + + assert_eq!(merged.root_count, 1); + assert!(merged.items[0].replacement); + assert!(!merged.items[0].automatic_dependency); +} + +struct FakeProvider { + versions: HashMap, + compatible: HashMap>, + projects: HashMap, + project_types: HashMap, + categories: HashMap>, + resolved: HashMap, + fail_project: Option, + fail_download: Option, +} + +impl FakeProvider { + fn registry(self) -> ProviderRegistry { + ProviderRegistry::new(vec![Box::new(self)]) + } +} + +#[async_trait] +impl ContentProvider for FakeProvider { + fn id(&self) -> &'static str { + "modrinth" + } + + async fn search( + &self, + _kind: ContentKind, + _query: &str, + _instance: &InstanceConfig, + _offset: usize, + _limit: usize, + ) -> Result { + unreachable!() + } + + async fn search_modpacks( + &self, + _query: &str, + _offset: usize, + _limit: usize, + ) -> Result { + unreachable!() + } + + async fn resolve_files( + &self, + files: &[FingerprintQuery], + ) -> Result, NetError> { + Ok(files + .iter() + .filter_map(|file| { + self.resolved + .get(&file.key) + .cloned() + .map(|project| ResolvedFile { + key: file.key.clone(), + project, + }) + }) + .collect()) + } + + async fn project(&self, project_id: &str) -> Result { + if self.fail_project.as_deref() == Some(project_id) { + return Err(NetError::Parse(format!( + "failed to load project {project_id}" + ))); + } + Ok(ProjectInfo { + id: project_id.to_owned(), + slug: project_id.to_owned(), + title: self + .projects + .get(project_id) + .cloned() + .unwrap_or_else(|| project_id.to_owned()), + description: String::new(), + body: String::new(), + icon_url: None, + categories: self.categories.get(project_id).cloned().unwrap_or_default(), + additional_categories: Vec::new(), + project_type: self + .project_types + .get(project_id) + .cloned() + .unwrap_or_else(|| "mod".to_owned()), + loaders: Vec::new(), + }) + } + + async fn compatible_versions( + &self, + project_id: &str, + _kind: ContentKind, + _game_version: &str, + _loader: ModLoader, + ) -> Result, NetError> { + Ok(self + .compatible + .get(project_id) + .into_iter() + .flatten() + .filter_map(|id| self.versions.get(id).cloned()) + .collect()) + } + + async fn version(&self, version_id: &str) -> Result { + self.versions + .get(version_id) + .cloned() + .ok_or_else(|| NetError::Parse(format!("missing version {version_id}"))) + } + + async fn icon(&self, _url: &str) -> Result, NetError> { + unreachable!() + } + + async fn download_version( + &self, + version: &VersionInfo, + destination: &Path, + _installed_path: Option<&Path>, + ) -> Result { + if self.fail_download.as_deref() == Some(version.id.as_str()) { + return Err(NetError::Parse(format!( + "failed to download {}", + version.id + ))); + } + let extension = if version.loaders.iter().any(|loader| loader == "datapack") { + "zip" + } else { + "jar" + }; + let path = destination.join(format!("{}.{extension}", version.id)); + tokio::fs::write(&path, version.id.as_bytes()).await?; + Ok(crate::net::modrinth::DownloadOutcome::Downloaded(path)) + } +} + +fn dependency(project_id: &str, dependency_type: DependencyType) -> VersionDependency { + VersionDependency { + version_id: None, + project_id: Some(project_id.to_owned()), + file_name: None, + dependency_type, + } +} + +fn version( + id: &str, + project_id: &str, + version_type: VersionType, + date: &str, + dependencies: Vec, +) -> VersionInfo { + VersionInfo { + id: id.to_owned(), + project_id: project_id.to_owned(), + name: id.to_owned(), + version_number: id.to_owned(), + game_versions: vec!["1.21.1".to_owned()], + loaders: vec!["fabric".to_owned()], + version_type, + dependencies, + date_published: date.to_owned(), + files: Vec::new(), + } +} + +fn instance() -> InstanceConfig { + InstanceConfig { + name: "Test".to_owned(), + game_version: "1.21.1".to_owned(), + loader: ModLoader::Fabric, + loader_version: Some("0.16.0".to_owned()), + created: Utc::now(), + last_played: None, + java_path: None, + memory_max: None, + memory_min: None, + jvm_args: Vec::new(), + resolution: None, + config_sync_profile: None, + modpack_source: None, + } +} + +fn root(version: VersionInfo) -> InstallRoot { + InstallRoot { + provider: "modrinth".to_owned(), + project_id: version.project_id.clone(), + title: "Root".to_owned(), + version, + installed_path: None, + kind: ContentKind::Mod, + target_world: None, + force_reinstall: false, + } +} + +fn installed_record(project_id: &str, version_id: &str, enabled: bool) -> ContentFileRecord { + ContentFileRecord { + relative_path: PathBuf::from(format!("mods/{project_id}.jar")), + kind: ContentKind::Mod, + enabled, + fingerprint: FileFingerprint { + size: 1, + modified_ns: 1, + hashes: Default::default(), + }, + resolution: Resolution::Resolved { + project: ProviderProject { + provider: "modrinth".to_owned(), + project_id: project_id.to_owned(), + version_id: version_id.to_owned(), + }, + }, + provider_aliases: Vec::new(), + provider_checks: Vec::new(), + required_dependencies: Vec::new(), + automatic_dependency: false, + cleanup_eligible: false, + } +} + +fn provider(versions: Vec) -> FakeProvider { + let compatible = versions.iter().fold( + HashMap::>::new(), + |mut compatible, version| { + compatible + .entry(version.project_id.clone()) + .or_default() + .push(version.id.clone()); + compatible + }, + ); + let projects = versions + .iter() + .map(|version| (version.project_id.clone(), version.project_id.clone())) + .collect(); + let categories = versions + .iter() + .map(|version| (version.project_id.clone(), vec!["library".to_owned()])) + .collect(); + FakeProvider { + versions: versions + .into_iter() + .map(|version| (version.id.clone(), version)) + .collect(), + compatible, + projects, + project_types: HashMap::new(), + categories, + resolved: HashMap::new(), + fail_project: None, + fail_download: None, + } +} + +#[tokio::test] +async fn datapack_dependencies_are_routed_to_the_world_or_instance_by_kind() { + let temp = tempfile::tempdir().unwrap(); + let minecraft = temp.path().join("minecraft"); + let world = minecraft.join("saves/world"); + let manifest_path = temp.path().join("manifest.json"); + let mut root_version = version( + "root", + "root", + VersionType::Release, + "2026-01-01", + vec![dependency("library", DependencyType::Required)], + ); + root_version.loaders = vec!["datapack".to_owned()]; + let library = version( + "library", + "library", + VersionType::Release, + "2026-01-01", + Vec::new(), + ); + let registry = provider(vec![root_version.clone(), library]).registry(); + let mut install_root = root(root_version); + install_root.kind = ContentKind::DataPack; + install_root.target_world = Some(world.clone()); + + let plan = resolve( + ®istry, + &ContentManifest::default(), + &minecraft, + &instance(), + install_root, + ) + .await + .unwrap(); + + assert_eq!(plan.items[0].kind, ContentKind::DataPack); + assert_eq!(plan.items[0].destination, world.join("datapacks")); + assert_eq!(plan.items[1].kind, ContentKind::Mod); + assert_eq!(plan.items[1].destination, minecraft.join("mods")); + + install(®istry, &manifest_path, &minecraft, &plan) + .await + .unwrap(); + let manifest = ContentManifest::load(&manifest_path).unwrap(); + assert_eq!( + manifest + .record(Path::new("saves/world/datapacks/root.zip")) + .unwrap() + .kind, + ContentKind::DataPack + ); + assert_eq!( + manifest.record(Path::new("mods/library.jar")).unwrap().kind, + ContentKind::Mod + ); +} + +#[tokio::test] +async fn datapack_install_rejects_an_incompatible_instance_mod() { + let mut root_version = version( + "root", + "root", + VersionType::Release, + "2026-01-01", + vec![dependency("conflict", DependencyType::Incompatible)], + ); + root_version.loaders = vec!["datapack".to_owned()]; + let registry = provider(vec![root_version.clone()]).registry(); + let mut install_root = root(root_version); + install_root.kind = ContentKind::DataPack; + install_root.target_world = Some(PathBuf::from("/minecraft/saves/world")); + let manifest = ContentManifest { + version: 1, + files: vec![installed_record("conflict", "conflict-version", true)], + }; + + let error = resolve( + ®istry, + &manifest, + Path::new("/minecraft"), + &instance(), + install_root, + ) + .await + .unwrap_err(); + + assert!(error.to_string().contains("incompatible")); +} + +#[test] +fn legacy_modrinth_datapack_projects_are_classified_by_loader() { + let project = ProjectInfo { + id: "legacy".to_owned(), + slug: "legacy".to_owned(), + title: "Legacy datapack".to_owned(), + description: String::new(), + body: String::new(), + icon_url: None, + categories: Vec::new(), + additional_categories: Vec::new(), + project_type: "mod".to_owned(), + loaders: vec!["datapack".to_owned()], + }; + + assert_eq!( + dependency_kind(Some(&project), None, ContentKind::DataPack), + ContentKind::DataPack + ); +} + +#[tokio::test] +async fn required_dependencies_prefer_the_newest_stable_release() { + let root_version = version( + "root", + "root", + VersionType::Release, + "2026-01-01", + vec![ + dependency("library", DependencyType::Required), + dependency("optional", DependencyType::Optional), + ], + ); + let beta = version( + "library-beta", + "library", + VersionType::Beta, + "2026-03-01", + Vec::new(), + ); + let stable_old = version( + "library-1", + "library", + VersionType::Release, + "2026-01-01", + Vec::new(), + ); + let stable_new = version( + "library-2", + "library", + VersionType::Release, + "2026-02-01", + Vec::new(), + ); + let registry = provider(vec![root_version.clone(), beta, stable_old, stable_new]).registry(); + + let plan = resolve( + ®istry, + &ContentManifest::default(), + Path::new("/minecraft"), + &instance(), + root(root_version), + ) + .await + .unwrap(); + + assert_eq!(plan.items.len(), 2); + assert_eq!(plan.items[1].version.id, "library-2"); + assert!(plan.items[1].automatic_dependency); + assert_eq!(plan.optional_dependencies, 1); +} + +#[tokio::test] +async fn functional_mod_dependencies_are_not_cleanup_eligible() { + let root_version = version( + "root", + "root", + VersionType::Release, + "2026-01-01", + vec![dependency("sodium", DependencyType::Required)], + ); + let sodium = version( + "sodium", + "sodium", + VersionType::Release, + "2026-01-01", + Vec::new(), + ); + let mut fake = provider(vec![root_version.clone(), sodium]); + fake.categories.insert( + "sodium".to_owned(), + vec!["library".to_owned(), "optimization".to_owned()], + ); + let registry = fake.registry(); + + let plan = resolve( + ®istry, + &ContentManifest::default(), + Path::new("/minecraft"), + &instance(), + root(root_version), + ) + .await + .unwrap(); + + assert!(plan.items[1].automatic_dependency); + assert!(!plan.items[1].cleanup_eligible); +} + +#[tokio::test] +async fn missing_project_metadata_does_not_block_required_dependencies() { + let root_version = version( + "root", + "root", + VersionType::Release, + "2026-01-01", + vec![dependency("library", DependencyType::Required)], + ); + let library = version( + "library", + "library", + VersionType::Release, + "2026-01-01", + Vec::new(), + ); + let mut fake = provider(vec![root_version.clone(), library]); + fake.fail_project = Some("library".to_owned()); + let registry = fake.registry(); + + let plan = resolve( + ®istry, + &ContentManifest::default(), + Path::new("/minecraft"), + &instance(), + root(root_version), + ) + .await + .unwrap(); + + assert_eq!(plan.items[1].title, "library"); + assert!(plan.items[1].automatic_dependency); + assert!(!plan.items[1].cleanup_eligible); +} + +#[tokio::test] +async fn incompatible_installed_dependency_is_replaced_with_a_compatible_version() { + let root_version = version( + "root", + "root", + VersionType::Release, + "2026-01-01", + vec![dependency("library", DependencyType::Required)], + ); + let mut incompatible = version( + "library-old", + "library", + VersionType::Release, + "2026-01-01", + Vec::new(), + ); + incompatible.game_versions = vec!["1.20.1".to_owned()]; + let compatible = version( + "library-new", + "library", + VersionType::Release, + "2026-02-01", + Vec::new(), + ); + let registry = provider(vec![root_version.clone(), incompatible, compatible]).registry(); + let manifest = ContentManifest { + version: 1, + files: vec![installed_record("library", "library-old", true)], + }; + + let plan = resolve( + ®istry, + &manifest, + Path::new("/minecraft"), + &instance(), + root(root_version), + ) + .await + .unwrap(); + + assert_eq!(plan.items[1].version.id, "library-new"); + assert!(plan.items[1].replacement); +} + +#[tokio::test] +async fn missing_installed_dependency_version_is_replaced() { + let root_version = version( + "root", + "root", + VersionType::Release, + "2026-01-01", + vec![dependency("library", DependencyType::Required)], + ); + let compatible = version( + "library-new", + "library", + VersionType::Release, + "2026-02-01", + Vec::new(), + ); + let registry = provider(vec![root_version.clone(), compatible]).registry(); + let manifest = ContentManifest { + version: 1, + files: vec![installed_record("library", "deleted-version", true)], + }; + + let plan = resolve( + ®istry, + &manifest, + Path::new("/minecraft"), + &instance(), + root(root_version), + ) + .await + .unwrap(); + + assert_eq!(plan.items[1].version.id, "library-new"); + assert!(plan.items[1].replacement); +} + +#[tokio::test] +async fn disabled_dependency_is_not_treated_as_installed() { + let root_version = version( + "root", + "root", + VersionType::Release, + "2026-01-01", + vec![dependency("library", DependencyType::Required)], + ); + let library = version( + "library", + "library", + VersionType::Release, + "2026-01-01", + Vec::new(), + ); + let registry = provider(vec![root_version.clone(), library]).registry(); + let manifest = ContentManifest { + version: 1, + files: vec![installed_record("library", "library", false)], + }; + + let plan = resolve( + ®istry, + &manifest, + Path::new("/minecraft"), + &instance(), + root(root_version), + ) + .await + .unwrap(); + + assert!(plan.items[1].installed_path.is_none()); + assert!(!plan.items[1].replacement); +} + +#[tokio::test] +async fn superseded_version_dependencies_are_not_kept_in_the_plan() { + let root_version = version( + "root", + "root", + VersionType::Release, + "2026-01-01", + vec![ + dependency("library", DependencyType::Required), + VersionDependency { + version_id: Some("library-old".to_owned()), + project_id: Some("library".to_owned()), + file_name: None, + dependency_type: DependencyType::Required, + }, + ], + ); + let library_new = version( + "library-new", + "library", + VersionType::Release, + "2026-03-01", + vec![dependency("stale", DependencyType::Required)], + ); + let library_old = version( + "library-old", + "library", + VersionType::Release, + "2026-02-01", + vec![dependency("current", DependencyType::Required)], + ); + let stale = version( + "stale", + "stale", + VersionType::Release, + "2026-01-01", + Vec::new(), + ); + let current = version( + "current", + "current", + VersionType::Release, + "2026-01-01", + Vec::new(), + ); + let registry = provider(vec![ + root_version.clone(), + library_new, + library_old, + stale, + current, + ]) + .registry(); + + let plan = resolve( + ®istry, + &ContentManifest::default(), + Path::new("/minecraft"), + &instance(), + root(root_version), + ) + .await + .unwrap(); + + assert_eq!(plan.items[1].version.id, "library-old"); + assert!(plan.items.iter().any(|item| item.project_id == "current")); + assert!(!plan.items.iter().any(|item| item.project_id == "stale")); +} + +#[tokio::test] +async fn dependency_version_from_another_project_is_rejected() { + let root_version = version( + "root", + "root", + VersionType::Release, + "2026-01-01", + vec![VersionDependency { + version_id: Some("wrong-version".to_owned()), + project_id: Some("expected".to_owned()), + file_name: None, + dependency_type: DependencyType::Required, + }], + ); + let wrong = version( + "wrong-version", + "other", + VersionType::Release, + "2026-01-01", + Vec::new(), + ); + let registry = provider(vec![root_version.clone(), wrong]).registry(); + + let error = resolve( + ®istry, + &ContentManifest::default(), + Path::new("/minecraft"), + &instance(), + root(root_version), + ) + .await + .unwrap_err(); + + assert!(error.to_string().contains("not 'expected'")); +} + +#[tokio::test] +async fn exact_cross_provider_match_replaces_only_the_wrong_version() { + let root_version = version( + "root", + "root", + VersionType::Release, + "2026-01-01", + vec![VersionDependency { + version_id: Some("library-new".to_owned()), + project_id: Some("library".to_owned()), + file_name: None, + dependency_type: DependencyType::Required, + }], + ); + let library = version( + "library-new", + "library", + VersionType::Release, + "2026-02-01", + Vec::new(), + ); + let mut fake = provider(vec![root_version.clone(), library]); + fake.resolved.insert( + "mods/library.jar".to_owned(), + ProviderProject { + provider: "modrinth".to_owned(), + project_id: "library".to_owned(), + version_id: "library-old".to_owned(), + }, + ); + let registry = fake.registry(); + let manifest = ContentManifest { + version: 1, + files: vec![ContentFileRecord { + relative_path: PathBuf::from("mods/library.jar"), + kind: ContentKind::Mod, + enabled: true, + fingerprint: FileFingerprint { + size: 1, + modified_ns: 1, + hashes: Default::default(), + }, + resolution: Resolution::Resolved { + project: ProviderProject { + provider: "curseforge".to_owned(), + project_id: "cf-library".to_owned(), + version_id: "7".to_owned(), + }, + }, + provider_aliases: Vec::new(), + provider_checks: Vec::new(), + required_dependencies: Vec::new(), + automatic_dependency: false, + cleanup_eligible: false, + }], + }; + + let plan = resolve( + ®istry, + &manifest, + Path::new("/minecraft"), + &instance(), + root(root_version), + ) + .await + .unwrap(); + + assert!(plan.items[1].replacement); + assert_eq!( + plan.items[1].installed_path.as_deref(), + Some(Path::new("/minecraft/mods/library.jar")) + ); +} + +#[tokio::test] +async fn dependency_cycles_are_rejected_before_downloads() { + let root_version = version( + "root", + "root", + VersionType::Release, + "2026-01-01", + vec![dependency("library", DependencyType::Required)], + ); + let library = version( + "library", + "library", + VersionType::Release, + "2026-01-01", + vec![dependency("root", DependencyType::Required)], + ); + let registry = provider(vec![root_version.clone(), library]).registry(); + + let error = resolve( + ®istry, + &ContentManifest::default(), + Path::new("/minecraft"), + &instance(), + root(root_version), + ) + .await + .unwrap_err(); + + assert!(error.to_string().contains("cycle")); +} + +#[tokio::test] +async fn selected_version_is_refetched_before_resolving_dependencies() { + let fresh = version( + "root", + "root", + VersionType::Release, + "2026-01-01", + vec![dependency("library", DependencyType::Required)], + ); + let mut cached = fresh.clone(); + cached.dependencies.clear(); + let library = version( + "library", + "library", + VersionType::Release, + "2026-01-01", + Vec::new(), + ); + let registry = provider(vec![fresh, library]).registry(); + + let plan = resolve( + ®istry, + &ContentManifest::default(), + Path::new("/minecraft"), + &instance(), + root(cached), + ) + .await + .unwrap(); + + assert_eq!(plan.items.len(), 2); +} + +#[tokio::test] +async fn installed_incompatible_projects_block_the_plan() { + let root_version = version( + "root", + "root", + VersionType::Release, + "2026-01-01", + vec![dependency("conflict", DependencyType::Incompatible)], + ); + let conflict = version( + "conflict-version", + "conflict", + VersionType::Release, + "2026-01-01", + Vec::new(), + ); + let registry = provider(vec![root_version.clone(), conflict]).registry(); + let manifest = ContentManifest { + version: 1, + files: vec![ContentFileRecord { + relative_path: PathBuf::from("mods/conflict.jar"), + kind: ContentKind::Mod, + enabled: true, + fingerprint: FileFingerprint { + size: 1, + modified_ns: 1, + hashes: Default::default(), + }, + resolution: Resolution::Resolved { + project: ProviderProject { + provider: "modrinth".to_owned(), + project_id: "conflict".to_owned(), + version_id: "conflict-version".to_owned(), + }, + }, + provider_aliases: Vec::new(), + provider_checks: Vec::new(), + required_dependencies: Vec::new(), + automatic_dependency: false, + cleanup_eligible: false, + }], + }; + + let error = resolve( + ®istry, + &manifest, + Path::new("/minecraft"), + &instance(), + root(root_version), + ) + .await + .unwrap_err(); + + assert!(error.to_string().contains("incompatible")); +} + +#[tokio::test] +async fn dependency_install_commits_files_and_manifest_together() { + let temp = tempfile::tempdir().unwrap(); + let minecraft = temp.path().join("minecraft"); + let mods = minecraft.join("mods"); + let manifest_path = temp.path().join("manifest.json"); + let root_version = version( + "root", + "root", + VersionType::Release, + "2026-01-01", + vec![dependency("library", DependencyType::Required)], + ); + let library = version( + "library", + "library", + VersionType::Release, + "2026-01-01", + Vec::new(), + ); + let registry = provider(vec![root_version.clone(), library]).registry(); + let plan = resolve( + ®istry, + &ContentManifest::default(), + &minecraft, + &instance(), + root(root_version), + ) + .await + .unwrap(); + + let installed = install(®istry, &manifest_path, &minecraft, &plan) + .await + .unwrap(); + + assert_eq!(installed.root_path, mods.join("root.jar")); + assert!(mods.join("library.jar").exists()); + let manifest = ContentManifest::load(&manifest_path).unwrap(); + assert_eq!(manifest.files.len(), 2); + assert!( + manifest + .record(Path::new("mods/library.jar")) + .unwrap() + .automatic_dependency + ); + assert!( + manifest + .record(Path::new("mods/library.jar")) + .unwrap() + .cleanup_eligible + ); + assert_eq!( + manifest + .record(Path::new("mods/root.jar")) + .unwrap() + .required_dependencies[0] + .project_id, + "library" + ); +} + +#[tokio::test] +async fn failed_dependency_download_leaves_no_partial_install() { + let temp = tempfile::tempdir().unwrap(); + let minecraft = temp.path().join("minecraft"); + let mods = minecraft.join("mods"); + let manifest_path = temp.path().join("manifest.json"); + let root_version = version( + "root", + "root", + VersionType::Release, + "2026-01-01", + vec![dependency("library", DependencyType::Required)], + ); + let library = version( + "library", + "library", + VersionType::Release, + "2026-01-01", + Vec::new(), + ); + let mut fake = provider(vec![root_version.clone(), library]); + fake.fail_download = Some("library".to_owned()); + let registry = fake.registry(); + let plan = resolve( + ®istry, + &ContentManifest::default(), + &minecraft, + &instance(), + root(root_version), + ) + .await + .unwrap(); + + assert!( + install(®istry, &manifest_path, &minecraft, &plan) + .await + .is_err() + ); + assert!(!manifest_path.exists()); + assert_eq!(std::fs::read_dir(&mods).unwrap().count(), 0); +} diff --git a/src/instance/tests/content/icons.rs b/src/instance/tests/content/icons.rs new file mode 100644 index 0000000..aaf16b4 --- /dev/null +++ b/src/instance/tests/content/icons.rs @@ -0,0 +1,23 @@ +use super::*; + +#[test] +fn quadrant_raster_has_requested_dimensions() { + let image = + image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(2, 2, image::Rgb([12, 34, 56]))); + let rows = make_icon_quadrants_from_image(&image, 7, 3); + + assert_eq!(rows.len(), 3); + assert!(rows.iter().all(|row| row.len() == 7)); + assert!(rows.iter().flatten().all(|cell| cell.symbol == '\u{2588}')); +} + +#[test] +fn fallback_icon_is_square_and_contains_a_separate_question_mark_dot() { + let icon = fallback_icon(); + + assert_eq!(icon.len(), 3); + assert!(icon.iter().all(|row| row.len() == 6)); + assert_eq!(icon[2][2].bg_r, 45); + assert_eq!(icon[2][2].fg_r, 150); + assert_eq!(icon[2][3].fg_r, 150); +} diff --git a/src/instance/tests/content/manifest.rs b/src/instance/tests/content/manifest.rs new file mode 100644 index 0000000..00cf2da --- /dev/null +++ b/src/instance/tests/content/manifest.rs @@ -0,0 +1,279 @@ +use super::*; + +#[test] +fn curseforge_fingerprint_ignores_whitespace() { + let temp = tempfile::tempdir().unwrap(); + let compact = temp.path().join("compact.jar"); + let spaced = temp.path().join("spaced.jar"); + std::fs::write(&compact, b"abc").unwrap(); + std::fs::write(&spaced, b"a b\nc\r\t").unwrap(); + let compact = fingerprint(&compact).unwrap(); + assert_eq!(compact.hash("curseforge"), Some("1621425345")); + assert_eq!( + compact.hash("curseforge"), + fingerprint(&spaced).unwrap().hash("curseforge") + ); +} + +#[test] +fn manifest_round_trip_and_lookup_are_exact() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("rmcl/content/manifest.json"); + let record = ContentFileRecord { + relative_path: PathBuf::from("mods/fabric-api.jar"), + kind: ContentKind::Mod, + enabled: true, + fingerprint: FileFingerprint { + size: 3, + modified_ns: 4, + hashes: BTreeMap::from([("sha1".to_owned(), "abc".to_owned())]), + }, + resolution: Resolution::Resolved { + project: ProviderProject { + provider: "modrinth".to_owned(), + project_id: "P7dR8mSH".to_owned(), + version_id: "version".to_owned(), + }, + }, + provider_aliases: Vec::new(), + provider_checks: Vec::new(), + required_dependencies: Vec::new(), + automatic_dependency: false, + cleanup_eligible: false, + }; + let mut manifest = ContentManifest::default(); + manifest.upsert(record); + manifest.save(&path).unwrap(); + + let loaded = ContentManifest::load(&path).unwrap(); + assert_eq!( + loaded.resolved_project_path("modrinth", "P7dR8mSH", Path::new("/instance/minecraft")), + Some(PathBuf::from("/instance/minecraft/mods/fabric-api.jar")) + ); + assert!( + loaded + .resolved_project_path("modrinth", "fabric-api", Path::new("/minecraft")) + .is_none() + ); +} + +#[test] +fn fingerprint_contains_both_modrinth_hashes() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("example.jar"); + std::fs::write(&path, b"abc").unwrap(); + let fingerprint = fingerprint(&path).unwrap(); + assert_eq!( + fingerprint.hash("sha1"), + Some("a9993e364706816aba3e25717850c26c9cd0d89d") + ); + assert_eq!(fingerprint.hash("sha512").unwrap().len(), 128); +} + +#[test] +fn renaming_a_record_preserves_its_resolution() { + let mut manifest = ContentManifest::default(); + manifest.upsert(ContentFileRecord { + relative_path: PathBuf::from("mods/example.jar"), + kind: ContentKind::Mod, + enabled: true, + fingerprint: FileFingerprint { + size: 3, + modified_ns: 4, + hashes: BTreeMap::new(), + }, + resolution: Resolution::Resolved { + project: ProviderProject { + provider: "modrinth".to_owned(), + project_id: "project".to_owned(), + version_id: "version".to_owned(), + }, + }, + provider_aliases: Vec::new(), + provider_checks: Vec::new(), + required_dependencies: Vec::new(), + automatic_dependency: false, + cleanup_eligible: false, + }); + + assert!(manifest.rename_record( + Path::new("mods/example.jar"), + Path::new("mods/example.jar.disabled"), + false, + )); + let record = manifest + .record(Path::new("mods/example.jar.disabled")) + .unwrap(); + assert!(!record.enabled); + assert!(matches!(record.resolution, Resolution::Resolved { .. })); +} + +fn managed_record( + path: &str, + project_id: &str, + automatic_dependency: bool, + required_dependencies: Vec, +) -> ContentFileRecord { + ContentFileRecord { + relative_path: PathBuf::from(path), + kind: ContentKind::Mod, + enabled: true, + fingerprint: FileFingerprint { + size: 1, + modified_ns: 1, + hashes: BTreeMap::new(), + }, + resolution: Resolution::Resolved { + project: ProviderProject { + provider: "modrinth".to_owned(), + project_id: project_id.to_owned(), + version_id: format!("{project_id}-version"), + }, + }, + provider_aliases: Vec::new(), + provider_checks: Vec::new(), + required_dependencies, + automatic_dependency, + cleanup_eligible: automatic_dependency, + } +} + +fn dependency(project_id: &str) -> ProviderProject { + ProviderProject { + provider: "modrinth".to_owned(), + project_id: project_id.to_owned(), + version_id: format!("{project_id}-version"), + } +} + +#[test] +fn provider_aliases_match_the_same_installed_record() { + let mut record = managed_record("mods/library.jar", "curseforge-library", false, Vec::new()); + record.provider_aliases.push(ProviderProject { + provider: "modrinth".to_owned(), + project_id: "modrinth-library".to_owned(), + version_id: "modrinth-version".to_owned(), + }); + let manifest = ContentManifest { + version: 1, + files: vec![record], + }; + + assert!( + manifest + .resolved_project_record("modrinth", "modrinth-library") + .is_some() + ); +} + +#[test] +fn orphan_cleanup_keeps_shared_and_user_managed_libraries() { + let manifest = ContentManifest { + version: 1, + files: vec![ + managed_record("mods/first.jar", "first", false, vec![dependency("shared")]), + managed_record( + "mods/second.jar", + "second", + false, + vec![dependency("shared"), dependency("explicit")], + ), + managed_record("mods/shared.jar", "shared", true, Vec::new()), + managed_record("mods/explicit.jar", "explicit", false, Vec::new()), + ], + }; + + assert_eq!( + manifest.orphaned_dependencies_after_removing(Path::new("mods/first.jar")), + Vec::::new() + ); + assert_eq!( + manifest.orphaned_dependencies_after_removing(Path::new("mods/second.jar")), + Vec::::new() + ); +} + +#[test] +fn orphan_cleanup_follows_automatic_dependency_chains() { + let manifest = ContentManifest { + version: 1, + files: vec![ + managed_record("mods/root.jar", "root", false, vec![dependency("library")]), + managed_record( + "mods/library.jar", + "library", + true, + vec![dependency("nested")], + ), + managed_record("mods/nested.jar", "nested", true, Vec::new()), + ], + }; + + assert_eq!( + manifest.orphaned_dependencies_after_removing(Path::new("mods/root.jar")), + vec![ + PathBuf::from("mods/library.jar"), + PathBuf::from("mods/nested.jar") + ] + ); +} + +#[test] +fn orphan_cleanup_keeps_automatic_non_library_dependencies() { + let mut dependency_record = managed_record("mods/sodium.jar", "sodium", true, Vec::new()); + dependency_record.cleanup_eligible = false; + let manifest = ContentManifest { + version: 1, + files: vec![ + managed_record("mods/root.jar", "root", false, vec![dependency("sodium")]), + dependency_record, + ], + }; + + assert!( + manifest + .orphaned_dependencies_after_removing(Path::new("mods/root.jar")) + .is_empty() + ); +} + +#[test] +fn datapack_dependencies_are_scoped_to_their_world() { + let mut first_root = managed_record( + "saves/first/datapacks/root.zip", + "root", + false, + vec![dependency("library")], + ); + first_root.kind = ContentKind::DataPack; + let mut second_root = managed_record( + "saves/second/datapacks/root.zip", + "root", + false, + vec![dependency("library")], + ); + second_root.kind = ContentKind::DataPack; + let mut first_library = managed_record( + "saves/first/datapacks/library.zip", + "library", + true, + Vec::new(), + ); + first_library.kind = ContentKind::DataPack; + let mut second_library = managed_record( + "saves/second/datapacks/library.zip", + "library", + true, + Vec::new(), + ); + second_library.kind = ContentKind::DataPack; + let manifest = ContentManifest { + version: 1, + files: vec![first_root, second_root, first_library, second_library], + }; + + assert_eq!( + manifest.orphaned_dependencies_after_removing(Path::new("saves/first/datapacks/root.zip")), + vec![PathBuf::from("saves/first/datapacks/library.zip")] + ); +} diff --git a/src/instance/tests/content/mods.rs b/src/instance/tests/content/mods.rs new file mode 100644 index 0000000..5186bc9 --- /dev/null +++ b/src/instance/tests/content/mods.rs @@ -0,0 +1,374 @@ +use super::*; +use crate::instance::content::entry::{ContentEntry, toggle_entry}; +use std::path::PathBuf; + +fn setup_mods_dir(tmp: &Path, instance: &str) -> PathBuf { + let dir = tmp + .join(instance) + .join(crate::storage::MINECRAFT_DIR_NAME) + .join("mods"); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +#[test] +fn scan_mods_empty_dir() { + let tmp = tempfile::tempdir().unwrap(); + setup_mods_dir(tmp.path(), "inst"); + let mods = scan_mods(tmp.path(), "inst"); + assert!(mods.is_empty()); +} + +#[test] +fn scan_mods_missing_dir_returns_empty() { + let tmp = tempfile::tempdir().unwrap(); + let mods = scan_mods(tmp.path(), "ghost"); + assert!(mods.is_empty()); +} + +#[test] +fn scan_mods_enabled_disabled_flags() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_mods_dir(tmp.path(), "inst"); + std::fs::write(dir.join("active.jar"), b"PK\x03\x04").unwrap(); + std::fs::write(dir.join("inactive.jar.disabled"), b"PK\x03\x04").unwrap(); + let mods = scan_mods(tmp.path(), "inst"); + let active = mods.iter().find(|m| m.file_stem == "active").unwrap(); + let inactive = mods.iter().find(|m| m.file_stem == "inactive").unwrap(); + assert!(active.enabled); + assert!(!inactive.enabled); +} + +#[test] +fn scan_mods_ignores_non_jar() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_mods_dir(tmp.path(), "inst"); + std::fs::write(dir.join("readme.txt"), "not a mod").unwrap(); + std::fs::write(dir.join("config.json"), "{}").unwrap(); + std::fs::write(dir.join("real.jar"), b"PK\x03\x04").unwrap(); + let mods = scan_mods(tmp.path(), "inst"); + assert_eq!(mods.len(), 1); +} + +#[test] +fn scan_mods_sorted_case_insensitive() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_mods_dir(tmp.path(), "inst"); + std::fs::write(dir.join("Zebra.jar"), b"PK\x03\x04").unwrap(); + std::fs::write(dir.join("alpha.jar"), b"PK\x03\x04").unwrap(); + std::fs::write(dir.join("Beta.jar"), b"PK\x03\x04").unwrap(); + let mods = scan_mods(tmp.path(), "inst"); + let names: Vec<&str> = mods.iter().map(|m| m.name.as_str()).collect(); + assert_eq!(names, vec!["alpha", "Beta", "Zebra"]); +} + +#[test] +fn toggle_entry_enable() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_mods_dir(tmp.path(), "inst"); + let disabled_path = dir.join("mymod.jar.disabled"); + std::fs::write(&disabled_path, b"PK\x03\x04").unwrap(); + + let entry = ContentEntry { + file_stem: "mymod".to_string(), + name: "mymod".to_string(), + source_slug: None, + installed_path: None, + provider_project: None, + world_details: None, + title_suffix: None, + footer_label: None, + footer_change: None, + description: String::new(), + enabled: false, + icon_bytes: None, + provider_icon: false, + provider_description: false, + path: disabled_path.clone(), + icon_lines: None, + }; + + toggle_entry(&entry).unwrap(); + assert!(!disabled_path.exists()); + assert!(dir.join("mymod.jar").exists()); +} + +use std::io::Write as _; + +fn make_jar(dir: &Path, name: &str, entries: &[(&str, &[u8])]) { + let path = dir.join(name); + let file = std::fs::File::create(&path).unwrap(); + let mut zip = zip::ZipWriter::new(file); + let options: zip::write::FileOptions<()> = + zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Stored); + for (entry_name, data) in entries { + zip.start_file(*entry_name, options).unwrap(); + zip.write_all(data).unwrap(); + } + zip.finish().unwrap(); +} + +#[test] +fn scan_mods_reads_fabric_metadata() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_mods_dir(tmp.path(), "inst"); + let meta = + r#"{"name":"Fabric Mod","description":"A fabric mod","version":"1.2.3","icon":"icon.png"}"#; + make_jar( + &dir, + "fabric-mod.jar", + &[("fabric.mod.json", meta.as_bytes())], + ); + let mods = scan_mods(tmp.path(), "inst"); + assert_eq!(mods.len(), 1); + assert_eq!(mods[0].name, "Fabric Mod"); + assert_eq!(mods[0].description, "A fabric mod"); + assert_eq!(mods[0].footer_label.as_deref(), Some("1.2.3")); +} + +#[test] +fn scan_mods_reads_quilt_metadata() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_mods_dir(tmp.path(), "inst"); + let meta = r#"{"quilt_loader":{"metadata":{"name":"Quilt Mod","description":"A quilt mod"}}}"#; + make_jar( + &dir, + "quilt-mod.jar", + &[("quilt.mod.json", meta.as_bytes())], + ); + let mods = scan_mods(tmp.path(), "inst"); + assert_eq!(mods.len(), 1); + assert_eq!(mods[0].name, "Quilt Mod"); + assert_eq!(mods[0].description, "A quilt mod"); +} + +#[test] +fn scan_mods_reads_forge_toml_metadata() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_mods_dir(tmp.path(), "inst"); + let meta = r#" +logoFile = "logo.png" + +[[mods]] +displayName = "Forge Mod" +description = "A forge mod" +"#; + make_jar( + &dir, + "forge-mod.jar", + &[("META-INF/mods.toml", meta.as_bytes())], + ); + let mods = scan_mods(tmp.path(), "inst"); + assert_eq!(mods.len(), 1); + assert_eq!(mods[0].name, "Forge Mod"); + assert_eq!(mods[0].description, "A forge mod"); +} + +#[test] +fn scan_mods_reads_neoforge_toml_metadata() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_mods_dir(tmp.path(), "inst"); + let meta = r#" +logoFile = "logo.png" + +[[mods]] +displayName = "NeoForge Mod" +description = "A neoforge mod" +"#; + make_jar( + &dir, + "neoforge-mod.jar", + &[("META-INF/neoforge.mods.toml", meta.as_bytes())], + ); + let mods = scan_mods(tmp.path(), "inst"); + assert_eq!(mods.len(), 1); + assert_eq!(mods[0].name, "NeoForge Mod"); + assert_eq!(mods[0].description, "A neoforge mod"); +} + +#[test] +fn scan_mods_reads_mcmod_info_array() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_mods_dir(tmp.path(), "inst"); + let meta = r#"[{"name":"Legacy Mod","description":"An old forge mod"}]"#; + make_jar(&dir, "legacy-mod.jar", &[("mcmod.info", meta.as_bytes())]); + let mods = scan_mods(tmp.path(), "inst"); + assert_eq!(mods.len(), 1); + assert_eq!(mods[0].name, "Legacy Mod"); + assert_eq!(mods[0].description, "An old forge mod"); +} + +#[test] +fn scan_mods_reads_mcmod_info_modlist() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_mods_dir(tmp.path(), "inst"); + let meta = r#"{"modList":[{"name":"Wrapped Mod","description":"Has modList wrapper"}]}"#; + make_jar(&dir, "wrapped-mod.jar", &[("mcmod.info", meta.as_bytes())]); + let mods = scan_mods(tmp.path(), "inst"); + assert_eq!(mods.len(), 1); + assert_eq!(mods[0].name, "Wrapped Mod"); + assert_eq!(mods[0].description, "Has modList wrapper"); +} + +#[test] +fn scan_mods_prefers_fabric_over_forge() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_mods_dir(tmp.path(), "inst"); + let fabric = r#"{"name":"Fabric Name","description":"fabric desc"}"#; + let forge = "[[mods]]\ndisplayName = \"Forge Name\"\ndescription = \"forge desc\"\n"; + make_jar( + &dir, + "multi.jar", + &[ + ("fabric.mod.json", fabric.as_bytes()), + ("META-INF/mods.toml", forge.as_bytes()), + ], + ); + let mods = scan_mods(tmp.path(), "inst"); + assert_eq!(mods[0].name, "Fabric Name"); +} + +#[test] +fn scan_mods_prefers_quilt_over_forge() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_mods_dir(tmp.path(), "inst"); + let quilt = r#"{"quilt_loader":{"id":"x","version":"1","metadata":{"name":"Quilt Name","description":"quilt desc"}}}"#; + let forge = "[[mods]]\ndisplayName = \"Forge Name\"\ndescription = \"forge desc\"\n"; + make_jar( + &dir, + "quilt-over-forge.jar", + &[ + ("quilt.mod.json", quilt.as_bytes()), + ("META-INF/mods.toml", forge.as_bytes()), + ], + ); + let mods = scan_mods(tmp.path(), "inst"); + assert_eq!(mods[0].name, "Quilt Name"); +} + +#[test] +fn scan_mods_prefers_forge_toml_over_mcmod_info() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_mods_dir(tmp.path(), "inst"); + let forge = "[[mods]]\ndisplayName = \"Forge Name\"\ndescription = \"forge desc\"\n"; + let mcmod = r#"[{"modid":"legacy","name":"Legacy Name","description":"legacy desc"}]"#; + make_jar( + &dir, + "forge-over-legacy.jar", + &[ + ("META-INF/mods.toml", forge.as_bytes()), + ("mcmod.info", mcmod.as_bytes()), + ], + ); + let mods = scan_mods(tmp.path(), "inst"); + assert_eq!(mods[0].name, "Forge Name"); +} + +// when both neoforge.mods.toml and mods.toml exist in the same jar (the +// shape a dual-format mod might ship), the neoforge one wins because +// read_forge_toml_meta tries it first via .or_else. +#[test] +fn scan_mods_prefers_neoforge_toml_over_forge_toml() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_mods_dir(tmp.path(), "inst"); + let neoforge = "[[mods]]\ndisplayName = \"NeoForge Name\"\ndescription = \"neoforge desc\"\n"; + let forge = "[[mods]]\ndisplayName = \"Forge Name\"\ndescription = \"forge desc\"\n"; + make_jar( + &dir, + "neoforge-over-forge.jar", + &[ + ("META-INF/neoforge.mods.toml", neoforge.as_bytes()), + ("META-INF/mods.toml", forge.as_bytes()), + ], + ); + let mods = scan_mods(tmp.path(), "inst"); + assert_eq!(mods[0].name, "NeoForge Name"); +} + +// a mods.toml with logoFile but no [[mods]] array (e.g. a dependency-only +// library jar) should still surface as a scanned mod with empty name + +// description but with the icon resolved. +#[test] +fn scan_mods_reads_mods_toml_without_mods_array() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_mods_dir(tmp.path(), "inst"); + let mods_toml = "logoFile = \"icon.png\"\n"; + let png_bytes = b"\x89PNG fake icon"; + make_jar( + &dir, + "lib-only.jar", + &[ + ("META-INF/mods.toml", mods_toml.as_bytes()), + ("icon.png", png_bytes), + ], + ); + let mods = scan_mods(tmp.path(), "inst"); + assert_eq!(mods.len(), 1); + // file-stem fallback when metadata name is empty - covered here + // because no other test exercises an empty-name + present-logo combo. + assert_eq!(mods[0].name, "lib-only"); + assert_eq!(mods[0].icon_bytes.as_deref(), Some(png_bytes.as_slice())); +} + +#[test] +fn scan_mods_fallback_icon_paths() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_mods_dir(tmp.path(), "inst"); + let png_bytes = b"\x89PNG fake"; + make_jar(&dir, "no-meta.jar", &[("logo.png", png_bytes)]); + let mods = scan_mods(tmp.path(), "inst"); + assert_eq!(mods.len(), 1); + assert_eq!(mods[0].name, "no-meta"); + assert_eq!(mods[0].icon_bytes.as_deref(), Some(png_bytes.as_slice())); +} + +#[test] +fn icon_path_from_value_string() { + let val = serde_json::json!("assets/icon.png"); + assert_eq!(icon_path_from_value(&val), "assets/icon.png"); +} + +#[test] +fn icon_path_from_value_map() { + // serde_json::Map is a BTreeMap, so iteration is sorted by key. + // "128" sorts before "64" lexicographically, so the first value wins. + let val = serde_json::json!({"64": "icon_64.png", "128": "icon_128.png"}); + assert_eq!(icon_path_from_value(&val), "icon_128.png"); +} + +#[test] +fn icon_path_from_value_null() { + let val = serde_json::Value::Null; + assert_eq!(icon_path_from_value(&val), ""); +} + +#[test] +fn toggle_entry_disable() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_mods_dir(tmp.path(), "inst"); + let enabled_path = dir.join("mymod.jar"); + std::fs::write(&enabled_path, b"PK\x03\x04").unwrap(); + + let entry = ContentEntry { + file_stem: "mymod".to_string(), + name: "mymod".to_string(), + source_slug: None, + installed_path: None, + provider_project: None, + world_details: None, + title_suffix: None, + footer_label: None, + footer_change: None, + description: String::new(), + enabled: true, + icon_bytes: None, + provider_icon: false, + provider_description: false, + path: enabled_path.clone(), + icon_lines: None, + }; + + toggle_entry(&entry).unwrap(); + assert!(!enabled_path.exists()); + assert!(dir.join("mymod.jar.disabled").exists()); +} diff --git a/src/instance/tests/content/provider.rs b/src/instance/tests/content/provider.rs new file mode 100644 index 0000000..acb56a5 --- /dev/null +++ b/src/instance/tests/content/provider.rs @@ -0,0 +1,28 @@ +use super::*; + +#[test] +fn registry_falls_back_to_first_capable_provider() { + let registry = ProviderRegistry::modrinth(crate::net::HttpClient::new()); + assert_eq!(registry.preferred("unknown").unwrap().id(), "modrinth"); +} + +#[test] +fn update_check_requires_the_installed_version_behind_a_newer_result() { + let version = |id: &str| VersionInfo { + id: id.to_owned(), + project_id: "project".to_owned(), + name: id.to_owned(), + version_number: id.to_owned(), + game_versions: vec!["1.21.1".to_owned()], + loaders: vec!["fabric".to_owned()], + version_type: crate::net::modrinth::VersionType::Release, + dependencies: Vec::new(), + date_published: String::new(), + files: Vec::new(), + }; + let versions = vec![version("new"), version("installed")]; + + assert!(has_newer_compatible_version(&versions, "installed")); + assert!(!has_newer_compatible_version(&versions, "new")); + assert!(!has_newer_compatible_version(&versions, "missing")); +} diff --git a/src/instance/tests/content/reconcile.rs b/src/instance/tests/content/reconcile.rs new file mode 100644 index 0000000..63c1b8b --- /dev/null +++ b/src/instance/tests/content/reconcile.rs @@ -0,0 +1,218 @@ +use super::*; + +struct NoopProgress; + +impl InventoryProgress for NoopProgress { + fn set_sub_action(&self, _text: &str) {} + + fn set_progress(&self, _current: u64, _total: u64) {} +} + +fn resolved_record() -> ContentFileRecord { + ContentFileRecord { + relative_path: PathBuf::from("mods/example.jar"), + kind: ContentKind::Mod, + enabled: true, + fingerprint: FileFingerprint { + size: 1, + modified_ns: 1, + hashes: Default::default(), + }, + resolution: Resolution::Resolved { + project: ProviderProject { + provider: "modrinth".to_owned(), + project_id: "example".to_owned(), + version_id: "1".to_owned(), + }, + }, + provider_aliases: Vec::new(), + provider_checks: Vec::new(), + required_dependencies: Vec::new(), + automatic_dependency: false, + cleanup_eligible: false, + } +} + +fn job(name: &str) -> ReconcileJob { + ReconcileJob { + instance: InstanceConfig { + name: name.to_owned(), + game_version: "1.21.1".to_owned(), + loader: crate::instance::ModLoader::Fabric, + loader_version: None, + created: chrono::DateTime::UNIX_EPOCH, + last_played: None, + java_path: None, + memory_max: None, + memory_min: None, + jvm_args: Vec::new(), + resolution: None, + config_sync_profile: None, + modpack_source: None, + }, + instances_dir: PathBuf::new(), + client: crate::net::HttpClient::new(), + } +} + +#[test] +fn coordinator_queues_instances_once_and_preserves_order() { + let mut coordinator = ReconcileCoordinator::default(); + let one = ("one".to_owned(), chrono::DateTime::UNIX_EPOCH); + assert!(coordinator.enqueue(job("one"), false)); + assert!(!coordinator.enqueue(job("two"), false)); + assert!(!coordinator.enqueue(job("one"), false)); + assert!(!coordinator.rerun.contains(&one)); + assert!(!coordinator.enqueue(job("one"), true)); + assert_eq!(coordinator.queue.len(), 2); + assert!(coordinator.rerun.contains(&one)); + assert_eq!(coordinator.queue.pop_front().unwrap().instance.name, "one"); + assert_eq!(coordinator.queue.pop_front().unwrap().instance.name, "two"); +} + +#[test] +fn coordinator_does_not_merge_recreated_instances_with_the_same_name() { + let mut coordinator = ReconcileCoordinator::default(); + let first = job("same"); + let mut recreated = job("same"); + recreated.instance.created += chrono::TimeDelta::seconds(1); + + assert!(coordinator.enqueue(first, false)); + assert!(!coordinator.enqueue(recreated, false)); + assert_eq!(coordinator.queue.len(), 2); +} + +#[test] +fn oversized_content_is_kept_without_hashing_or_provider_query() { + let temp = tempfile::tempdir().unwrap(); + let minecraft = temp.path().join("minecraft"); + let resource_packs = minecraft.join("resourcepacks"); + std::fs::create_dir_all(&resource_packs).unwrap(); + let pack = resource_packs.join("large.zip"); + let file = std::fs::File::create(&pack).unwrap(); + file.set_len(2 * 1024 * 1024).unwrap(); + let manifest_path = temp.path().join("manifest.json"); + + let inventory = reconcile_inventory(&manifest_path, &minecraft, 24, 1, &NoopProgress).unwrap(); + + assert!(inventory.queries.is_empty()); + assert_eq!(inventory.manifest.files.len(), 1); + assert!(inventory.manifest.files[0].fingerprint.hashes.is_empty()); + assert!(matches!( + inventory.manifest.files[0].resolution, + Resolution::Unmatched { .. } + )); +} + +#[test] +fn unchanged_saved_index_reuses_fingerprint_and_skips_provider_query() { + let temp = tempfile::tempdir().unwrap(); + let minecraft = temp.path().join("minecraft"); + let mods = minecraft.join("mods"); + std::fs::create_dir_all(&mods).unwrap(); + std::fs::write(mods.join("example.jar"), b"example").unwrap(); + let manifest_path = temp.path().join("manifest.json"); + + let mut first = + reconcile_inventory(&manifest_path, &minecraft, 24, 512, &NoopProgress).unwrap(); + assert_eq!(first.queries.len(), 1); + let fingerprint = first.manifest.files[0].fingerprint.clone(); + first.manifest.files[0].resolution = Resolution::Unmatched { + checked_at: chrono::Utc::now().timestamp(), + providers: vec!["modrinth".to_owned(), "curseforge".to_owned()], + }; + first.manifest.save(&manifest_path).unwrap(); + + let second = reconcile_inventory(&manifest_path, &minecraft, 24, 512, &NoopProgress).unwrap(); + assert!(second.queries.is_empty()); + assert_eq!(second.manifest.files[0].fingerprint, fingerprint); +} + +#[test] +fn newly_configured_provider_retries_an_unmatched_record() { + let mut record = resolved_record(); + + assert!(provider_was_not_checked(&record, "curseforge")); + assert!(!provider_was_not_checked(&record, "modrinth")); + record.provider_checks.push("curseforge".to_owned()); + assert!(!provider_was_not_checked(&record, "curseforge")); +} + +#[test] +fn reconciliation_saves_provider_aliases_for_an_existing_resolution() { + let temp = tempfile::tempdir().unwrap(); + let manifest_path = temp.path().join("manifest.json"); + let current = ContentManifest { + version: 1, + files: vec![resolved_record()], + }; + current.save(&manifest_path).unwrap(); + + let mut enriched = resolved_record(); + enriched.provider_aliases.push(ProviderProject { + provider: "curseforge".to_owned(), + project_id: "42".to_owned(), + version_id: "84".to_owned(), + }); + enriched.provider_checks = vec!["modrinth".to_owned(), "curseforge".to_owned()]; + let saved = save_reconciled_manifest( + &manifest_path, + &temp.path().join("minecraft"), + ContentManifest { + version: 1, + files: vec![enriched.clone()], + }, + ) + .unwrap(); + + assert_eq!(saved.files, vec![enriched]); +} + +#[test] +fn directory_packs_are_indexed_without_provider_queries() { + let temp = tempfile::tempdir().unwrap(); + let minecraft = temp.path().join("minecraft"); + let pack = minecraft.join("resourcepacks/example"); + std::fs::create_dir_all(&pack).unwrap(); + std::fs::write(pack.join("pack.mcmeta"), b"{}").unwrap(); + + let inventory = reconcile_inventory( + &temp.path().join("manifest.json"), + &minecraft, + 24, + 512, + &NoopProgress, + ) + .unwrap(); + + assert!(inventory.queries.is_empty()); + assert_eq!( + inventory.manifest.files[0].relative_path, + PathBuf::from("resourcepacks/example") + ); +} + +#[test] +fn datapacks_are_indexed_under_their_world() { + let temp = tempfile::tempdir().unwrap(); + let minecraft = temp.path().join("minecraft"); + let datapacks = minecraft.join("saves/world/datapacks"); + std::fs::create_dir_all(datapacks.join("folder-pack")).unwrap(); + std::fs::write(datapacks.join("zipped-pack.zip"), b"zip").unwrap(); + + let inventory = reconcile_inventory( + &temp.path().join("manifest.json"), + &minecraft, + 24, + 512, + &NoopProgress, + ) + .unwrap(); + + assert_eq!(inventory.manifest.files.len(), 2); + assert!(inventory.manifest.files.iter().all(|record| { + record.kind == ContentKind::DataPack + && record.relative_path.starts_with("saves/world/datapacks") + })); + assert_eq!(inventory.queries.len(), 1); +} diff --git a/src/instance/tests/content/resource_packs.rs b/src/instance/tests/content/resource_packs.rs new file mode 100644 index 0000000..bfabac5 --- /dev/null +++ b/src/instance/tests/content/resource_packs.rs @@ -0,0 +1,18 @@ +use super::super::packs::extract_description; +use serde_json::json; + +// every case exercises a distinct match arm in extract_description. +#[rstest::rstest] +#[case::string(json!("Simple pack"), "Simple pack")] +#[case::object_with_text(json!({"text": "Hello world"}), "Hello world")] +#[case::object_without_text(json!({"color": "red"}), "")] +#[case::array_of_strings(json!(["Hello", " ", "world"]), "Hello world")] +#[case::array_of_objects(json!([{"text": "A"}, {"text": "B"}]), "AB")] +#[case::mixed_array(json!(["Prefix ", {"text": "suffix"}]), "Prefix suffix")] +#[case::empty_array(json!([]), "")] +#[case::null(serde_json::Value::Null, "")] +#[case::number(json!(42), "")] +#[case::bool(json!(true), "")] +fn extract_description_handles(#[case] input: serde_json::Value, #[case] expected: &str) { + assert_eq!(extract_description(&input), expected); +} diff --git a/src/instance/tests/content/worlds.rs b/src/instance/tests/content/worlds.rs new file mode 100644 index 0000000..46c1fd1 --- /dev/null +++ b/src/instance/tests/content/worlds.rs @@ -0,0 +1,71 @@ +use std::io::Write; + +use flate2::{Compression, write::GzEncoder}; + +use super::*; + +#[test] +fn world_scan_uses_level_dat_metadata_and_falls_back_cleanly() { + let temp = tempfile::tempdir().unwrap(); + let world = temp.path().join("world-folder"); + std::fs::create_dir_all(world.join("region")).unwrap(); + std::fs::write(world.join("region/r.0.0.mca"), vec![0; 2048]).unwrap(); + + let nbt = fastnbt::nbt!({ + "Data": { + "LevelName": "Display World", + "GameType": 0, + "hardcore": 1_i8, + "Difficulty": 3_i8, + "allowCommands": 0_i8, + "LastPlayed": 1_700_000_000_000_i64, + "Version": { "Name": "1.21.1" }, + "DataVersion": 3955, + } + }); + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder + .write_all(&fastnbt::to_bytes(&nbt).unwrap()) + .unwrap(); + std::fs::write(world.join("level.dat"), encoder.finish().unwrap()).unwrap(); + + let entry = scan_one_world(&world, "world-folder", true); + assert_eq!(entry.name, "Display World"); + let details = entry.world_details.as_ref().unwrap(); + assert_eq!(details.game_mode, Some(WorldGameMode::Hardcore)); + assert_eq!( + details.last_played, + chrono::DateTime::from_timestamp(1_700_000_000, 0) + ); + assert_eq!(details.minecraft_version.as_deref(), Some("1.21.1")); + assert_eq!(details.size.as_deref(), Some("2.2 KB")); + assert!(entry.description.is_empty()); + + std::fs::write(world.join("level.dat"), b"not nbt").unwrap(); + let fallback = scan_one_world(&world, "world-folder", true); + assert_eq!(fallback.name, "world-folder"); + assert_eq!(fallback.title_suffix, None); + assert_eq!( + fallback + .world_details + .as_ref() + .and_then(|details| details.size.as_deref()), + Some("2.0 KB") + ); +} + +#[test] +fn world_scan_lists_directory_and_zip_datapacks() { + let temp = tempfile::tempdir().unwrap(); + let world = temp.path().join("world"); + std::fs::create_dir_all(world.join("datapacks/folder-pack")).unwrap(); + std::fs::write(world.join("datapacks/zipped-pack.zip"), b"zip").unwrap(); + std::fs::write(world.join("datapacks/disabled-pack.zip.disabled"), b"zip").unwrap(); + + let details = scan_one_world(&world, "world", true).world_details.unwrap(); + + assert_eq!( + details.datapacks, + ["disabled-pack", "folder-pack", "zipped-pack"] + ); +} diff --git a/src/instance/tests/desktop.rs b/src/instance/tests/desktop.rs new file mode 100644 index 0000000..9c97869 --- /dev/null +++ b/src/instance/tests/desktop.rs @@ -0,0 +1,30 @@ +use super::*; + +#[test] +fn sanitize_keeps_alphanumeric() { + assert_eq!(sanitize("my-instance_123"), "my-instance_123"); +} + +#[test] +fn sanitize_replaces_special_chars() { + assert_eq!(sanitize("my instance!"), "my_instance_"); + assert_eq!(sanitize("path/traversal"), "path_traversal"); +} + +#[test] +#[cfg(target_os = "linux")] +fn build_content_linux() { + let content = build_content("TestPack", None); + assert!(content.contains("Name=Minecraft - TestPack")); + assert!(content.contains("Exec=rmcl instance launch \"TestPack\"")); + assert!(content.contains("Terminal=false")); + assert!(content.contains("Categories=Game;")); +} + +#[test] +#[cfg(target_os = "linux")] +fn build_content_linux_with_icon() { + let icon = PathBuf::from("/tmp/icon.png"); + let content = build_content("TestPack", Some(&icon)); + assert!(content.contains("Icon=/tmp/icon.png")); +} diff --git a/src/instance/tests/import/curseforge.rs b/src/instance/tests/import/curseforge.rs new file mode 100644 index 0000000..f4eaf4a --- /dev/null +++ b/src/instance/tests/import/curseforge.rs @@ -0,0 +1,65 @@ +use super::*; + +#[test] +fn curseforge_manifest_builds_import_summary() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("pack.zip"); + let file = std::fs::File::create(&path).unwrap(); + let mut zip = zip::ZipWriter::new(file); + zip.start_file("manifest.json", zip::write::SimpleFileOptions::default()) + .unwrap(); + use std::io::Write; + zip.write_all( + br#"{ + "name":"Example", + "version":"1.0", + "minecraft":{ + "version":"1.20.1", + "modLoaders":[{"id":"forge-47.2.0","primary":true}] + }, + "files":[{"projectID":1,"fileID":2,"required":true}], + "overrides":"overrides" + }"#, + ) + .unwrap(); + zip.start_file( + "overrides/config/example.txt", + zip::write::SimpleFileOptions::default(), + ) + .unwrap(); + zip.write_all(b"value").unwrap(); + zip.finish().unwrap(); + + assert_eq!( + super::super::detect_format(&path).unwrap(), + PackFormat::CurseForge + ); + let summary = build_summary(&path).unwrap(); + assert_eq!(summary.name, "Example"); + assert_eq!(summary.loader, ModLoader::Forge); + assert_eq!(summary.loader_version.as_deref(), Some("47.2.0")); + assert_eq!(summary.override_count, 1); +} + +#[test] +fn empty_overrides_root_does_not_count_the_manifest() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("pack.zip"); + let file = std::fs::File::create(&path).unwrap(); + let mut zip = zip::ZipWriter::new(file); + zip.start_file("manifest.json", zip::write::SimpleFileOptions::default()) + .unwrap(); + use std::io::Write; + zip.write_all( + br#"{ + "name":"No Overrides", + "version":"1.0", + "minecraft":{"version":"1.20.1"}, + "overrides":"" + }"#, + ) + .unwrap(); + zip.finish().unwrap(); + + assert_eq!(build_summary(&path).unwrap().override_count, 0); +} diff --git a/src/instance/tests/import/format_detection.rs b/src/instance/tests/import/format_detection.rs new file mode 100644 index 0000000..99a6af9 --- /dev/null +++ b/src/instance/tests/import/format_detection.rs @@ -0,0 +1,172 @@ +use super::*; +use std::io::Write; + +fn make_pack_zip(tmp: &Path, name: &str, entries: &[(&str, &[u8])]) -> std::path::PathBuf { + let path = tmp.join(name); + let file = std::fs::File::create(&path).unwrap(); + let mut zip = zip::ZipWriter::new(file); + let opts: zip::write::SimpleFileOptions = Default::default(); + for (filename, bytes) in entries { + zip.start_file(*filename, opts).unwrap(); + zip.write_all(bytes).unwrap(); + } + zip.finish().unwrap(); + path +} + +#[test] +fn detect_format_recognises_mrpack() { + let tmp = tempfile::tempdir().unwrap(); + let path = make_pack_zip(tmp.path(), "pack.mrpack", &[("modrinth.index.json", b"{}")]); + assert_eq!(detect_format(&path), Ok(PackFormat::Mrpack)); +} + +#[test] +fn detect_format_recognises_mmc_flat() { + // mmc-pack.json at the zip root - the flat layout that some mmc + // archives use. + let tmp = tempfile::tempdir().unwrap(); + let path = make_pack_zip(tmp.path(), "pack.zip", &[("mmc-pack.json", b"{}")]); + assert_eq!(detect_format(&path), Ok(PackFormat::Mmc)); +} + +#[test] +fn detect_format_recognises_mmc_nested() { + // mmc-pack.json one directory deep - the more common layout where + // the archive wraps everything in a named directory. + let tmp = tempfile::tempdir().unwrap(); + let path = make_pack_zip(tmp.path(), "pack.zip", &[("MyPack/mmc-pack.json", b"{}")]); + assert_eq!(detect_format(&path), Ok(PackFormat::Mmc)); +} + +#[test] +fn detect_format_prefers_mrpack_when_both_markers_present() { + // a zip with both markers should resolve to Mrpack since the + // detector checks modrinth.index.json first. + let tmp = tempfile::tempdir().unwrap(); + let path = make_pack_zip( + tmp.path(), + "weird.zip", + &[("modrinth.index.json", b"{}"), ("mmc-pack.json", b"{}")], + ); + assert_eq!(detect_format(&path), Ok(PackFormat::Mrpack)); +} + +#[test] +fn detect_format_errors_on_unknown_archive() { + let tmp = tempfile::tempdir().unwrap(); + let path = make_pack_zip(tmp.path(), "random.zip", &[("readme.txt", b"hello")]); + let err = detect_format(&path).unwrap_err(); + assert!( + err.contains("Unknown pack format"), + "expected unknown format error, got: {err}" + ); +} + +#[test] +fn detect_format_errors_on_missing_file() { + let tmp = tempfile::tempdir().unwrap(); + let err = detect_format(&tmp.path().join("missing.zip")).unwrap_err(); + assert!(err.contains("Cannot open"), "got: {err}"); +} + +#[test] +fn unique_name_no_collision() { + let tmp = tempfile::tempdir().unwrap(); + let name = unique_instance_name("TestPack", tmp.path()); + assert_eq!(name, "TestPack"); +} + +#[test] +fn unique_name_with_collision() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("TestPack"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("instance.json"), "{}").unwrap(); + let name = unique_instance_name("TestPack", tmp.path()); + assert_eq!(name, "TestPack (2)"); +} + +#[test] +fn unique_name_multiple_collisions() { + let tmp = tempfile::tempdir().unwrap(); + for suffix in ["", " (2)", " (3)"] { + let dir = tmp.path().join(format!("TestPack{suffix}")); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("instance.json"), "{}").unwrap(); + } + let name = unique_instance_name("TestPack", tmp.path()); + assert_eq!(name, "TestPack (4)"); +} + +#[test] +fn parse_project_url() { + assert_eq!( + parse_import_input("https://modrinth.com/modpack/fabulously-optimized"), + ImportInput::ProjectSlug("fabulously-optimized".to_string()) + ); +} + +#[test] +fn parse_version_url() { + assert_eq!( + parse_import_input("https://modrinth.com/modpack/fabulously-optimized/version/abc123"), + ImportInput::VersionId { + slug: "fabulously-optimized".to_string(), + version_id: "abc123".to_string(), + } + ); +} + +#[test] +fn parse_local_mrpack() { + assert_eq!( + parse_import_input("/home/user/pack.mrpack"), + ImportInput::LocalFile("/home/user/pack.mrpack".to_string()) + ); +} + +#[test] +fn parse_local_zip() { + assert_eq!( + parse_import_input("GT_New_Horizons.zip"), + ImportInput::LocalFile("GT_New_Horizons.zip".to_string()) + ); +} + +#[test] +fn parse_tilde_path() { + assert_eq!( + parse_import_input("~/Downloads/pack.mrpack"), + ImportInput::LocalFile("~/Downloads/pack.mrpack".to_string()) + ); +} + +#[test] +fn parse_bare_slug() { + assert_eq!( + parse_import_input("fabulously-optimized"), + ImportInput::ProjectSlug("fabulously-optimized".to_string()) + ); +} + +#[test] +fn parse_input_trims_whitespace() { + assert_eq!( + parse_import_input(" fabulously-optimized "), + ImportInput::ProjectSlug("fabulously-optimized".to_string()) + ); +} + +#[test] +fn failed_import_cleanup_removes_the_partial_instance() { + let tmp = tempfile::tempdir().unwrap(); + let manager = InstanceManager::new(tmp.path().join("instances"), tmp.path().join("meta")); + let instance_dir = manager.instances_dir.join("Broken"); + std::fs::create_dir_all(&instance_dir).unwrap(); + std::fs::write(instance_dir.join("partial"), b"data").unwrap(); + + cleanup_failed_import(&manager, "Broken"); + + assert!(!instance_dir.exists()); +} diff --git a/src/instance/tests/import/mmc.rs b/src/instance/tests/import/mmc.rs new file mode 100644 index 0000000..f5b4d74 --- /dev/null +++ b/src/instance/tests/import/mmc.rs @@ -0,0 +1,108 @@ +use super::*; + +#[test] +fn parse_mmc_pack_json() { + let json = r#"{ + "formatVersion": 1, + "components": [ + { + "uid": "net.minecraft", + "version": "1.7.10", + "cachedName": "Minecraft" + }, + { + "uid": "net.minecraftforge", + "version": "10.13.4.1614", + "cachedName": "Forge" + } + ] + }"#; + let pack: MmcPack = serde_json::from_str(json).unwrap(); + assert_eq!(pack.game_version(), Some("1.7.10".to_string())); + let (loader, version) = pack.loader(); + assert_eq!(loader, Some(ModLoader::Forge)); + assert_eq!(version, Some("10.13.4.1614".to_string())); +} + +#[test] +fn parse_mmc_pack_vanilla() { + let json = r#"{ + "formatVersion": 1, + "components": [ + {"uid": "net.minecraft", "version": "1.21.4"} + ] + }"#; + let pack: MmcPack = serde_json::from_str(json).unwrap(); + assert!(pack.loader().0.is_none()); +} + +// builds an in-memory mmc-style pack zip and verifies that +// extract_mmc_archive copies only the .minecraft/ subtree into the +// destination, preserving relative paths and skipping siblings. +#[test] +fn extract_mmc_archive_copies_minecraft_subtree() { + use std::io::Write; + + let tmp = tempfile::tempdir().unwrap(); + let archive_path = tmp.path().join("pack.zip"); + let dest = tmp.path().join("instance/.minecraft"); + std::fs::create_dir_all(&dest).unwrap(); + + // Pack/ is the prefix; only .minecraft/ entries should land in dest. + // mmc-style pack: a root dir "Pack/" wrapping the .minecraft tree + // plus a sibling mmc-pack.json that should NOT be extracted. + { + let file = std::fs::File::create(&archive_path).unwrap(); + let mut zip = zip::ZipWriter::new(file); + let opts: zip::write::SimpleFileOptions = Default::default(); + + zip.start_file("Pack/mmc-pack.json", opts).unwrap(); + zip.write_all(b"{}").unwrap(); + + zip.start_file("Pack/.minecraft/options.txt", opts).unwrap(); + zip.write_all(b"lang:en_us").unwrap(); + + zip.start_file("Pack/.minecraft/mods/test-mod.jar", opts) + .unwrap(); + zip.write_all(b"jar-bytes").unwrap(); + + zip.finish().unwrap(); + } + + extract_mmc_archive(&archive_path, &dest).expect("extract"); + + // .minecraft/ entries must have been copied with their relative paths + let options = std::fs::read(dest.join("options.txt")).expect("options.txt"); + assert_eq!(options, b"lang:en_us"); + let modjar = std::fs::read(dest.join("mods/test-mod.jar")).expect("mods/test-mod.jar"); + assert_eq!(modjar, b"jar-bytes"); + + // and the sibling outside .minecraft/ must not have been copied + assert!( + !dest.join("mmc-pack.json").exists(), + "mmc-pack.json should not land in the instance dir" + ); +} + +#[test] +fn extract_mmc_archive_rejects_path_traversal() { + use std::io::Write; + + let tmp = tempfile::tempdir().unwrap(); + let archive_path = tmp.path().join("pack.zip"); + let dest = tmp.path().join("instance/minecraft"); + let file = std::fs::File::create(&archive_path).unwrap(); + let mut zip = zip::ZipWriter::new(file); + let options: zip::write::SimpleFileOptions = Default::default(); + zip.start_file("Pack/mmc-pack.json", options).unwrap(); + zip.write_all(b"{}").unwrap(); + zip.start_file("Pack/.minecraft/../../escaped.txt", options) + .unwrap(); + zip.write_all(b"escaped").unwrap(); + zip.finish().unwrap(); + + let error = extract_mmc_archive(&archive_path, &dest).unwrap_err(); + + assert!(error.to_string().contains("archive path")); + assert!(!tmp.path().join("escaped.txt").exists()); +} diff --git a/src/instance/tests/import/mrpack.rs b/src/instance/tests/import/mrpack.rs new file mode 100644 index 0000000..4400291 --- /dev/null +++ b/src/instance/tests/import/mrpack.rs @@ -0,0 +1,330 @@ +use std::collections::HashMap; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use super::*; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const INDEX: &[u8] = br#"{ + "formatVersion": 1, + "game": "minecraft", + "versionId": "1.0.0", + "name": "Test Pack", + "dependencies": { + "minecraft": "1.21.1", + "fabric-loader": "0.16.14" + }, + "files": [] +}"#; + +fn make_mrpack(dir: &Path, entries: &[(&str, &[u8])]) -> PathBuf { + let path = dir.join("test.mrpack"); + let file = std::fs::File::create(&path).unwrap(); + let mut zip = zip::ZipWriter::new(file); + let options: zip::write::SimpleFileOptions = Default::default(); + zip.start_file("modrinth.index.json", options).unwrap(); + zip.write_all(INDEX).unwrap(); + for (name, contents) in entries { + zip.start_file(*name, options).unwrap(); + zip.write_all(contents).unwrap(); + } + zip.finish().unwrap(); + path +} + +#[test] +fn summary_counts_both_override_roots() { + let tmp = tempfile::tempdir().unwrap(); + let path = make_mrpack( + tmp.path(), + &[ + ("overrides/config/test.toml", b"config"), + ("client-overrides/options.txt", b"options"), + ], + ); + + let summary = build_summary(&path).unwrap(); + + assert_eq!(summary.name, "Test Pack"); + assert_eq!(summary.game_version, "1.21.1"); + assert_eq!(summary.loader, ModLoader::Fabric); + assert_eq!(summary.loader_version.as_deref(), Some("0.16.14")); + assert_eq!(summary.override_count, 2); +} + +#[test] +fn dependencies_resolve_fabric_loader() { + let dependencies = HashMap::from([ + ("minecraft".to_owned(), "1.21.4".to_owned()), + ("fabric-loader".to_owned(), "0.16.10".to_owned()), + ]); + + assert_eq!( + loader_from_dependencies(&dependencies), + (Some(ModLoader::Fabric), Some("0.16.10".to_owned())) + ); + assert_eq!( + game_version_from_dependencies(&dependencies), + Some("1.21.4".to_owned()) + ); +} + +#[test] +fn dependencies_resolve_forge_loader() { + let dependencies = HashMap::from([ + ("minecraft".to_owned(), "1.20.1".to_owned()), + ("forge".to_owned(), "47.2.0".to_owned()), + ]); + + assert_eq!( + loader_from_dependencies(&dependencies), + (Some(ModLoader::Forge), Some("47.2.0".to_owned())) + ); +} + +#[test] +fn dependencies_without_a_loader_are_vanilla() { + let dependencies = HashMap::from([("minecraft".to_owned(), "1.21.4".to_owned())]); + + assert_eq!(loader_from_dependencies(&dependencies), (None, None)); +} + +#[test] +fn extraction_merges_both_override_roots() { + let tmp = tempfile::tempdir().unwrap(); + let path = make_mrpack( + tmp.path(), + &[ + ("overrides/config/test.toml", b"config"), + ("client-overrides/options.txt", b"options"), + ], + ); + let minecraft = tmp.path().join("minecraft"); + + extract_overrides(&path, &minecraft).unwrap(); + + assert_eq!( + std::fs::read(minecraft.join("config/test.toml")).unwrap(), + b"config" + ); + assert_eq!( + std::fs::read(minecraft.join("options.txt")).unwrap(), + b"options" + ); +} + +#[test] +fn extraction_rejects_path_traversal() { + let tmp = tempfile::tempdir().unwrap(); + let path = make_mrpack(tmp.path(), &[("overrides/../../escaped.txt", b"escaped")]); + let minecraft = tmp.path().join("minecraft"); + + let error = extract_overrides(&path, &minecraft).unwrap_err(); + + assert!(error.to_string().contains("Unsafe override path")); + assert!(!tmp.path().join("escaped.txt").exists()); +} + +#[test] +fn mod_files_download_to_their_manifest_paths() { + let _guard = crate::tests::TEST_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/first.jar")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(b"first".to_vec())) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/second.jar")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(b"second".to_vec())) + .expect(1) + .mount(&server) + .await; + let index = MrpackIndex { + format_version: 1, + game: "minecraft".to_owned(), + version_id: "1".to_owned(), + name: "Downloads".to_owned(), + dependencies: Default::default(), + files: vec![ + MrpackFile { + path: "mods/first.jar".to_owned(), + hashes: Default::default(), + downloads: vec![format!("{}/first.jar", server.uri())], + file_size: 5, + }, + MrpackFile { + path: "resourcepacks/second.jar".to_owned(), + hashes: Default::default(), + downloads: vec![format!("{}/second.jar", server.uri())], + file_size: 6, + }, + ], + }; + let tmp = tempfile::tempdir().unwrap(); + + download_mod_files(&index, tmp.path()).await.unwrap(); + + assert_eq!( + std::fs::read(tmp.path().join("mods/first.jar")).unwrap(), + b"first" + ); + assert_eq!( + std::fs::read(tmp.path().join("resourcepacks/second.jar")).unwrap(), + b"second" + ); + crate::feedback::progress::clear(); + }); +} + +#[test] +fn mod_file_without_a_download_url_fails_without_creating_a_file() { + let _guard = crate::tests::TEST_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + let index = MrpackIndex { + format_version: 1, + game: "minecraft".to_owned(), + version_id: "1".to_owned(), + name: "Missing URL".to_owned(), + dependencies: Default::default(), + files: vec![MrpackFile { + path: "mods/missing.jar".to_owned(), + hashes: Default::default(), + downloads: Vec::new(), + file_size: 0, + }], + }; + let tmp = tempfile::tempdir().unwrap(); + + assert!(download_mod_files(&index, tmp.path()).await.is_err()); + assert!(!tmp.path().join("mods/missing.jar").exists()); + crate::feedback::progress::clear(); + }); +} + +#[test] +fn mod_file_rejects_paths_outside_the_instance() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + let index = MrpackIndex { + format_version: 1, + game: "minecraft".to_owned(), + version_id: "1".to_owned(), + name: "Unsafe path".to_owned(), + dependencies: Default::default(), + files: vec![MrpackFile { + path: "../escape.jar".to_owned(), + hashes: Default::default(), + downloads: vec!["https://example.invalid/escape.jar".to_owned()], + file_size: 1, + }], + }; + let tmp = tempfile::tempdir().unwrap(); + + let error = download_mod_files(&index, tmp.path()).await.unwrap_err(); + + assert!(error.to_string().contains("Unsafe .mrpack file path")); + assert!(!tmp.path().parent().unwrap().join("escape.jar").exists()); + }); +} + +#[test] +fn mod_file_removes_downloads_with_invalid_metadata() { + let _guard = crate::tests::TEST_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/corrupt.jar")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(b"corrupt".to_vec())) + .mount(&server) + .await; + let index = MrpackIndex { + format_version: 1, + game: "minecraft".to_owned(), + version_id: "1".to_owned(), + name: "Corrupt download".to_owned(), + dependencies: Default::default(), + files: vec![MrpackFile { + path: "mods/corrupt.jar".to_owned(), + hashes: Default::default(), + downloads: vec![format!("{}/corrupt.jar", server.uri())], + file_size: 1, + }], + }; + let tmp = tempfile::tempdir().unwrap(); + + assert!(download_mod_files(&index, tmp.path()).await.is_err()); + assert!(!tmp.path().join("mods/corrupt.jar").exists()); + crate::feedback::progress::clear(); + }); +} + +#[test] +fn import_seeds_exact_modrinth_content_identity() { + let tmp = tempfile::tempdir().unwrap(); + let paths = crate::storage::InstancePaths::new(tmp.path()); + let relative_path = PathBuf::from("resourcepacks/example.zip"); + std::fs::create_dir_all(paths.minecraft().join("resourcepacks")).unwrap(); + std::fs::write(paths.minecraft().join(&relative_path), b"resource pack").unwrap(); + let sha512 = + crate::instance::content::manifest::fingerprint(&paths.minecraft().join(&relative_path)) + .unwrap() + .hash("sha512") + .unwrap() + .to_owned(); + let index = MrpackIndex { + format_version: 1, + game: "minecraft".to_owned(), + version_id: "1".to_owned(), + name: "Indexed".to_owned(), + dependencies: Default::default(), + files: vec![MrpackFile { + path: relative_path.to_string_lossy().into_owned(), + hashes: HashMap::from([("sha512".to_owned(), sha512)]), + downloads: vec![ + "https://cdn.modrinth.com/data/project/versions/version/example.zip".to_owned(), + ], + file_size: 13, + }], + }; + + seed_content_manifest(&index, &paths).unwrap(); + + let manifest = crate::instance::ContentManifest::load(&paths.content_manifest()).unwrap(); + let record = manifest.record(&relative_path).unwrap(); + assert_eq!(record.kind, crate::instance::ContentKind::ResourcePack); + assert_eq!( + record.resolution, + crate::instance::Resolution::Resolved { + project: crate::instance::ProviderProject { + provider: "modrinth".to_owned(), + project_id: "project".to_owned(), + version_id: "version".to_owned(), + }, + } + ); +} diff --git a/src/instance/tests/launch/parser.rs b/src/instance/tests/launch/parser.rs new file mode 100644 index 0000000..bcea19f --- /dev/null +++ b/src/instance/tests/launch/parser.rs @@ -0,0 +1,102 @@ +use super::*; + +fn parse_all(lines: &[(LogStream, &str)]) -> Vec { + let mut parser = MinecraftLogParser::new(); + let mut events = Vec::new(); + for (stream, line) in lines { + events.extend(parser.push_line(*stream, *line)); + } + events.extend(parser.flush()); + events +} + +#[test] +fn classifies_minecraft_headers() { + let events = parse_all(&[ + (LogStream::Stdout, "[Render thread/INFO]: hello"), + (LogStream::Stdout, "[Render thread/WARN]: careful"), + (LogStream::Stdout, "[Render thread/ERROR]: broken"), + (LogStream::Stdout, "[Render thread/DEBUG]: noisy"), + (LogStream::Stdout, "[Render thread/TRACE]: tiny"), + ]); + + assert_eq!(events.len(), 5); + assert_eq!(events[0].level, LogLevel::Info); + assert_eq!(events[1].level, LogLevel::Warn); + assert_eq!(events[2].level, LogLevel::Error); + assert_eq!(events[3].level, LogLevel::Debug); + assert_eq!(events[4].level, LogLevel::Trace); +} + +#[test] +fn explicit_stderr_info_stays_info() { + let events = parse_all(&[(LogStream::Stderr, "[Render thread/INFO]: hello")]); + + assert_eq!(events[0].level, LogLevel::Info); +} + +#[test] +fn unstructured_stderr_falls_back_to_error() { + let events = parse_all(&[(LogStream::Stderr, "native library failed")]); + + assert_eq!(events[0].level, LogLevel::Error); +} + +#[test] +fn groups_java_stacktrace() { + let events = parse_all(&[ + ( + LogStream::Stderr, + "Exception in thread \"main\" java.lang.RuntimeException: boom", + ), + ( + LogStream::Stderr, + "\tat net.minecraft.client.Main.main(Main.java:42)", + ), + ( + LogStream::Stderr, + "Caused by: java.lang.IllegalStateException: bad", + ), + (LogStream::Stderr, "\tat example.Mod.load(Mod.java:7)"), + (LogStream::Stdout, "[Render thread/INFO]: after"), + ]); + + assert_eq!(events.len(), 2); + assert_eq!(events[0].level, LogLevel::Error); + assert_eq!(events[0].lines.len(), 4); + assert!( + events[0] + .java + .as_ref() + .is_some_and(|java| java.has_stacktrace) + ); + assert_eq!(events[1].level, LogLevel::Info); +} + +#[test] +fn groups_jvm_startup_failure_burst() { + let events = parse_all(&[ + (LogStream::Stderr, "Unrecognized option: --bad"), + ( + LogStream::Stderr, + "Could not create the Java Virtual Machine.", + ), + ( + LogStream::Stderr, + "A fatal exception has occurred. Program will exit.", + ), + ]); + + assert_eq!(events.len(), 1); + assert_eq!(events[0].level, LogLevel::Error); + assert_eq!(events[0].lines.len(), 3); +} + +#[test] +fn colored_header_classifies_but_keeps_original_text() { + let line = "\u{1b}[31m[Render thread/ERROR]: red\u{1b}[0m"; + let events = parse_all(&[(LogStream::Stdout, line)]); + + assert_eq!(events[0].level, LogLevel::Error); + assert_eq!(events[0].lines[0], line); +} diff --git a/src/instance/tests/launch/patches.rs b/src/instance/tests/launch/patches.rs new file mode 100644 index 0000000..be9eb65 --- /dev/null +++ b/src/instance/tests/launch/patches.rs @@ -0,0 +1,135 @@ +use super::*; +use std::io::Write; + +// builds an in-memory zip with the given entries, writes it to tmp, and +// returns the path. shared by parse_add_opens tests. +fn make_zip(tmp: &Path, name: &str, entries: &[(&str, &[u8])]) -> PathBuf { + let path = tmp.join(name); + let file = std::fs::File::create(&path).unwrap(); + let mut zip = zip::ZipWriter::new(file); + let opts: zip::write::SimpleFileOptions = Default::default(); + for (filename, bytes) in entries { + zip.start_file(*filename, opts).unwrap(); + zip.write_all(bytes).unwrap(); + } + zip.finish().unwrap(); + path +} + +#[test] +fn strip_replaced_libs_removes_dominated_prefixes() { + let mut classpath = vec![ + PathBuf::from("/libs/launchwrapper-1.12.jar"), + PathBuf::from("/libs/asm-all-5.0.3.jar"), + PathBuf::from("/libs/lwjgl-2.9.4.jar"), + PathBuf::from("/libs/lwjgl_util-2.9.4.jar"), + PathBuf::from("/libs/commons-compress-1.4.1.jar"), + PathBuf::from("/libs/commons-io-2.4.jar"), + PathBuf::from("/libs/guava-15.0.jar"), + // these stay + PathBuf::from("/libs/log4j-core-2.0.jar"), + PathBuf::from("/libs/guava-21.0.jar"), + ]; + strip_replaced_libs(&mut classpath); + assert_eq!( + classpath, + vec![ + PathBuf::from("/libs/log4j-core-2.0.jar"), + PathBuf::from("/libs/guava-21.0.jar"), + ] + ); +} + +#[test] +fn strip_replaced_libs_keeps_unrelated_entries() { + let mut classpath = vec![ + PathBuf::from("/libs/log4j-core-2.0.jar"), + PathBuf::from("/libs/mixin-0.8.5.jar"), + ]; + let original = classpath.clone(); + strip_replaced_libs(&mut classpath); + assert_eq!(classpath, original); +} + +#[test] +fn parse_add_opens_extracts_module_args() { + let tmp = tempfile::tempdir().unwrap(); + let manifest = b"Manifest-Version: 1.0\nAdd-Opens: java.base/java.lang java.base/java.util\n"; + let zip_path = make_zip( + tmp.path(), + "patches.zip", + &[("META-INF/MANIFEST.MF", manifest)], + ); + let args = parse_add_opens(&zip_path).expect("parsed"); + assert_eq!( + args, + vec![ + "--add-opens", + "java.base/java.lang=ALL-UNNAMED", + "--add-opens", + "java.base/java.util=ALL-UNNAMED", + ] + ); +} + +#[test] +fn parse_add_opens_handles_continuation_lines() { + // jar manifests wrap long lines: the line ends with a trailing space, + // then the next physical line starts with a leading space marker. + // when joined per the MANIFEST.MF spec, the trailing space remains + // and the leading space marker is consumed, so the two values stay + // separated by exactly one space when split_whitespace runs. + let tmp = tempfile::tempdir().unwrap(); + let manifest = + b"Manifest-Version: 1.0\nAdd-Opens: java.base/java.lang \n java.base/sun.security.util\n"; + let zip_path = make_zip( + tmp.path(), + "patches-continuation.zip", + &[("META-INF/MANIFEST.MF", manifest)], + ); + let args = parse_add_opens(&zip_path).expect("parsed"); + assert_eq!( + args, + vec![ + "--add-opens", + "java.base/java.lang=ALL-UNNAMED", + "--add-opens", + "java.base/sun.security.util=ALL-UNNAMED", + ] + ); +} + +#[test] +fn parse_add_opens_returns_none_when_manifest_missing() { + let tmp = tempfile::tempdir().unwrap(); + let zip_path = make_zip(tmp.path(), "no-manifest.zip", &[("other.txt", b"x")]); + assert!(parse_add_opens(&zip_path).is_none()); +} + +#[test] +fn parse_add_opens_returns_none_for_missing_file() { + let tmp = tempfile::tempdir().unwrap(); + assert!(parse_add_opens(&tmp.path().join("missing.zip")).is_none()); +} + +#[test] +fn add_lwjgl3_inserts_only_jars_that_exist() { + let tmp = tempfile::tempdir().unwrap(); + let lib_dir = tmp.path(); + + // pre-create only the lwjgl core jar; everything else absent so the + // function should skip them silently. proves we don't insert paths + // for jars that aren't there. + let core_jar = lib_dir.join("org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3.jar"); + std::fs::create_dir_all(core_jar.parent().unwrap()).unwrap(); + std::fs::write(&core_jar, b"jar").unwrap(); + + let mut classpath = vec![PathBuf::from("/leading/forge-patches.jar")]; + add_lwjgl3(lib_dir, &mut classpath); + + // forge-patches stays at index 0; lwjgl core is inserted at index 1. + // none of the other modules existed so nothing else was added. + assert_eq!(classpath[0], PathBuf::from("/leading/forge-patches.jar")); + assert_eq!(classpath[1], core_jar); + assert_eq!(classpath.len(), 2); +} diff --git a/src/instance/tests/launch/pipeline.rs b/src/instance/tests/launch/pipeline.rs new file mode 100644 index 0000000..0ef3bc9 --- /dev/null +++ b/src/instance/tests/launch/pipeline.rs @@ -0,0 +1,246 @@ +use super::*; + +#[rstest::rstest] +#[case("openjdk version \"25.0.3\" 2026-04-21", Some(25))] +#[case("openjdk version \"21.0.11\" 2026-04-21", Some(21))] +#[case("java version \"1.8.0_402\"", Some(8))] +#[case("garbage", None)] +fn parse_java_major_version_handles_common_outputs( + #[case] output: &str, + #[case] expected: Option, +) { + assert_eq!(parse_java_major_version(output), expected); +} + +#[test] +fn quick_play_world_must_be_a_direct_save_directory() { + let temp = tempfile::tempdir().unwrap(); + let minecraft = temp.path().join("minecraft"); + std::fs::create_dir_all(minecraft.join("saves/World")).unwrap(); + + assert!(validate_quick_play_world(&minecraft, "World").is_ok()); + assert!(validate_quick_play_world(&minecraft, "../World").is_err()); + assert!(validate_quick_play_world(&minecraft, "Missing").is_err()); +} + +#[test] +fn build_game_args_renders_upstream_arguments() { + use crate::launch_profile::model::{Argument, Arguments, LaunchProfile}; + use crate::launch_profile::rules::{FeatureSet, RuleContext}; + use TemplateContext; + use std::path::PathBuf; + + let lib = PathBuf::from("/m/libraries"); + let nat = PathBuf::from("/m/natives"); + let game_dir = PathBuf::from("/i/.minecraft"); + let assets = PathBuf::from("/m/assets"); + + let template_ctx = TemplateContext { + library_directory: &lib, + classpath_separator: ":", + version_name: "1.20.1", + natives_directory: &nat, + classpath: "a.jar:b.jar", + game_directory: &game_dir, + assets_root: &assets, + assets_index_name: "5", + auth_player_name: "Player", + auth_uuid: "00000000-0000-0000-0000-000000000000", + auth_access_token: "token", + auth_xuid: "0", + user_type: "msa", + user_properties: "{}", + launcher_name: "rmcl", + launcher_version: "test", + clientid: "0", + quick_play_singleplayer: None, + version_type: "release", + }; + let features = FeatureSet::default(); + let rule_ctx = RuleContext { + os_name: "linux", + os_version: "6.0", + arch: "x86_64", + features: &features, + }; + + let profile = LaunchProfile { + id: "1.20.1".into(), + inherits_from: None, + main_class: Some("net.minecraft.client.main.Main".into()), + libraries: Vec::new(), + arguments: Some(Arguments { + game: vec![ + Argument::Literal("--username".into()), + Argument::Literal("${auth_player_name}".into()), + ], + jvm: vec![Argument::Literal( + "-Djava.library.path=${natives_directory}".into(), + )], + }), + ..Default::default() + }; + + let (jvm, game_args) = build_game_args(&profile, &rule_ctx, &template_ctx).unwrap(); + assert_eq!(jvm, vec!["-Djava.library.path=/m/natives"]); + assert_eq!(game_args, vec!["--username", "Player"]); +} + +// exercises the early-return branch of migrate_legacy_meta_if_needed. +// a profile with either arguments or minecraftArguments is not legacy +// and must produce Ok(None) without touching the network. covers both +// shapes in one parameterised test so a regression that drops one of +// the two predicate conditions is caught. +#[rstest::rstest] +#[case::modern_arguments(true, false)] +#[case::legacy_minecraft_arguments(false, true)] +#[tokio::test] +async fn migrate_legacy_meta_skips_when_arguments_present( + #[case] modern: bool, + #[case] legacy: bool, +) { + use crate::launch_profile::model::{Arguments, LaunchProfile}; + use tempfile::TempDir; + + let tmp = TempDir::new().unwrap(); + let meta_path = tmp.path().join("meta.json"); + std::fs::write(&meta_path, b"{}").unwrap(); + + let profile = LaunchProfile { + id: "1.20.1".into(), + main_class: Some("net.test.Main".into()), + arguments: modern.then(Arguments::default), + minecraft_arguments: legacy.then(|| "--username Player".into()), + ..Default::default() + }; + + let result = migrate_legacy_meta_if_needed(&meta_path, &profile, "1.20.1").await; + assert!( + matches!(result, Ok(None)), + "expected Ok(None) for non-legacy profile, got {result:?}" + ); +} + +// each loader maps to a distinct directory-naming branch. one rstest +// exercises every variant so a regression that misorders the match +// arms in installer_version_dir_name is caught. +#[rstest::rstest] +#[case::forge(ModLoader::Forge, "1.20.1", "47.2.0", Some("1.20.1-forge-47.2.0"))] +#[case::neoforge(ModLoader::NeoForge, "1.21.1", "21.1.0", Some("neoforge-21.1.0"))] +#[case::vanilla(ModLoader::Vanilla, "1.20.1", "v", None)] +#[case::fabric(ModLoader::Fabric, "1.20.1", "0.14.21", None)] +#[case::quilt(ModLoader::Quilt, "1.20.1", "0.20.0", None)] +fn installer_version_dir_name_per_loader( + #[case] loader: ModLoader, + #[case] game_version: &str, + #[case] loader_version: &str, + #[case] expected: Option<&str>, +) { + assert_eq!( + installer_version_dir_name(loader, game_version, loader_version), + expected.map(str::to_owned) + ); +} + +// exercises the modern-profile early-return in +// migrate_legacy_loader_profile_if_needed. any of inheritsFrom, +// arguments, minecraftArguments present (or game_arguments absent) +// means "not legacy" and the function must return Ok(None) without +// touching the installer JSON path. +#[tokio::test] +async fn migrate_legacy_loader_profile_skips_modern_with_inherits_from() { + use LaunchProfile; + use chrono::Utc; + use tempfile::TempDir; + + let tmp = TempDir::new().unwrap(); + let instance_dir = tmp.path().join("instance"); + std::fs::create_dir_all(&instance_dir).unwrap(); + let profile_path = tmp.path().join("forge-1.20.1-47.2.0.json"); + std::fs::write(&profile_path, b"{}").unwrap(); + + let modern = LaunchProfile { + id: "1.20.1-forge-47.2.0".into(), + inherits_from: Some("1.20.1".into()), + main_class: Some("cpw.mods.bootstraplauncher.BootstrapLauncher".into()), + ..Default::default() + }; + + let config = InstanceConfig { + name: "test".into(), + game_version: "1.20.1".into(), + loader: ModLoader::Forge, + loader_version: Some("47.2.0".into()), + created: Utc::now(), + last_played: None, + java_path: None, + memory_max: None, + memory_min: None, + jvm_args: Vec::new(), + resolution: None, + config_sync_profile: None, + modpack_source: None, + }; + + let result = + migrate_legacy_loader_profile_if_needed(&profile_path, &modern, &config, &instance_dir) + .await; + assert!( + matches!(result, Ok(None)), + "expected Ok(None), got {result:?}" + ); +} + +#[tokio::test] +async fn migrate_legacy_loader_profile_skips_fabric() { + // a fresh upstream Fabric profile happens to match the "legacy" + // shape (no inheritsFrom, no arguments, no minecraftArguments). + // make sure the migration helper recognises this is Fabric and + // returns Ok(None) instead of erroring with "reinstall Fabric". + use LaunchProfile; + use chrono::Utc; + use tempfile::TempDir; + + let tmp = TempDir::new().unwrap(); + let instance_dir = tmp.path().join("instance"); + std::fs::create_dir_all(&instance_dir).unwrap(); + let profile_path = tmp.path().join("fabric-1.20.1-0.14.21.json"); + std::fs::write(&profile_path, b"{}").unwrap(); + + let upstream_fabric_shape = LaunchProfile { + id: "fabric-loader-0.14.21-1.20.1".into(), + inherits_from: None, + main_class: Some("net.fabricmc.loader.impl.launch.knot.KnotClient".into()), + libraries: Vec::new(), + ..Default::default() + }; + + let config = InstanceConfig { + name: "test".into(), + game_version: "1.20.1".into(), + loader: ModLoader::Fabric, + loader_version: Some("0.14.21".into()), + created: Utc::now(), + last_played: None, + java_path: None, + memory_max: None, + memory_min: None, + jvm_args: Vec::new(), + resolution: None, + config_sync_profile: None, + modpack_source: None, + }; + + let result = migrate_legacy_loader_profile_if_needed( + &profile_path, + &upstream_fabric_shape, + &config, + &instance_dir, + ) + .await; + + assert!( + matches!(result, Ok(None)), + "expected Ok(None), got {result:?}" + ); +} diff --git a/src/instance/tests/loader/forge.rs b/src/instance/tests/loader/forge.rs new file mode 100644 index 0000000..36ecc09 --- /dev/null +++ b/src/instance/tests/loader/forge.rs @@ -0,0 +1,55 @@ +use super::*; + +// builds an in-memory zip in a tempdir with the given json as +// install_profile.json. lets the legacy-install-profile detector be +// tested without an actual forge installer. +fn make_installer_zip(tmp: &std::path::Path, json: &serde_json::Value) -> std::path::PathBuf { + use std::io::Write; + let path = tmp.join("installer.jar"); + let file = std::fs::File::create(&path).unwrap(); + let mut zip = zip::ZipWriter::new(file); + let opts: zip::write::SimpleFileOptions = Default::default(); + zip.start_file("install_profile.json", opts).unwrap(); + zip.write_all(serde_json::to_string(json).unwrap().as_bytes()) + .unwrap(); + zip.finish().unwrap(); + path +} + +#[test] +fn has_legacy_install_profile_true_when_version_info_present() { + let tmp = tempfile::tempdir().unwrap(); + let jar = make_installer_zip( + tmp.path(), + &serde_json::json!({ + "install": {}, + "versionInfo": { + "id": "1.7.10-Forge10.13.4.1614-1.7.10", + "mainClass": "net.minecraft.launchwrapper.Launch" + } + }), + ); + assert!(has_legacy_install_profile(&jar)); +} + +#[test] +fn has_legacy_install_profile_false_when_version_info_absent() { + let tmp = tempfile::tempdir().unwrap(); + let jar = make_installer_zip( + tmp.path(), + &serde_json::json!({ + "spec": 1, + "minecraft": "1.20.1", + "data": {} + }), + ); + assert!(!has_legacy_install_profile(&jar)); +} + +#[test] +fn has_legacy_install_profile_false_for_missing_jar() { + let tmp = tempfile::tempdir().unwrap(); + assert!(!has_legacy_install_profile( + &tmp.path().join("missing-installer.jar") + )); +} diff --git a/src/instance/tests/loader/installers.rs b/src/instance/tests/loader/installers.rs new file mode 100644 index 0000000..0da7912 --- /dev/null +++ b/src/instance/tests/loader/installers.rs @@ -0,0 +1,144 @@ +use super::*; + +// the factory maps every ModLoader variant to its concrete installer. +// covering all five arms catches a misordered match or a copy-paste typo +// that would route, say, NeoForge to the Forge installer. +#[rstest::rstest] +#[case::vanilla(ModLoader::Vanilla)] +#[case::forge(ModLoader::Forge)] +#[case::neoforge(ModLoader::NeoForge)] +#[case::fabric(ModLoader::Fabric)] +#[case::quilt(ModLoader::Quilt)] +fn get_installer_returns_matching_loader_type(#[case] loader: ModLoader) { + let installer = get_installer(loader); + assert_eq!(installer.loader_type(), loader); +} + +#[test] +fn save_installer_profile_copies_raw_bytes_verbatim() { + use tempfile::TempDir; + let tmp = TempDir::new().unwrap(); + let instance_dir = tmp.path().join("instance"); + let meta_dir = tmp.path().join("meta"); + + // a synthetic installer version JSON with the modern arguments + // object - exactly the shape we used to strip. + let installer_json = br#"{ + "id": "1.20.1-forge-47.2.0", + "inheritsFrom": "1.20.1", + "mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher", + "libraries": [{ "name": "net.minecraftforge:forge:47.2.0" }], + "arguments": { + "game": ["--launchTarget", "forge_client"], + "jvm": ["--add-opens", "java.base/sun.security.util=cpw.mods.securejarhandler"] + } + }"#; + + let ver_dir = instance_dir + .join(crate::storage::MINECRAFT_DIR_NAME) + .join("versions") + .join("1.20.1-forge-47.2.0"); + std::fs::create_dir_all(&ver_dir).unwrap(); + let ver_json_path = ver_dir.join("1.20.1-forge-47.2.0.json"); + std::fs::write(&ver_json_path, installer_json).unwrap(); + + save_installer_profile( + &instance_dir, + &meta_dir, + "1.20.1-forge-47.2.0", + "forge-1.20.1-47.2.0.json", + ) + .unwrap(); + + let saved = std::fs::read( + meta_dir + .join("cache/loaders/profiles") + .join("forge-1.20.1-47.2.0.json"), + ) + .unwrap(); + assert_eq!( + saved, + installer_json.to_vec(), + "saved profile should be byte-for-byte identical to installer output" + ); +} + +// shape-pinning test: a synthetic versionInfo from a 1.7.10 forge +// install_profile.json must deserialise as a LaunchProfile so the +// launch flow's render_args + resolve pipeline can consume it. no +// filesystem round-trip; serde_json directly on the literal bytes. +#[test] +fn legacy_forge_version_info_deserialises_as_launch_profile() { + let bytes = br#"{ + "id": "1.7.10-Forge10.13.4.1614-1.7.10", + "mainClass": "net.minecraft.launchwrapper.Launch", + "minecraftArguments": "--username ${auth_player_name} --tweakClass cpw.mods.fml.common.launcher.FMLTweaker", + "libraries": [ + { "name": "net.minecraftforge:forge:10.13.4.1614", "url": "http://files.minecraftforge.net/maven/" }, + { "name": "net.minecraft:launchwrapper:1.9" } + ] + }"#; + + let profile: crate::launch_profile::model::LaunchProfile = + serde_json::from_slice(bytes).unwrap(); + assert_eq!(profile.id, "1.7.10-Forge10.13.4.1614-1.7.10"); + assert_eq!( + profile.main_class.as_deref(), + Some("net.minecraft.launchwrapper.Launch") + ); + // legacy forge profiles omit inheritsFrom; the launch flow's + // implicit fallback adds it before resolve. + assert!(profile.inherits_from.is_none()); + assert!( + profile + .minecraft_arguments + .as_deref() + .unwrap() + .contains("--tweakClass") + ); + assert_eq!(profile.libraries.len(), 2); + assert_eq!( + profile.libraries[0].name, + "net.minecraftforge:forge:10.13.4.1614" + ); + assert_eq!( + profile.libraries[0].url.as_deref(), + Some("http://files.minecraftforge.net/maven/") + ); + // legacy libs typically have no downloads.artifact; they resolve + // at launch time via maven_coord_to_path(name). + assert!(profile.libraries[0].downloads.is_none()); +} + +// shape-pinning test: a synthetic upstream fabric profile (no +// inheritsFrom, no arguments, libraries with name+url) must +// deserialise as a LaunchProfile so the install path can write it +// through to disk and the launch flow can read it back. +#[test] +fn raw_fabric_profile_bytes_parse_as_launch_profile() { + let bytes = br#"{ + "id": "fabric-loader-0.14.21-1.20.1", + "mainClass": "net.fabricmc.loader.impl.launch.knot.KnotClient", + "libraries": [ + { "name": "net.fabricmc:fabric-loader:0.14.21", "url": "https://maven.fabricmc.net/" }, + { "name": "net.fabricmc:intermediary:1.20.1", "url": "https://maven.fabricmc.net/" } + ] + }"#; + + let parsed: crate::launch_profile::model::LaunchProfile = + serde_json::from_slice(bytes).unwrap(); + assert_eq!(parsed.id, "fabric-loader-0.14.21-1.20.1"); + assert_eq!( + parsed.main_class.as_deref(), + Some("net.fabricmc.loader.impl.launch.knot.KnotClient") + ); + // upstream Fabric profiles omit inheritsFrom; the launch flow's + // implicit fallback handles it before resolve. + assert!(parsed.inherits_from.is_none()); + assert!(parsed.arguments.is_none()); + assert_eq!(parsed.libraries.len(), 2); + assert_eq!( + parsed.libraries[0].url.as_deref(), + Some("https://maven.fabricmc.net/") + ); +} diff --git a/src/instance/tests/loader/maven.rs b/src/instance/tests/loader/maven.rs new file mode 100644 index 0000000..8ee237d --- /dev/null +++ b/src/instance/tests/loader/maven.rs @@ -0,0 +1,32 @@ +use super::*; + +#[test] +fn maven_3_part_coord() { + assert_eq!( + maven_coord_to_path("org.example:artifact:1.0"), + Some("org/example/artifact/1.0/artifact-1.0.jar".to_string()) + ); +} + +#[test] +fn maven_4_part_coord_with_classifier() { + assert_eq!( + maven_coord_to_path("org.example:artifact:1.0:sources"), + Some("org/example/artifact/1.0/artifact-1.0-sources.jar".to_string()) + ); +} + +#[test] +fn maven_nested_group() { + assert_eq!( + maven_coord_to_path("com.google.code.gson:gson:2.10"), + Some("com/google/code/gson/gson/2.10/gson-2.10.jar".to_string()) + ); +} + +#[test] +fn maven_invalid_coordinates() { + for coordinate in ["org.example:artifact", "a:b:c:d:e", "just-a-string", ""] { + assert_eq!(maven_coord_to_path(coordinate), None); + } +} diff --git a/src/instance/tests/loader/vanilla.rs b/src/instance/tests/loader/vanilla.rs new file mode 100644 index 0000000..b9b7d11 --- /dev/null +++ b/src/instance/tests/loader/vanilla.rs @@ -0,0 +1,33 @@ +use super::*; +use crate::net::mojang::{LatestVersions, VersionEntry, VersionManifest}; + +#[test] +fn manifest_types_map_to_stable_game_versions() { + let manifest = VersionManifest { + latest: LatestVersions { + release: "1.21.1".to_owned(), + snapshot: "24w01a".to_owned(), + }, + versions: vec![ + VersionEntry { + id: "1.21.1".to_owned(), + version_type: "release".to_owned(), + url: String::new(), + sha1: String::new(), + }, + VersionEntry { + id: "24w01a".to_owned(), + version_type: "snapshot".to_owned(), + url: String::new(), + sha1: String::new(), + }, + ], + }; + + let versions = game_versions_from_manifest(manifest); + + assert_eq!(versions[0].id, "1.21.1"); + assert!(versions[0].stable); + assert_eq!(versions[1].id, "24w01a"); + assert!(!versions[1].stable); +} diff --git a/src/instance/tests/logs/files.rs b/src/instance/tests/logs/files.rs new file mode 100644 index 0000000..22518ac --- /dev/null +++ b/src/instance/tests/logs/files.rs @@ -0,0 +1,10 @@ +use super::*; + +#[test] +fn log_dir_builds_correct_path() { + let p = log_dir(Path::new("/instances"), "my-world"); + assert_eq!( + p, + PathBuf::from("/instances/my-world/minecraft/logs/launches") + ); +} diff --git a/src/instance/tests/logs/live.rs b/src/instance/tests/logs/live.rs new file mode 100644 index 0000000..9429b3c --- /dev/null +++ b/src/instance/tests/logs/live.rs @@ -0,0 +1,47 @@ +use super::*; + +#[test] +fn push_and_get_all() { + let name = "test_push_get"; + push(name, "line1"); + push(name, "line2"); + let lines = get_all(name); + assert!(lines.contains(&"line1".to_string())); + assert!(lines.contains(&"line2".to_string())); +} + +#[test] +fn get_all_missing_instance_returns_empty() { + let lines = get_all("nonexistent_instance_xyz"); + assert!(lines.is_empty()); +} + +#[test] +fn clear_removes_instance() { + let name = "test_clear"; + push(name, "data"); + assert!(!get_all(name).is_empty()); + clear(name); + assert!(get_all(name).is_empty()); +} + +#[test] +fn clear_nonexistent_is_noop() { + clear("never_existed_xyz"); +} + +#[test] +fn buffer_respects_max_lines() { + let name = "test_max_lines"; + for i in 0..(MAX_LINES + 100) { + push(name, format!("line-{i}")); + } + let lines = get_all(name); + assert_eq!(lines.len(), MAX_LINES); + assert!( + lines + .last() + .unwrap() + .contains(&format!("{}", MAX_LINES + 99)) + ); +} diff --git a/src/instance/tests/manager.rs b/src/instance/tests/manager.rs new file mode 100644 index 0000000..8756366 --- /dev/null +++ b/src/instance/tests/manager.rs @@ -0,0 +1,167 @@ +use super::*; +use crate::instance::models::ModLoader; +use tempfile::TempDir; + +// tmp owns the temp directory; its Drop impl cleans up everything when +// the test ends. the returned InstanceManager points at tmp.path() so +// tests can join("name") off of either to refer to the same locations. +fn test_manager() -> (InstanceManager, TempDir) { + let tmp = tempfile::tempdir().unwrap(); + let meta = tmp.path().join("meta"); + std::fs::create_dir_all(&meta).unwrap(); + (InstanceManager::new(tmp.path().to_path_buf(), meta), tmp) +} + +fn dummy_config(name: &str) -> InstanceConfig { + InstanceConfig { + name: name.to_string(), + game_version: "1.20.1".to_string(), + loader: ModLoader::Vanilla, + loader_version: None, + created: chrono::Utc::now(), + last_played: None, + java_path: None, + memory_max: None, + memory_min: None, + jvm_args: vec![], + resolution: None, + config_sync_profile: None, + modpack_source: None, + } +} + +#[test] +fn validate_name_accepts_safe_names() { + assert!(validate_name("my-instance").is_ok()); + assert!(validate_name("test_world").is_ok()); +} + +#[test] +fn validate_name_rejects_empty_traversal_and_hidden() { + assert!(validate_name("").is_err()); + assert!(validate_name("path/traversal").is_err()); + assert!(validate_name(".hidden").is_err()); +} + +#[test] +fn delete_missing_instance_returns_not_found() { + let (manager, _tmp) = test_manager(); + let result = manager.delete("ghost-instance"); + assert!(matches!(result, Err(InstanceError::NotFound(_)))); +} + +#[test] +fn save_then_load_all_round_trips_config() { + let (manager, tmp) = test_manager(); + std::fs::create_dir_all(tmp.path().join("test-save")).unwrap(); + manager.save(&dummy_config("test-save")).expect("save"); + + let all = manager.load_all(); + assert_eq!(all.len(), 1); + assert_eq!(all[0].name, "test-save"); + assert_eq!(all[0].game_version, "1.20.1"); +} + +#[test] +fn load_all_accepts_numeric_memory() { + let (manager, tmp) = test_manager(); + let instance_dir = tmp.path().join("test-memory"); + std::fs::create_dir_all(&instance_dir).unwrap(); + std::fs::write( + instance_dir.join("instance.json"), + r#"{ + "name": "test-memory", + "game_version": "1.7.10", + "loader": "forge", + "loader_version": "10.13.4.1614", + "created": "2026-04-20T18:04:25.567993893Z", + "memory_max": 8, + "memory_min": 512 +}"#, + ) + .expect("write config"); + + let all = manager.load_all(); + assert_eq!(all.len(), 1); + assert_eq!(all[0].memory_max.as_deref(), Some("8G")); + assert_eq!(all[0].memory_min.as_deref(), Some("512M")); +} + +#[test] +fn load_one_missing_returns_not_found() { + let (manager, _tmp) = test_manager(); + let result = manager.load_one("ghost-instance"); + assert!(matches!(result, Err(InstanceError::NotFound(_)))); +} + +#[test] +fn rename_moves_dir_and_updates_config_name() { + let (manager, tmp) = test_manager(); + let old_dir = tmp.path().join("old-name"); + std::fs::create_dir_all(&old_dir).unwrap(); + manager.save(&dummy_config("old-name")).expect("save"); + + manager.rename("old-name", "new-name").expect("rename"); + + assert!(!old_dir.exists(), "old dir should be gone"); + let new_dir = tmp.path().join("new-name"); + assert!(new_dir.exists(), "new dir should exist"); + let reloaded = manager.load_one("new-name").expect("load_one new-name"); + assert_eq!(reloaded.name, "new-name"); +} + +#[test] +fn rename_to_same_name_is_noop() { + let (manager, tmp) = test_manager(); + let dir = tmp.path().join("same"); + std::fs::create_dir_all(&dir).unwrap(); + manager.save(&dummy_config("same")).expect("save"); + manager.rename("same", "same").expect("noop rename"); + assert!(dir.exists()); +} + +#[test] +fn rename_empty_target_rejects() { + let (manager, tmp) = test_manager(); + std::fs::create_dir_all(tmp.path().join("orig")).unwrap(); + manager.save(&dummy_config("orig")).expect("save"); + let err = manager.rename("orig", " ").unwrap_err(); + assert!(matches!(err, InstanceError::InvalidName(_))); +} + +#[test] +fn rename_missing_source_errors() { + let (manager, _tmp) = test_manager(); + let err = manager.rename("ghost", "anything").unwrap_err(); + assert!(matches!(err, InstanceError::NotFound(_))); +} + +#[test] +fn rename_target_exists_errors() { + let (manager, tmp) = test_manager(); + std::fs::create_dir_all(tmp.path().join("source")).unwrap(); + std::fs::create_dir_all(tmp.path().join("collision")).unwrap(); + manager.save(&dummy_config("source")).expect("save src"); + manager.save(&dummy_config("collision")).expect("save dst"); + let err = manager.rename("source", "collision").unwrap_err(); + assert!(matches!(err, InstanceError::AlreadyExists(_))); +} + +#[test] +fn touch_last_played_updates_field() { + let (manager, tmp) = test_manager(); + std::fs::create_dir_all(tmp.path().join("ticker")).unwrap(); + manager.save(&dummy_config("ticker")).expect("save"); + assert!(manager.load_one("ticker").unwrap().last_played.is_none()); + + manager.touch_last_played("ticker").expect("touch"); + let reloaded = manager.load_one("ticker").unwrap(); + let stamp = reloaded + .last_played + .expect("last_played should be Some now"); + let age = chrono::Utc::now() - stamp; + assert!( + age.num_seconds().abs() < 5, + "last_played should be roughly now, got age {age:?}" + ); +} diff --git a/src/instance/tests/models.rs b/src/instance/tests/models.rs new file mode 100644 index 0000000..fde155b --- /dev/null +++ b/src/instance/tests/models.rs @@ -0,0 +1,85 @@ +use super::*; + +#[test] +fn instance_config_roundtrips_through_json() { + let config = InstanceConfig { + name: "test".to_string(), + game_version: "1.20.1".to_string(), + loader: ModLoader::Fabric, + loader_version: Some("0.15.0".to_string()), + created: Utc::now(), + last_played: None, + java_path: None, + memory_max: Some("4G".to_string()), + memory_min: Some("512M".to_string()), + jvm_args: vec![], + resolution: Some((1920, 1080)), + config_sync_profile: None, + modpack_source: Some(crate::instance::ProviderProject { + provider: "modrinth".to_owned(), + project_id: "pack".to_owned(), + version_id: "version".to_owned(), + }), + }; + let json = serde_json::to_string_pretty(&config).expect("serialize"); + let parsed: InstanceConfig = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(parsed.name, config.name); + assert_eq!(parsed.game_version, config.game_version); + assert_eq!(parsed.loader, config.loader); + assert_eq!(parsed.resolution, config.resolution); + assert_eq!(parsed.modpack_source, config.modpack_source); +} + +#[test] +fn instance_config_accepts_numeric_memory() { + let json = r#" + { + "name": "test", + "game_version": "1.7.10", + "loader": "forge", + "loader_version": "10.13.4.1614", + "created": "2026-04-20T18:04:25.567993893Z", + "memory_max": 8, + "memory_min": 512 + } + "#; + let parsed: InstanceConfig = serde_json::from_str(json).expect("deserialize"); + assert_eq!(parsed.memory_max.as_deref(), Some("8G")); + assert_eq!(parsed.memory_min.as_deref(), Some("512M")); +} + +#[test] +fn normalize_memory_value_handles_bare_numbers() { + assert_eq!(normalize_memory_value("8").as_deref(), Some("8G")); + assert_eq!(normalize_memory_value("4096").as_deref(), Some("4096M")); + assert_eq!(normalize_memory_value("8G").as_deref(), Some("8G")); + assert_eq!(normalize_memory_value("2048m").as_deref(), Some("2048M")); + assert_eq!(normalize_memory_value(""), None); +} + +#[test] +fn instance_config_ignores_invalid_memory_values() { + let json = r#" + { + "name": "test", + "game_version": "1.7.10", + "loader": "forge", + "loader_version": "10.13.4.1614", + "created": "2026-04-20T18:04:25.567993893Z", + "memory_max": ["8G"], + "memory_min": "8GB" + } + "#; + let parsed: InstanceConfig = serde_json::from_str(json).expect("deserialize"); + assert_eq!(parsed.memory_max, None); + assert_eq!(parsed.memory_min, None); +} + +#[test] +fn normalize_memory_value_rejects_invalid_values() { + assert_eq!(normalize_memory_value("0"), None); + assert_eq!(normalize_memory_value("-1"), None); + assert_eq!(normalize_memory_value("1.5G"), None); + assert_eq!(normalize_memory_value("8GB"), None); + assert_eq!(normalize_memory_value("banana"), None); +} diff --git a/src/instance/tests/runtime.rs b/src/instance/tests/runtime.rs new file mode 100644 index 0000000..8a5358d --- /dev/null +++ b/src/instance/tests/runtime.rs @@ -0,0 +1,75 @@ +use super::*; + +#[test] +fn set_and_get_state() { + set_state("run_test_1", RunState::Starting); + assert_eq!(get("run_test_1"), Some(RunState::Starting)); +} + +#[test] +fn get_missing_returns_none() { + assert_eq!(get("run_never_set_xyz"), None); +} + +#[test] +fn remove_clears_state() { + set_state("run_test_2", RunState::Running); + remove("run_test_2"); + assert_eq!(get("run_test_2"), None); +} + +#[test] +fn set_state_overwrites() { + set_state("run_test_3", RunState::Starting); + set_state("run_test_3", RunState::Running); + assert_eq!(get("run_test_3"), Some(RunState::Running)); +} + +#[test] +fn all_returns_entries() { + set_state("run_test_all_a", RunState::Running); + let entries = all(); + assert!(entries.iter().any(|(k, _)| k == "run_test_all_a")); +} + +#[test] +fn crashed_state_stores_exit_code() { + set_state("run_test_crash", RunState::Crashed(Some(1))); + assert_eq!(get("run_test_crash"), Some(RunState::Crashed(Some(1)))); +} + +#[test] +fn push_and_drain_last_played() { + let time = Utc::now(); + push_last_played("run_test_lp", time); + let drained = drain_last_played(); + assert!(drained.iter().any(|(k, _)| k == "run_test_lp")); +} + +// Removed drain_empty_returns_empty: it relied on no other test pushing +// to LAST_PLAYED between the two drain calls, which races with the +// parallel push_and_drain_last_played test. The drain semantics are +// already covered by push_and_drain_last_played, which asserts a +// specific entry is present, and the empty-result path is exercised +// implicitly any time drain runs after that test's cleanup. + +#[test] +fn send_kill_returns_false_for_missing() { + assert!(!send_kill("run_never_registered_xyz")); +} + +#[test] +fn register_and_send_kill() { + let (tx, mut rx) = tokio::sync::oneshot::channel::<()>(); + register_kill("run_test_kill", tx); + assert!(send_kill("run_test_kill")); + let _ = rx.try_recv(); +} + +#[test] +fn cleanup_kill_sender_removes() { + let (tx, _rx) = tokio::sync::oneshot::channel::<()>(); + register_kill("run_test_cleanup", tx); + cleanup_kill_sender("run_test_cleanup"); + assert!(!send_kill("run_test_cleanup")); +} diff --git a/src/launch_profile/model.rs b/src/launch_profile/model.rs index 127c3ed..94df4b7 100644 --- a/src/launch_profile/model.rs +++ b/src/launch_profile/model.rs @@ -111,171 +111,5 @@ pub struct Download { } #[cfg(test)] -mod tests { - use super::*; - use crate::launch_profile::rules::RuleAction; - - const MODERN_FIXTURE: &str = r#"{ - "id": "1.20.1", - "type": "release", - "mainClass": "net.minecraft.client.main.Main", - "assetIndex": { - "id": "5", - "url": "https://example.invalid/5.json", - "sha1": "0000000000000000000000000000000000000000" - }, - "javaVersion": { - "component": "java-runtime-gamma", - "majorVersion": 17 - }, - "libraries": [ - { - "name": "org.lwjgl:lwjgl:3.3.1", - "downloads": { - "artifact": { - "url": "https://example.invalid/lwjgl.jar", - "path": "org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1.jar", - "sha1": "1111111111111111111111111111111111111111", - "size": 100 - } - }, - "rules": [ - { "action": "allow", "os": { "name": "linux" } } - ] - } - ], - "arguments": { - "game": [ - "--username", "${auth_player_name}", - { - "rules": [{ "action": "allow", "features": { "is_demo_user": true } }], - "value": "--demo" - } - ], - "jvm": [ - "-Djava.library.path=${natives_directory}", - { - "rules": [{ "action": "allow", "os": { "name": "osx" } }], - "value": ["-XstartOnFirstThread"] - } - ] - } - }"#; - - const LEGACY_FIXTURE: &str = r#"{ - "id": "1.7.10", - "type": "release", - "mainClass": "net.minecraft.client.main.Main", - "minecraftArguments": "--username ${auth_player_name} --version ${version_name} --gameDir ${game_directory}", - "assetIndex": { - "id": "1.7.10", - "url": "https://example.invalid/1.7.10.json", - "sha1": "0000000000000000000000000000000000000000" - }, - "libraries": [] - }"#; - - const LOADER_FIXTURE: &str = r#"{ - "id": "1.20.1-forge-47.2.0", - "inheritsFrom": "1.20.1", - "mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher", - "libraries": [ - { "name": "net.minecraftforge:forge:47.2.0" } - ], - "arguments": { - "game": ["--launchTarget", "forge_client"], - "jvm": [ - "--add-opens", "java.base/sun.security.util=cpw.mods.securejarhandler", - "-DlibraryDirectory=${library_directory}" - ] - } - }"#; - - #[test] - fn parses_modern_arguments_object() { - let profile: LaunchProfile = serde_json::from_str(MODERN_FIXTURE).unwrap(); - assert_eq!(profile.id, "1.20.1"); - assert_eq!( - profile.main_class.as_deref(), - Some("net.minecraft.client.main.Main") - ); - assert!(profile.inherits_from.is_none()); - assert!(profile.minecraft_arguments.is_none()); - - let args = profile.arguments.as_ref().expect("arguments present"); - assert_eq!(args.game.len(), 3); - assert_eq!(args.jvm.len(), 2); - - // first game arg should be a literal "--username" - match &args.game[0] { - Argument::Literal(s) => assert_eq!(s, "--username"), - _ => panic!("expected literal"), - } - // third game arg should be a conditional with a single-string value - match &args.game[2] { - Argument::Conditional { rules, value } => { - assert_eq!(rules.len(), 1); - assert_eq!(rules[0].action, RuleAction::Allow); - assert!(matches!(value, ArgumentValue::Single(_))); - } - _ => panic!("expected conditional"), - } - // second jvm arg should be a conditional with a multi-string value - match &args.jvm[1] { - Argument::Conditional { value, .. } => { - assert!(matches!(value, ArgumentValue::Multiple(_))); - } - _ => panic!("expected conditional"), - } - } - - #[test] - fn parses_legacy_minecraft_arguments_string() { - let profile: LaunchProfile = serde_json::from_str(LEGACY_FIXTURE).unwrap(); - assert_eq!(profile.id, "1.7.10"); - assert!(profile.arguments.is_none()); - assert!( - profile - .minecraft_arguments - .as_deref() - .unwrap() - .contains("${version_name}") - ); - assert!(profile.libraries.is_empty()); - } - - #[test] - fn parses_loader_profile_with_inherits_from() { - let profile: LaunchProfile = serde_json::from_str(LOADER_FIXTURE).unwrap(); - assert_eq!(profile.id, "1.20.1-forge-47.2.0"); - assert_eq!(profile.inherits_from.as_deref(), Some("1.20.1")); - assert!(profile.asset_index.is_none()); // inherited from parent - let args = profile.arguments.as_ref().unwrap(); - assert_eq!(args.game.len(), 2); - assert_eq!(args.jvm.len(), 3); - } - - #[test] - fn modern_profile_round_trips() { - let original: LaunchProfile = serde_json::from_str(MODERN_FIXTURE).unwrap(); - let serialized = serde_json::to_string(&original).unwrap(); - let reparsed: LaunchProfile = serde_json::from_str(&serialized).unwrap(); - assert_eq!(original, reparsed); - } - - #[test] - fn loader_profile_round_trips() { - let original: LaunchProfile = serde_json::from_str(LOADER_FIXTURE).unwrap(); - let serialized = serde_json::to_string(&original).unwrap(); - let reparsed: LaunchProfile = serde_json::from_str(&serialized).unwrap(); - assert_eq!(original, reparsed); - } - - #[test] - fn legacy_profile_round_trips() { - let original: LaunchProfile = serde_json::from_str(LEGACY_FIXTURE).unwrap(); - let serialized = serde_json::to_string(&original).unwrap(); - let reparsed: LaunchProfile = serde_json::from_str(&serialized).unwrap(); - assert_eq!(original, reparsed); - } -} +#[path = "tests/model.rs"] +mod tests; diff --git a/src/launch_profile/render.rs b/src/launch_profile/render.rs index db9fd3e..9cfa0d3 100644 --- a/src/launch_profile/render.rs +++ b/src/launch_profile/render.rs @@ -79,279 +79,5 @@ fn push_argument( } #[cfg(test)] -mod tests { - use super::*; - use crate::launch_profile::model::Arguments; - use crate::launch_profile::rules::{FeatureSet, OsCondition, Rule, RuleAction}; - use std::path::PathBuf; - - // owns the path buffers + FeatureSet so each test just calls - // fx.template_ctx() and fx.rule_ctx() instead of declaring four PathBufs - // and a RuleContext inline. all tests use linux/x86_64; if a test needs - // a different OS it sets it explicitly. - struct Fixture { - lib: PathBuf, - nat: PathBuf, - game: PathBuf, - assets: PathBuf, - features: FeatureSet, - } - - impl Fixture { - fn new() -> Self { - Self { - lib: PathBuf::from("/m/libraries"), - nat: PathBuf::from("/m/natives"), - game: PathBuf::from("/i/.minecraft"), - assets: PathBuf::from("/m/assets"), - features: FeatureSet::default(), - } - } - - fn template_ctx(&self) -> TemplateContext<'_> { - TemplateContext { - library_directory: &self.lib, - classpath_separator: ":", - version_name: "1.20.1", - version_type: "release", - natives_directory: &self.nat, - classpath: "a.jar:b.jar", - game_directory: &self.game, - assets_root: &self.assets, - assets_index_name: "5", - auth_player_name: "Player", - auth_uuid: "00000000-0000-0000-0000-000000000000", - auth_access_token: "token", - auth_xuid: "0", - user_type: "msa", - user_properties: "{}", - launcher_name: "rmcl", - launcher_version: "0.3.0", - clientid: "0", - } - } - - fn rule_ctx(&self) -> RuleContext<'_> { - RuleContext { - os_name: "linux", - os_version: "6.0", - arch: "x86_64", - features: &self.features, - } - } - } - - fn minimal_profile() -> LaunchProfile { - LaunchProfile { - id: "test".into(), - main_class: Some("net.test.Main".into()), - ..Default::default() - } - } - - #[test] - fn legacy_minecraft_arguments_render_into_game() { - let fx = Fixture::new(); - let mut profile = minimal_profile(); - profile.minecraft_arguments = - Some("--username ${auth_player_name} --version ${version_name}".into()); - - let rendered = render_args(&profile, &fx.rule_ctx(), &fx.template_ctx()).unwrap(); - assert_eq!(rendered.main_class, "net.test.Main"); - assert!(rendered.jvm.is_empty()); - assert_eq!( - rendered.game, - vec!["--username", "Player", "--version", "1.20.1"] - ); - } - - #[test] - fn modern_arguments_render_with_literals_and_substitutions() { - let fx = Fixture::new(); - let mut profile = minimal_profile(); - profile.arguments = Some(Arguments { - game: vec![ - Argument::Literal("--username".into()), - Argument::Literal("${auth_player_name}".into()), - ], - jvm: vec![Argument::Literal( - "-Djava.library.path=${natives_directory}".into(), - )], - }); - - let rendered = render_args(&profile, &fx.rule_ctx(), &fx.template_ctx()).unwrap(); - assert_eq!(rendered.game, vec!["--username", "Player"]); - assert_eq!(rendered.jvm, vec!["-Djava.library.path=/m/natives"]); - } - - #[test] - fn conditional_argument_with_single_value_is_filtered_by_os_rule() { - let fx = Fixture::new(); - let osx_only = Argument::Conditional { - rules: vec![Rule { - action: RuleAction::Allow, - os: Some(OsCondition { - name: Some("osx".into()), - arch: None, - ..Default::default() - }), - features: None, - }], - value: ArgumentValue::Single("-XstartOnFirstThread".into()), - }; - - let mut profile = minimal_profile(); - profile.arguments = Some(Arguments { - game: Vec::new(), - jvm: vec![osx_only], - }); - - let rendered = render_args(&profile, &fx.rule_ctx(), &fx.template_ctx()).unwrap(); - assert!( - rendered.jvm.is_empty(), - "osx-only arg should be skipped on linux" - ); - } - - #[test] - fn conditional_argument_with_multiple_value_pushes_all() { - let fx = Fixture::new(); - let linux_arg = Argument::Conditional { - rules: vec![Rule { - action: RuleAction::Allow, - os: Some(OsCondition { - name: Some("linux".into()), - arch: None, - ..Default::default() - }), - features: None, - }], - value: ArgumentValue::Multiple(vec![ - "--add-opens".into(), - "java.base/sun.security.util=ALL-UNNAMED".into(), - ]), - }; - - let mut profile = minimal_profile(); - profile.arguments = Some(Arguments { - game: Vec::new(), - jvm: vec![linux_arg], - }); - - let rendered = render_args(&profile, &fx.rule_ctx(), &fx.template_ctx()).unwrap(); - assert_eq!( - rendered.jvm, - vec!["--add-opens", "java.base/sun.security.util=ALL-UNNAMED"] - ); - } - - #[test] - fn missing_main_class_returns_error() { - let fx = Fixture::new(); - let mut profile = minimal_profile(); - profile.main_class = None; - - let result = render_args(&profile, &fx.rule_ctx(), &fx.template_ctx()); - assert!(matches!(result, Err(RenderError::MissingMainClass))); - } - - #[tokio::test] - async fn end_to_end_resolve_then_render_modern_forge_shape() { - // exercises the full pipeline: load a synthetic vanilla profile - // from disk, load a synthetic loader profile with inheritsFrom, - // resolve the chain, then render args. catches integration bugs - // that unit tests of each layer would miss. - use crate::launch_profile::resolve; - use tempfile::TempDir; - - let tmp = TempDir::new().unwrap(); - let vanilla_path = tmp.path().join("versions").join("1.20.1").join("meta.json"); - std::fs::create_dir_all(vanilla_path.parent().unwrap()).unwrap(); - let vanilla_json = br#"{ - "id": "1.20.1", - "mainClass": "net.minecraft.client.main.Main", - "libraries": [ - { - "name": "org.lwjgl:lwjgl:3.3.1", - "downloads": { - "artifact": { - "url": "https://example.invalid/lwjgl.jar", - "path": "org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1.jar", - "sha1": "1111111111111111111111111111111111111111", - "size": 100 - } - } - } - ], - "arguments": { - "game": ["--username", "${auth_player_name}", "--version", "${version_name}"], - "jvm": ["-Djava.library.path=${natives_directory}"] - } - }"#; - std::fs::write(&vanilla_path, vanilla_json).unwrap(); - - let loader_json = r#"{ - "id": "1.20.1-forge-47.2.0", - "inheritsFrom": "1.20.1", - "mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher", - "libraries": [ - { "name": "net.minecraftforge:forge:47.2.0" } - ], - "arguments": { - "game": ["--launchTarget", "forge_client"], - "jvm": [ - "--add-opens", "java.base/sun.security.util=cpw.mods.securejarhandler" - ] - } - }"#; - let loader_profile: LaunchProfile = serde_json::from_str(loader_json).unwrap(); - - let merged = resolve::resolve(loader_profile, tmp.path()).await.unwrap(); - - let fx = Fixture::new(); - let rendered = render_args(&merged, &fx.rule_ctx(), &fx.template_ctx()).unwrap(); - - // child main_class wins after merge - assert_eq!( - rendered.main_class, - "cpw.mods.bootstraplauncher.BootstrapLauncher" - ); - // game args: parent first then child - assert_eq!( - rendered.game, - vec![ - "--username", - "Player", - "--version", - "1.20.1", - "--launchTarget", - "forge_client" - ] - ); - // jvm args: parent first then child - assert_eq!( - rendered.jvm, - vec![ - "-Djava.library.path=/m/natives", - "--add-opens", - "java.base/sun.security.util=cpw.mods.securejarhandler" - ] - ); - } - - #[test] - fn modern_arguments_takes_precedence_over_legacy_field() { - // a profile that somehow has both arguments and minecraft_arguments - // should use arguments only (legacy is fallback). - let fx = Fixture::new(); - let mut profile = minimal_profile(); - profile.arguments = Some(Arguments { - game: vec![Argument::Literal("--from-arguments".into())], - jvm: Vec::new(), - }); - profile.minecraft_arguments = Some("--from-legacy".into()); - - let rendered = render_args(&profile, &fx.rule_ctx(), &fx.template_ctx()).unwrap(); - assert_eq!(rendered.game, vec!["--from-arguments"]); - } -} +#[path = "tests/render.rs"] +mod tests; diff --git a/src/launch_profile/resolve.rs b/src/launch_profile/resolve.rs index fe1b50e..d325b1f 100644 --- a/src/launch_profile/resolve.rs +++ b/src/launch_profile/resolve.rs @@ -128,7 +128,10 @@ pub async fn resolve( return Err(ResolveError::CircularInheritance(parent_id)); } - let parent_path = meta_dir.join("versions").join(&parent_id).join("meta.json"); + let parent_path = crate::storage::MetadataPaths::new(meta_dir) + .versions() + .join(&parent_id) + .join("meta.json"); if !parent_path.exists() { return Err(ResolveError::ParentNotFound( parent_path.display().to_string(), @@ -146,420 +149,5 @@ pub async fn resolve( } #[cfg(test)] -mod tests { - use super::*; - use crate::launch_profile::model::{Argument, ArgumentValue, AssetIndex, JavaVersion, Library}; - use crate::launch_profile::rules::{Rule, RuleAction}; - - fn empty_profile(id: &str) -> LaunchProfile { - LaunchProfile { - id: id.into(), - ..Default::default() - } - } - - fn lib(name: &str) -> Library { - Library { - name: name.into(), - ..Default::default() - } - } - - fn allow_linux_rule() -> Rule { - Rule { - action: RuleAction::Allow, - os: Some(crate::launch_profile::rules::OsCondition { - name: Some("linux".into()), - ..Default::default() - }), - features: None, - } - } - - #[test] - fn child_id_wins() { - let mut child = empty_profile("child"); - let parent = empty_profile("parent"); - child.main_class = None; - let merged = merge_into(child, parent); - assert_eq!(merged.id, "child"); - } - - #[test] - fn merge_carries_parent_inherits_from() { - // merge_into preserves parent's inherits_from so resolve() can keep - // walking. resolve() itself clears the final result's inherits_from - // after the loop exits. - let mut child = empty_profile("child"); - child.inherits_from = Some("parent".into()); - let mut parent = empty_profile("parent"); - parent.inherits_from = Some("grandparent".into()); - let merged = merge_into(child, parent); - assert_eq!(merged.inherits_from.as_deref(), Some("grandparent")); - } - - #[test] - fn merge_with_root_parent_clears_inherits_from() { - // parent with no inherits_from means the chain ends. - let mut child = empty_profile("child"); - child.inherits_from = Some("parent".into()); - let parent = empty_profile("parent"); - let merged = merge_into(child, parent); - assert!(merged.inherits_from.is_none()); - } - - #[test] - fn child_main_class_overrides_parent() { - let mut child = empty_profile("child"); - let mut parent = empty_profile("parent"); - child.main_class = Some("child.Main".into()); - parent.main_class = Some("parent.Main".into()); - let merged = merge_into(child, parent); - assert_eq!(merged.main_class.as_deref(), Some("child.Main")); - } - - #[test] - fn parent_main_class_used_when_child_missing() { - let child = empty_profile("child"); - let mut parent = empty_profile("parent"); - parent.main_class = Some("parent.Main".into()); - let merged = merge_into(child, parent); - assert_eq!(merged.main_class.as_deref(), Some("parent.Main")); - } - - #[test] - fn libraries_are_concatenated_parent_first() { - let mut child = empty_profile("child"); - let mut parent = empty_profile("parent"); - parent.libraries = vec![lib("p1"), lib("p2")]; - child.libraries = vec![lib("c1")]; - let merged = merge_into(child, parent); - let names: Vec<_> = merged.libraries.iter().map(|l| l.name.as_str()).collect(); - assert_eq!(names, vec!["p1", "p2", "c1"]); - } - - #[test] - fn child_library_supersedes_parent_with_same_group_artifact() { - // forge declares log4j 2.17.0; vanilla declared 2.0-beta9. without - // dedup, both end up on the classpath and the JVM picks the first - // (vanilla) match - defeating forge's override. dedup keeps child's. - let mut child = empty_profile("forge"); - let mut parent = empty_profile("vanilla"); - parent.libraries = vec![ - lib("org.apache.logging.log4j:log4j-core:2.0-beta9"), - lib("org.lwjgl:lwjgl:3.3.1"), - ]; - child.libraries = vec![ - lib("org.apache.logging.log4j:log4j-core:2.17.0"), - lib("net.minecraftforge:forge:47.2.0"), - ]; - let merged = merge_into(child, parent); - let names: Vec<_> = merged.libraries.iter().map(|l| l.name.as_str()).collect(); - // parent's log4j-core is filtered (superseded by child); parent's - // lwjgl stays (no conflict); child's log4j and forge come last. - assert_eq!( - names, - vec![ - "org.lwjgl:lwjgl:3.3.1", - "org.apache.logging.log4j:log4j-core:2.17.0", - "net.minecraftforge:forge:47.2.0", - ] - ); - } - - #[test] - fn coord_key_extracts_group_artifact() { - assert_eq!(coord_key("org.lwjgl:lwjgl:3.3.1"), "org.lwjgl:lwjgl"); - assert_eq!( - coord_key("org.apache.logging.log4j:log4j-core:2.17.0"), - "org.apache.logging.log4j:log4j-core" - ); - // with classifier - assert_eq!( - coord_key("org.lwjgl:lwjgl:3.3.1:natives-linux"), - "org.lwjgl:lwjgl" - ); - // malformed (no colons) - return as-is - assert_eq!(coord_key("malformed"), "malformed"); - } - - #[test] - fn arguments_are_concatenated_parent_first() { - let mut child = empty_profile("child"); - let mut parent = empty_profile("parent"); - parent.arguments = Some(Arguments { - game: vec![Argument::Literal("--from-parent-game".into())], - jvm: vec![Argument::Literal("--from-parent-jvm".into())], - }); - child.arguments = Some(Arguments { - game: vec![Argument::Literal("--from-child-game".into())], - jvm: vec![Argument::Literal("--from-child-jvm".into())], - }); - let merged = merge_into(child, parent); - let args = merged.arguments.expect("arguments present"); - assert_eq!( - args.game, - vec![ - Argument::Literal("--from-parent-game".into()), - Argument::Literal("--from-child-game".into()), - ] - ); - assert_eq!( - args.jvm, - vec![ - Argument::Literal("--from-parent-jvm".into()), - Argument::Literal("--from-child-jvm".into()), - ] - ); - } - - #[test] - fn arguments_from_child_only_carry_through() { - let mut child = empty_profile("child"); - let parent = empty_profile("parent"); - child.arguments = Some(Arguments { - game: vec![Argument::Literal("--child".into())], - jvm: Vec::new(), - }); - let merged = merge_into(child, parent); - let args = merged.arguments.expect("arguments present"); - assert_eq!(args.game.len(), 1); - assert!(args.jvm.is_empty()); - } - - #[test] - fn arguments_from_parent_only_carry_through() { - let child = empty_profile("child"); - let mut parent = empty_profile("parent"); - parent.arguments = Some(Arguments { - game: Vec::new(), - jvm: vec![Argument::Literal("--parent-jvm".into())], - }); - let merged = merge_into(child, parent); - let args = merged.arguments.expect("arguments present"); - assert!(args.game.is_empty()); - assert_eq!(args.jvm.len(), 1); - } - - #[test] - fn conditional_arguments_with_rules_survive_merge() { - // make sure the Argument::Conditional shape isn't accidentally - // flattened or filtered during merging - rule eval happens later - // at render time, not during merge. - let mut child = empty_profile("child"); - let parent = empty_profile("parent"); - child.arguments = Some(Arguments { - game: vec![Argument::Conditional { - rules: vec![allow_linux_rule()], - value: ArgumentValue::Single("--linux-only".into()), - }], - jvm: Vec::new(), - }); - let merged = merge_into(child, parent); - let args = merged.arguments.expect("arguments present"); - match &args.game[0] { - Argument::Conditional { rules, .. } => { - assert_eq!(rules.len(), 1); - assert_eq!(rules[0].action, RuleAction::Allow); - } - _ => panic!("expected conditional argument to survive merge"), - } - } - - #[test] - fn legacy_minecraft_arguments_child_overrides_parent() { - let mut child = empty_profile("child"); - let mut parent = empty_profile("parent"); - child.minecraft_arguments = Some("--child".into()); - parent.minecraft_arguments = Some("--parent".into()); - let merged = merge_into(child, parent); - assert_eq!(merged.minecraft_arguments.as_deref(), Some("--child")); - } - - #[test] - fn asset_index_inherits_from_parent_when_child_absent() { - let child = empty_profile("child"); - let mut parent = empty_profile("parent"); - parent.asset_index = Some(AssetIndex { - id: "5".into(), - url: "https://example.invalid/5.json".into(), - sha1: "0".repeat(40), - size: None, - total_size: None, - }); - let merged = merge_into(child, parent); - assert!(merged.asset_index.is_some()); - assert_eq!(merged.asset_index.unwrap().id, "5"); - } - - #[test] - fn java_version_inherits_from_parent_when_child_absent() { - let child = empty_profile("child"); - let mut parent = empty_profile("parent"); - parent.java_version = Some(JavaVersion { - component: Some("java-runtime-gamma".into()), - major_version: 17, - }); - let merged = merge_into(child, parent); - assert_eq!( - merged.java_version.as_ref().map(|j| j.major_version), - Some(17) - ); - } - - use tempfile::TempDir; - - fn write_profile(meta_dir: &Path, profile: &LaunchProfile) { - let path = meta_dir - .join("versions") - .join(&profile.id) - .join("meta.json"); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - let json = serde_json::to_string_pretty(profile).unwrap(); - std::fs::write(&path, json).unwrap(); - } - - #[tokio::test] - async fn resolve_returns_unchanged_when_no_inherits_from() { - let tmp = TempDir::new().unwrap(); - let profile = empty_profile("standalone"); - let resolved = resolve(profile, tmp.path()).await.unwrap(); - assert_eq!(resolved.id, "standalone"); - assert!(resolved.inherits_from.is_none()); - } - - #[tokio::test] - async fn resolve_single_level_inheritance_merges_parent() { - let tmp = TempDir::new().unwrap(); - - let mut parent = empty_profile("1.20.1"); - parent.main_class = Some("net.minecraft.client.main.Main".into()); - parent.libraries = vec![lib("vanilla-lib")]; - write_profile(tmp.path(), &parent); - - let mut child = empty_profile("1.20.1-forge-47.2.0"); - child.inherits_from = Some("1.20.1".into()); - child.libraries = vec![lib("forge-lib")]; - - let resolved = resolve(child, tmp.path()).await.unwrap(); - assert_eq!(resolved.id, "1.20.1-forge-47.2.0"); - assert!(resolved.inherits_from.is_none()); - assert_eq!( - resolved.main_class.as_deref(), - Some("net.minecraft.client.main.Main") - ); - let names: Vec<_> = resolved.libraries.iter().map(|l| l.name.as_str()).collect(); - assert_eq!(names, vec!["vanilla-lib", "forge-lib"]); - } - - #[tokio::test] - async fn resolve_errors_when_parent_missing() { - let tmp = TempDir::new().unwrap(); - - let mut child = empty_profile("1.20.1-forge-47.2.0"); - child.inherits_from = Some("1.20.1".into()); - - let err = resolve(child, tmp.path()).await.unwrap_err(); - assert!( - matches!(err, ResolveError::ParentNotFound(_)), - "expected ParentNotFound, got {err:?}" - ); - } - - #[tokio::test] - async fn resolve_errors_when_parent_is_invalid_json() { - let tmp = TempDir::new().unwrap(); - - let parent_path = tmp.path().join("versions").join("1.20.1").join("meta.json"); - std::fs::create_dir_all(parent_path.parent().unwrap()).unwrap(); - std::fs::write(&parent_path, "{ not valid json").unwrap(); - - let mut child = empty_profile("1.20.1-forge-47.2.0"); - child.inherits_from = Some("1.20.1".into()); - - let err = resolve(child, tmp.path()).await.unwrap_err(); - assert!( - matches!(err, ResolveError::ParseError(_, _)), - "expected ParseError, got {err:?}" - ); - } - - #[tokio::test] - async fn resolve_multi_level_chain_merges_all_parents() { - let tmp = TempDir::new().unwrap(); - - // chain: grandchild -> child -> root (vanilla). - let mut root = empty_profile("1.20.1"); - root.main_class = Some("net.minecraft.client.main.Main".into()); - root.libraries = vec![lib("vanilla-lib")]; - write_profile(tmp.path(), &root); - - let mut child = empty_profile("1.20.1-forge-47.2.0"); - child.inherits_from = Some("1.20.1".into()); - child.libraries = vec![lib("forge-lib")]; - write_profile(tmp.path(), &child); - - let mut grandchild = empty_profile("1.20.1-forge-47.2.0-modpack"); - grandchild.inherits_from = Some("1.20.1-forge-47.2.0".into()); - grandchild.libraries = vec![lib("modpack-lib")]; - - let resolved = resolve(grandchild, tmp.path()).await.unwrap(); - assert_eq!(resolved.id, "1.20.1-forge-47.2.0-modpack"); - assert!(resolved.inherits_from.is_none()); - assert_eq!( - resolved.main_class.as_deref(), - Some("net.minecraft.client.main.Main") - ); - // libs: root ++ child ++ grandchild (each parent prepended) - let names: Vec<_> = resolved.libraries.iter().map(|l| l.name.as_str()).collect(); - assert_eq!(names, vec!["vanilla-lib", "forge-lib", "modpack-lib"]); - } - - #[tokio::test] - async fn resolve_detects_circular_chain() { - let tmp = TempDir::new().unwrap(); - - // a -> b -> a (cycle) - let mut a = empty_profile("a"); - a.inherits_from = Some("b".into()); - write_profile(tmp.path(), &a); - - let mut b = empty_profile("b"); - b.inherits_from = Some("a".into()); - write_profile(tmp.path(), &b); - - // start from a fresh "a" profile that asks to inherit from b - let mut entry = empty_profile("a"); - entry.inherits_from = Some("b".into()); - - let err = resolve(entry, tmp.path()).await.unwrap_err(); - assert!( - matches!(err, ResolveError::CircularInheritance(ref s) if s == "a"), - "expected CircularInheritance(a), got {err:?}" - ); - } - - #[tokio::test] - async fn resolve_caps_depth() { - let tmp = TempDir::new().unwrap(); - - // build a chain 0 -> 1 -> 2 -> ... -> 10. with cap of 8, hitting 10 - // should fail with DepthExceeded. - for i in 0..=10 { - let mut p = empty_profile(&format!("v{i}")); - if i < 10 { - p.inherits_from = Some(format!("v{}", i + 1)); - } - write_profile(tmp.path(), &p); - } - - let mut entry = empty_profile("entry"); - entry.inherits_from = Some("v0".into()); - - let err = resolve(entry, tmp.path()).await.unwrap_err(); - assert!( - matches!(err, ResolveError::DepthExceeded(_)), - "expected DepthExceeded, got {err:?}" - ); - } -} +#[path = "tests/resolve.rs"] +mod tests; diff --git a/src/launch_profile/rules.rs b/src/launch_profile/rules.rs index 276be50..3fe77c7 100644 --- a/src/launch_profile/rules.rs +++ b/src/launch_profile/rules.rs @@ -27,11 +27,9 @@ pub struct OsCondition { pub struct FeatureSet { pub is_demo_user: Option, pub has_custom_resolution: Option, - // quick-play feature flags (1.20+). rmcl never sets these, so any rule - // gated on them is filtered out by features_match. listing them - // explicitly is what makes that filter work: without the fields, - // serde would silently drop them during deserialization, leaving a - // FeatureSet::default that matches everything. + // quick-play feature flags (1.20+). normal launches leave these unset; + // world quick launch enables only the singleplayer flag. listing every + // flag explicitly keeps unrelated conditional arguments filtered out. pub has_quick_plays_support: Option, pub is_quick_play_singleplayer: Option, pub is_quick_play_multiplayer: Option, @@ -138,274 +136,5 @@ fn features_match(required: &FeatureSet, current: &FeatureSet) -> bool { } #[cfg(test)] -mod tests { - use super::*; - - fn linux_ctx<'a>(features: &'a FeatureSet) -> RuleContext<'a> { - RuleContext { - os_name: "linux", - os_version: "6.0", - arch: "x86_64", - features, - } - } - - #[test] - fn empty_rules_allow() { - let features = FeatureSet::default(); - let ctx = linux_ctx(&features); - assert!(evaluate(&[], &ctx)); - } - - #[test] - fn single_allow_matching_os_allows() { - let rules = vec![Rule { - action: RuleAction::Allow, - os: Some(OsCondition { - name: Some("linux".into()), - arch: None, - ..Default::default() - }), - features: None, - }]; - let features = FeatureSet::default(); - let ctx = linux_ctx(&features); - assert!(evaluate(&rules, &ctx)); - } - - #[test] - fn single_disallow_matching_os_disallows() { - let rules = vec![Rule { - action: RuleAction::Disallow, - os: Some(OsCondition { - name: Some("linux".into()), - arch: None, - ..Default::default() - }), - features: None, - }]; - let features = FeatureSet::default(); - let ctx = linux_ctx(&features); - assert!(!evaluate(&rules, &ctx)); - } - - #[test] - fn allow_without_os_match_disallows_by_default() { - // explicit allow for windows; we are on linux; nothing matches; - // default state remains disallow. - let rules = vec![Rule { - action: RuleAction::Allow, - os: Some(OsCondition { - name: Some("windows".into()), - arch: None, - ..Default::default() - }), - features: None, - }]; - let features = FeatureSet::default(); - let ctx = linux_ctx(&features); - assert!(!evaluate(&rules, &ctx)); - } - - #[test] - fn last_matching_rule_wins_when_allow_then_disallow() { - let rules = vec![ - Rule { - action: RuleAction::Allow, - os: None, - features: None, - }, - Rule { - action: RuleAction::Disallow, - os: Some(OsCondition { - name: Some("osx".into()), - arch: None, - ..Default::default() - }), - features: None, - }, - ]; - let features = FeatureSet::default(); - let osx_ctx = RuleContext { - os_name: "osx", - os_version: "6.0", - arch: "x86_64", - features: &features, - }; - assert!(!evaluate(&rules, &osx_ctx)); - // and on linux: only the first rule matches → still allow. - let lin = linux_ctx(&features); - assert!(evaluate(&rules, &lin)); - } - - #[test] - fn last_matching_rule_wins_when_disallow_then_allow() { - let rules = vec![ - Rule { - action: RuleAction::Disallow, - os: None, - features: None, - }, - Rule { - action: RuleAction::Allow, - os: Some(OsCondition { - name: Some("linux".into()), - arch: None, - ..Default::default() - }), - features: None, - }, - ]; - let features = FeatureSet::default(); - let ctx = linux_ctx(&features); - assert!(evaluate(&rules, &ctx)); - } - - #[test] - fn arch_mismatch_blocks_rule() { - let rules = vec![Rule { - action: RuleAction::Allow, - os: Some(OsCondition { - name: Some("linux".into()), - arch: Some("arm64".into()), - version: None, - }), - features: None, - }]; - let features = FeatureSet::default(); - let ctx = linux_ctx(&features); - // we are on linux but x86_64, not arm64 → rule does not match. - assert!(!evaluate(&rules, &ctx)); - } - - #[test] - fn feature_required_true_matches_when_ctx_true() { - let rules = vec![Rule { - action: RuleAction::Allow, - os: None, - features: Some(FeatureSet { - is_demo_user: Some(true), - ..Default::default() - }), - }]; - let demo_features = FeatureSet { - is_demo_user: Some(true), - ..Default::default() - }; - let ctx = RuleContext { - os_name: "linux", - os_version: "6.0", - arch: "x86_64", - features: &demo_features, - }; - assert!(evaluate(&rules, &ctx)); - } - - #[test] - fn feature_required_true_blocks_when_ctx_false() { - let rules = vec![Rule { - action: RuleAction::Allow, - os: None, - features: Some(FeatureSet { - is_demo_user: Some(true), - ..Default::default() - }), - }]; - let features = FeatureSet::default(); - let ctx = linux_ctx(&features); - assert!(!evaluate(&rules, &ctx)); - } - - #[test] - fn no_os_no_features_rule_always_matches() { - let rules = vec![Rule { - action: RuleAction::Allow, - os: None, - features: None, - }]; - let features = FeatureSet::default(); - let ctx = linux_ctx(&features); - assert!(evaluate(&rules, &ctx)); - } - - #[test] - fn os_version_pattern_matches_against_host() { - let rules = vec![Rule { - action: RuleAction::Allow, - os: Some(OsCondition { - name: Some("osx".into()), - arch: None, - version: Some("^10\\.".into()), - }), - features: None, - }]; - let features = FeatureSet::default(); - let ctx_match = RuleContext { - os_name: "osx", - os_version: "10.15.7", - arch: "x86_64", - features: &features, - }; - assert!(evaluate(&rules, &ctx_match)); - let ctx_mismatch = RuleContext { - os_name: "osx", - os_version: "13.2.1", - arch: "x86_64", - features: &features, - }; - assert!(!evaluate(&rules, &ctx_mismatch)); - } - - #[test] - fn os_version_pattern_does_not_match_when_host_unknown() { - let rules = vec![Rule { - action: RuleAction::Allow, - os: Some(OsCondition { - name: Some("windows".into()), - arch: None, - version: Some("^10\\.".into()), - }), - features: None, - }]; - let features = FeatureSet::default(); - let ctx = RuleContext { - os_name: "windows", - os_version: "", - arch: "x86_64", - features: &features, - }; - assert!(!evaluate(&rules, &ctx)); - } - - #[test] - fn rule_deserializes_from_mojang_json() { - // shape lifted from real mojang library rules. - let json = r#"{ - "action": "allow", - "os": { "name": "osx" } - }"#; - let rule: Rule = serde_json::from_str(json).unwrap(); - assert_eq!(rule.action, RuleAction::Allow); - assert_eq!( - rule.os.as_ref().and_then(|o| o.name.as_deref()), - Some("osx") - ); - assert!(rule.os.as_ref().and_then(|o| o.arch.as_ref()).is_none()); - assert!(rule.features.is_none()); - } - - #[test] - fn rule_deserializes_with_features() { - let json = r#"{ - "action": "allow", - "features": { "is_demo_user": true } - }"#; - let rule: Rule = serde_json::from_str(json).unwrap(); - assert_eq!(rule.action, RuleAction::Allow); - assert!(rule.os.is_none()); - assert_eq!( - rule.features.as_ref().and_then(|f| f.is_demo_user), - Some(true) - ); - } -} +#[path = "tests/rules.rs"] +mod tests; diff --git a/src/launch_profile/templates.rs b/src/launch_profile/templates.rs index 3eb0e6c..6b43aff 100644 --- a/src/launch_profile/templates.rs +++ b/src/launch_profile/templates.rs @@ -29,6 +29,7 @@ pub struct TemplateContext<'a> { pub launcher_name: &'a str, pub launcher_version: &'a str, pub clientid: &'a str, + pub quick_play_singleplayer: Option<&'a str>, } pub fn substitute(input: &str, ctx: &TemplateContext) -> String { @@ -63,15 +64,6 @@ pub fn substitute(input: &str, ctx: &TemplateContext) -> String { out } -// quick-play templates (`${quickPlayPath}`, `${quickPlaySingleplayer}`, -// `${quickPlayMultiplayer}`, `${quickPlayRealms}`) are intentionally not -// listed here. they only appear in `arguments.game` entries gated on -// `is_quick_play_*` feature flags; since rmcl never sets those flags -// (FeatureSet defaults to None across the board), the surrounding -// conditional argument is filtered out by the rule evaluator before -// template substitution even runs. if we ever expose quick-play to users, -// add the variables here AND set the corresponding feature flags in -// RuleContext at launch. fn lookup(name: &str, ctx: &TemplateContext) -> Option { Some(match name { "library_directory" => ctx.library_directory.display().to_string(), @@ -92,112 +84,11 @@ fn lookup(name: &str, ctx: &TemplateContext) -> Option { "launcher_name" => ctx.launcher_name.to_string(), "launcher_version" => ctx.launcher_version.to_string(), "clientid" => ctx.clientid.to_string(), + "quickPlaySingleplayer" => ctx.quick_play_singleplayer?.to_owned(), _ => return None, }) } #[cfg(test)] -mod tests { - use super::*; - use std::path::PathBuf; - - // owns the path buffers so tests don't have to declare them inline; the - // ctx() method borrows from self to build a TemplateContext with the - // standard set of values. windows() returns a fixture with backslash - // paths so the OS-independence test stays self-contained. - struct Fixture { - lib: PathBuf, - nat: PathBuf, - game: PathBuf, - assets: PathBuf, - user_properties: String, - } - - impl Fixture { - fn unix() -> Self { - Self { - lib: PathBuf::from("/m/libraries"), - nat: PathBuf::from("/m/natives"), - game: PathBuf::from("/i/.minecraft"), - assets: PathBuf::from("/m/assets"), - user_properties: "{}".to_string(), - } - } - - fn windows() -> Self { - Self { - lib: PathBuf::from(r"C:\Users\test\.minecraft\libraries"), - nat: PathBuf::from(r"C:\Users\test\.minecraft\natives"), - game: PathBuf::from(r"C:\Users\test\.minecraft"), - assets: PathBuf::from(r"C:\Users\test\.minecraft\assets"), - user_properties: "{}".to_string(), - } - } - - fn ctx(&self) -> TemplateContext<'_> { - TemplateContext { - library_directory: &self.lib, - classpath_separator: ":", - version_name: "1.20.1", - version_type: "release", - natives_directory: &self.nat, - classpath: "a.jar:b.jar", - game_directory: &self.game, - assets_root: &self.assets, - assets_index_name: "5", - auth_player_name: "Player", - auth_uuid: "00000000-0000-0000-0000-000000000000", - auth_access_token: "token", - auth_xuid: "0", - user_type: "msa", - user_properties: &self.user_properties, - launcher_name: "rmcl", - launcher_version: "0.3.0", - clientid: "0", - } - } - } - - #[rstest::rstest] - #[case::no_placeholders("--add-modules ALL-MODULE-PATH", "--add-modules ALL-MODULE-PATH")] - #[case::single_known("v=${version_name}", "v=1.20.1")] - #[case::unknown_placeholder("x=${not_a_real_var}y", "x=${not_a_real_var}y")] - #[case::unclosed_placeholder("--prefix ${unclosed", "--prefix ${unclosed")] - #[case::dollar_without_brace("$$ literal $5 $", "$$ literal $5 $")] - #[case::multiple("${version_name}-${auth_player_name}", "1.20.1-Player")] - #[case::path( - "-DlibraryDirectory=${library_directory}", - "-DlibraryDirectory=/m/libraries" - )] - #[case::empty_input("", "")] - fn substitute_handles(#[case] input: &str, #[case] expected: &str) { - let fx = Fixture::unix(); - assert_eq!(substitute(input, &fx.ctx()), expected); - } - - #[test] - fn substituted_value_is_not_recursively_substituted() { - // simulate a user_properties value that happens to contain a ${...} - // pattern. it should NOT trigger another substitution pass. - let mut fx = Fixture::unix(); - fx.user_properties = "${version_name}".to_string(); - assert_eq!( - substitute("${user_properties}", &fx.ctx()), - "${version_name}" - ); - } - - #[test] - fn windows_style_backslashes_in_value_pass_through() { - // simulate a Windows install where library_directory is a path with - // backslashes. the substitution must not interpret backslashes as - // escape sequences or do anything else clever - it just copies the - // value into the output. - let fx = Fixture::windows(); - let result = substitute("-Dpath=${library_directory}", &fx.ctx()); - assert!( - result.contains(r"C:\Users\test\.minecraft\libraries"), - "expected backslashes preserved, got: {result}" - ); - } -} +#[path = "tests/templates.rs"] +mod tests; diff --git a/src/launch_profile/tests/model.rs b/src/launch_profile/tests/model.rs new file mode 100644 index 0000000..20769c4 --- /dev/null +++ b/src/launch_profile/tests/model.rs @@ -0,0 +1,166 @@ +use super::*; +use crate::launch_profile::rules::RuleAction; + +const MODERN_FIXTURE: &str = r#"{ + "id": "1.20.1", + "type": "release", + "mainClass": "net.minecraft.client.main.Main", + "assetIndex": { + "id": "5", + "url": "https://example.invalid/5.json", + "sha1": "0000000000000000000000000000000000000000" + }, + "javaVersion": { + "component": "java-runtime-gamma", + "majorVersion": 17 + }, + "libraries": [ + { + "name": "org.lwjgl:lwjgl:3.3.1", + "downloads": { + "artifact": { + "url": "https://example.invalid/lwjgl.jar", + "path": "org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1.jar", + "sha1": "1111111111111111111111111111111111111111", + "size": 100 + } + }, + "rules": [ + { "action": "allow", "os": { "name": "linux" } } + ] + } + ], + "arguments": { + "game": [ + "--username", "${auth_player_name}", + { + "rules": [{ "action": "allow", "features": { "is_demo_user": true } }], + "value": "--demo" + } + ], + "jvm": [ + "-Djava.library.path=${natives_directory}", + { + "rules": [{ "action": "allow", "os": { "name": "osx" } }], + "value": ["-XstartOnFirstThread"] + } + ] + } + }"#; + +const LEGACY_FIXTURE: &str = r#"{ + "id": "1.7.10", + "type": "release", + "mainClass": "net.minecraft.client.main.Main", + "minecraftArguments": "--username ${auth_player_name} --version ${version_name} --gameDir ${game_directory}", + "assetIndex": { + "id": "1.7.10", + "url": "https://example.invalid/1.7.10.json", + "sha1": "0000000000000000000000000000000000000000" + }, + "libraries": [] + }"#; + +const LOADER_FIXTURE: &str = r#"{ + "id": "1.20.1-forge-47.2.0", + "inheritsFrom": "1.20.1", + "mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher", + "libraries": [ + { "name": "net.minecraftforge:forge:47.2.0" } + ], + "arguments": { + "game": ["--launchTarget", "forge_client"], + "jvm": [ + "--add-opens", "java.base/sun.security.util=cpw.mods.securejarhandler", + "-DlibraryDirectory=${library_directory}" + ] + } + }"#; + +#[test] +fn parses_modern_arguments_object() { + let profile: LaunchProfile = serde_json::from_str(MODERN_FIXTURE).unwrap(); + assert_eq!(profile.id, "1.20.1"); + assert_eq!( + profile.main_class.as_deref(), + Some("net.minecraft.client.main.Main") + ); + assert!(profile.inherits_from.is_none()); + assert!(profile.minecraft_arguments.is_none()); + + let args = profile.arguments.as_ref().expect("arguments present"); + assert_eq!(args.game.len(), 3); + assert_eq!(args.jvm.len(), 2); + + // first game arg should be a literal "--username" + match &args.game[0] { + Argument::Literal(s) => assert_eq!(s, "--username"), + _ => panic!("expected literal"), + } + // third game arg should be a conditional with a single-string value + match &args.game[2] { + Argument::Conditional { rules, value } => { + assert_eq!(rules.len(), 1); + assert_eq!(rules[0].action, RuleAction::Allow); + assert!(matches!(value, ArgumentValue::Single(_))); + } + _ => panic!("expected conditional"), + } + // second jvm arg should be a conditional with a multi-string value + match &args.jvm[1] { + Argument::Conditional { value, .. } => { + assert!(matches!(value, ArgumentValue::Multiple(_))); + } + _ => panic!("expected conditional"), + } +} + +#[test] +fn parses_legacy_minecraft_arguments_string() { + let profile: LaunchProfile = serde_json::from_str(LEGACY_FIXTURE).unwrap(); + assert_eq!(profile.id, "1.7.10"); + assert!(profile.arguments.is_none()); + assert!( + profile + .minecraft_arguments + .as_deref() + .unwrap() + .contains("${version_name}") + ); + assert!(profile.libraries.is_empty()); +} + +#[test] +fn parses_loader_profile_with_inherits_from() { + let profile: LaunchProfile = serde_json::from_str(LOADER_FIXTURE).unwrap(); + assert_eq!(profile.id, "1.20.1-forge-47.2.0"); + assert_eq!(profile.inherits_from.as_deref(), Some("1.20.1")); + assert!(profile.asset_index.is_none()); // inherited from parent + let args = profile.arguments.as_ref().unwrap(); + assert_eq!(args.game.len(), 2); + assert_eq!(args.jvm.len(), 3); +} + +#[test] +fn modern_profile_round_trips() { + let original: LaunchProfile = serde_json::from_str(MODERN_FIXTURE).unwrap(); + let serialized = serde_json::to_string(&original).unwrap(); + let reparsed: LaunchProfile = serde_json::from_str(&serialized).unwrap(); + assert_eq!(original, reparsed); +} + +#[test] +fn loader_profile_round_trips() { + let original: LaunchProfile = serde_json::from_str(LOADER_FIXTURE).unwrap(); + let serialized = serde_json::to_string(&original).unwrap(); + let reparsed: LaunchProfile = serde_json::from_str(&serialized).unwrap(); + assert_eq!(original, reparsed); +} + +#[test] +fn legacy_profile_round_trips() { + let original: LaunchProfile = serde_json::from_str(LEGACY_FIXTURE).unwrap(); + let serialized = serde_json::to_string(&original).unwrap(); + let reparsed: LaunchProfile = serde_json::from_str(&serialized).unwrap(); + assert_eq!(original, reparsed); +} diff --git a/src/launch_profile/tests/render.rs b/src/launch_profile/tests/render.rs new file mode 100644 index 0000000..0533774 --- /dev/null +++ b/src/launch_profile/tests/render.rs @@ -0,0 +1,279 @@ +use super::*; +use crate::launch_profile::model::Arguments; +use crate::launch_profile::rules::{FeatureSet, OsCondition, Rule, RuleAction}; +use std::path::PathBuf; + +// owns the path buffers + FeatureSet so each test just calls +// fx.template_ctx() and fx.rule_ctx() instead of declaring four PathBufs +// and a RuleContext inline. all tests use linux/x86_64; if a test needs +// a different OS it sets it explicitly. +struct Fixture { + lib: PathBuf, + nat: PathBuf, + game: PathBuf, + assets: PathBuf, + features: FeatureSet, +} + +impl Fixture { + fn new() -> Self { + Self { + lib: PathBuf::from("/m/libraries"), + nat: PathBuf::from("/m/natives"), + game: PathBuf::from("/i/.minecraft"), + assets: PathBuf::from("/m/assets"), + features: FeatureSet::default(), + } + } + + fn template_ctx(&self) -> TemplateContext<'_> { + TemplateContext { + library_directory: &self.lib, + classpath_separator: ":", + version_name: "1.20.1", + version_type: "release", + natives_directory: &self.nat, + classpath: "a.jar:b.jar", + game_directory: &self.game, + assets_root: &self.assets, + assets_index_name: "5", + auth_player_name: "Player", + auth_uuid: "00000000-0000-0000-0000-000000000000", + auth_access_token: "token", + auth_xuid: "0", + user_type: "msa", + user_properties: "{}", + launcher_name: "rmcl", + launcher_version: "0.3.0", + clientid: "0", + quick_play_singleplayer: None, + } + } + + fn rule_ctx(&self) -> RuleContext<'_> { + RuleContext { + os_name: "linux", + os_version: "6.0", + arch: "x86_64", + features: &self.features, + } + } +} + +fn minimal_profile() -> LaunchProfile { + LaunchProfile { + id: "test".into(), + main_class: Some("net.test.Main".into()), + ..Default::default() + } +} + +#[test] +fn legacy_minecraft_arguments_render_into_game() { + let fx = Fixture::new(); + let mut profile = minimal_profile(); + profile.minecraft_arguments = + Some("--username ${auth_player_name} --version ${version_name}".into()); + + let rendered = render_args(&profile, &fx.rule_ctx(), &fx.template_ctx()).unwrap(); + assert_eq!(rendered.main_class, "net.test.Main"); + assert!(rendered.jvm.is_empty()); + assert_eq!( + rendered.game, + vec!["--username", "Player", "--version", "1.20.1"] + ); +} + +#[test] +fn modern_arguments_render_with_literals_and_substitutions() { + let fx = Fixture::new(); + let mut profile = minimal_profile(); + profile.arguments = Some(Arguments { + game: vec![ + Argument::Literal("--username".into()), + Argument::Literal("${auth_player_name}".into()), + ], + jvm: vec![Argument::Literal( + "-Djava.library.path=${natives_directory}".into(), + )], + }); + + let rendered = render_args(&profile, &fx.rule_ctx(), &fx.template_ctx()).unwrap(); + assert_eq!(rendered.game, vec!["--username", "Player"]); + assert_eq!(rendered.jvm, vec!["-Djava.library.path=/m/natives"]); +} + +#[test] +fn conditional_argument_with_single_value_is_filtered_by_os_rule() { + let fx = Fixture::new(); + let osx_only = Argument::Conditional { + rules: vec![Rule { + action: RuleAction::Allow, + os: Some(OsCondition { + name: Some("osx".into()), + arch: None, + ..Default::default() + }), + features: None, + }], + value: ArgumentValue::Single("-XstartOnFirstThread".into()), + }; + + let mut profile = minimal_profile(); + profile.arguments = Some(Arguments { + game: Vec::new(), + jvm: vec![osx_only], + }); + + let rendered = render_args(&profile, &fx.rule_ctx(), &fx.template_ctx()).unwrap(); + assert!( + rendered.jvm.is_empty(), + "osx-only arg should be skipped on linux" + ); +} + +#[test] +fn conditional_argument_with_multiple_value_pushes_all() { + let fx = Fixture::new(); + let linux_arg = Argument::Conditional { + rules: vec![Rule { + action: RuleAction::Allow, + os: Some(OsCondition { + name: Some("linux".into()), + arch: None, + ..Default::default() + }), + features: None, + }], + value: ArgumentValue::Multiple(vec![ + "--add-opens".into(), + "java.base/sun.security.util=ALL-UNNAMED".into(), + ]), + }; + + let mut profile = minimal_profile(); + profile.arguments = Some(Arguments { + game: Vec::new(), + jvm: vec![linux_arg], + }); + + let rendered = render_args(&profile, &fx.rule_ctx(), &fx.template_ctx()).unwrap(); + assert_eq!( + rendered.jvm, + vec!["--add-opens", "java.base/sun.security.util=ALL-UNNAMED"] + ); +} + +#[test] +fn missing_main_class_returns_error() { + let fx = Fixture::new(); + let mut profile = minimal_profile(); + profile.main_class = None; + + let result = render_args(&profile, &fx.rule_ctx(), &fx.template_ctx()); + assert!(matches!(result, Err(RenderError::MissingMainClass))); +} + +#[tokio::test] +async fn end_to_end_resolve_then_render_modern_forge_shape() { + // exercises the full pipeline: load a synthetic vanilla profile + // from disk, load a synthetic loader profile with inheritsFrom, + // resolve the chain, then render args. catches integration bugs + // that unit tests of each layer would miss. + use crate::launch_profile::resolve; + use tempfile::TempDir; + + let tmp = TempDir::new().unwrap(); + let vanilla_path = tmp + .path() + .join("cache/minecraft/versions") + .join("1.20.1") + .join("meta.json"); + std::fs::create_dir_all(vanilla_path.parent().unwrap()).unwrap(); + let vanilla_json = br#"{ + "id": "1.20.1", + "mainClass": "net.minecraft.client.main.Main", + "libraries": [ + { + "name": "org.lwjgl:lwjgl:3.3.1", + "downloads": { + "artifact": { + "url": "https://example.invalid/lwjgl.jar", + "path": "org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1.jar", + "sha1": "1111111111111111111111111111111111111111", + "size": 100 + } + } + } + ], + "arguments": { + "game": ["--username", "${auth_player_name}", "--version", "${version_name}"], + "jvm": ["-Djava.library.path=${natives_directory}"] + } + }"#; + std::fs::write(&vanilla_path, vanilla_json).unwrap(); + + let loader_json = r#"{ + "id": "1.20.1-forge-47.2.0", + "inheritsFrom": "1.20.1", + "mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher", + "libraries": [ + { "name": "net.minecraftforge:forge:47.2.0" } + ], + "arguments": { + "game": ["--launchTarget", "forge_client"], + "jvm": [ + "--add-opens", "java.base/sun.security.util=cpw.mods.securejarhandler" + ] + } + }"#; + let loader_profile: LaunchProfile = serde_json::from_str(loader_json).unwrap(); + + let merged = resolve::resolve(loader_profile, tmp.path()).await.unwrap(); + + let fx = Fixture::new(); + let rendered = render_args(&merged, &fx.rule_ctx(), &fx.template_ctx()).unwrap(); + + // child main_class wins after merge + assert_eq!( + rendered.main_class, + "cpw.mods.bootstraplauncher.BootstrapLauncher" + ); + // game args: parent first then child + assert_eq!( + rendered.game, + vec![ + "--username", + "Player", + "--version", + "1.20.1", + "--launchTarget", + "forge_client" + ] + ); + // jvm args: parent first then child + assert_eq!( + rendered.jvm, + vec![ + "-Djava.library.path=/m/natives", + "--add-opens", + "java.base/sun.security.util=cpw.mods.securejarhandler" + ] + ); +} + +#[test] +fn modern_arguments_takes_precedence_over_legacy_field() { + // a profile that somehow has both arguments and minecraft_arguments + // should use arguments only (legacy is fallback). + let fx = Fixture::new(); + let mut profile = minimal_profile(); + profile.arguments = Some(Arguments { + game: vec![Argument::Literal("--from-arguments".into())], + jvm: Vec::new(), + }); + profile.minecraft_arguments = Some("--from-legacy".into()); + + let rendered = render_args(&profile, &fx.rule_ctx(), &fx.template_ctx()).unwrap(); + assert_eq!(rendered.game, vec!["--from-arguments"]); +} diff --git a/src/launch_profile/tests/resolve.rs b/src/launch_profile/tests/resolve.rs new file mode 100644 index 0000000..40d3a26 --- /dev/null +++ b/src/launch_profile/tests/resolve.rs @@ -0,0 +1,419 @@ +use super::*; +use crate::launch_profile::model::{Argument, ArgumentValue, AssetIndex, JavaVersion, Library}; +use crate::launch_profile::rules::{Rule, RuleAction}; + +fn empty_profile(id: &str) -> LaunchProfile { + LaunchProfile { + id: id.into(), + ..Default::default() + } +} + +fn lib(name: &str) -> Library { + Library { + name: name.into(), + ..Default::default() + } +} + +fn allow_linux_rule() -> Rule { + Rule { + action: RuleAction::Allow, + os: Some(crate::launch_profile::rules::OsCondition { + name: Some("linux".into()), + ..Default::default() + }), + features: None, + } +} + +#[test] +fn child_id_wins() { + let mut child = empty_profile("child"); + let parent = empty_profile("parent"); + child.main_class = None; + let merged = merge_into(child, parent); + assert_eq!(merged.id, "child"); +} + +#[test] +fn merge_carries_parent_inherits_from() { + // merge_into preserves parent's inherits_from so resolve() can keep + // walking. resolve() itself clears the final result's inherits_from + // after the loop exits. + let mut child = empty_profile("child"); + child.inherits_from = Some("parent".into()); + let mut parent = empty_profile("parent"); + parent.inherits_from = Some("grandparent".into()); + let merged = merge_into(child, parent); + assert_eq!(merged.inherits_from.as_deref(), Some("grandparent")); +} + +#[test] +fn merge_with_root_parent_clears_inherits_from() { + // parent with no inherits_from means the chain ends. + let mut child = empty_profile("child"); + child.inherits_from = Some("parent".into()); + let parent = empty_profile("parent"); + let merged = merge_into(child, parent); + assert!(merged.inherits_from.is_none()); +} + +#[test] +fn child_main_class_overrides_parent() { + let mut child = empty_profile("child"); + let mut parent = empty_profile("parent"); + child.main_class = Some("child.Main".into()); + parent.main_class = Some("parent.Main".into()); + let merged = merge_into(child, parent); + assert_eq!(merged.main_class.as_deref(), Some("child.Main")); +} + +#[test] +fn parent_main_class_used_when_child_missing() { + let child = empty_profile("child"); + let mut parent = empty_profile("parent"); + parent.main_class = Some("parent.Main".into()); + let merged = merge_into(child, parent); + assert_eq!(merged.main_class.as_deref(), Some("parent.Main")); +} + +#[test] +fn libraries_are_concatenated_parent_first() { + let mut child = empty_profile("child"); + let mut parent = empty_profile("parent"); + parent.libraries = vec![lib("p1"), lib("p2")]; + child.libraries = vec![lib("c1")]; + let merged = merge_into(child, parent); + let names: Vec<_> = merged.libraries.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, vec!["p1", "p2", "c1"]); +} + +#[test] +fn child_library_supersedes_parent_with_same_group_artifact() { + // forge declares log4j 2.17.0; vanilla declared 2.0-beta9. without + // dedup, both end up on the classpath and the JVM picks the first + // (vanilla) match - defeating forge's override. dedup keeps child's. + let mut child = empty_profile("forge"); + let mut parent = empty_profile("vanilla"); + parent.libraries = vec![ + lib("org.apache.logging.log4j:log4j-core:2.0-beta9"), + lib("org.lwjgl:lwjgl:3.3.1"), + ]; + child.libraries = vec![ + lib("org.apache.logging.log4j:log4j-core:2.17.0"), + lib("net.minecraftforge:forge:47.2.0"), + ]; + let merged = merge_into(child, parent); + let names: Vec<_> = merged.libraries.iter().map(|l| l.name.as_str()).collect(); + // parent's log4j-core is filtered (superseded by child); parent's + // lwjgl stays (no conflict); child's log4j and forge come last. + assert_eq!( + names, + vec![ + "org.lwjgl:lwjgl:3.3.1", + "org.apache.logging.log4j:log4j-core:2.17.0", + "net.minecraftforge:forge:47.2.0", + ] + ); +} + +#[test] +fn coord_key_extracts_group_artifact() { + assert_eq!(coord_key("org.lwjgl:lwjgl:3.3.1"), "org.lwjgl:lwjgl"); + assert_eq!( + coord_key("org.apache.logging.log4j:log4j-core:2.17.0"), + "org.apache.logging.log4j:log4j-core" + ); + // with classifier + assert_eq!( + coord_key("org.lwjgl:lwjgl:3.3.1:natives-linux"), + "org.lwjgl:lwjgl" + ); + // malformed (no colons) - return as-is + assert_eq!(coord_key("malformed"), "malformed"); +} + +#[test] +fn arguments_are_concatenated_parent_first() { + let mut child = empty_profile("child"); + let mut parent = empty_profile("parent"); + parent.arguments = Some(Arguments { + game: vec![Argument::Literal("--from-parent-game".into())], + jvm: vec![Argument::Literal("--from-parent-jvm".into())], + }); + child.arguments = Some(Arguments { + game: vec![Argument::Literal("--from-child-game".into())], + jvm: vec![Argument::Literal("--from-child-jvm".into())], + }); + let merged = merge_into(child, parent); + let args = merged.arguments.expect("arguments present"); + assert_eq!( + args.game, + vec![ + Argument::Literal("--from-parent-game".into()), + Argument::Literal("--from-child-game".into()), + ] + ); + assert_eq!( + args.jvm, + vec![ + Argument::Literal("--from-parent-jvm".into()), + Argument::Literal("--from-child-jvm".into()), + ] + ); +} + +#[test] +fn arguments_from_child_only_carry_through() { + let mut child = empty_profile("child"); + let parent = empty_profile("parent"); + child.arguments = Some(Arguments { + game: vec![Argument::Literal("--child".into())], + jvm: Vec::new(), + }); + let merged = merge_into(child, parent); + let args = merged.arguments.expect("arguments present"); + assert_eq!(args.game.len(), 1); + assert!(args.jvm.is_empty()); +} + +#[test] +fn arguments_from_parent_only_carry_through() { + let child = empty_profile("child"); + let mut parent = empty_profile("parent"); + parent.arguments = Some(Arguments { + game: Vec::new(), + jvm: vec![Argument::Literal("--parent-jvm".into())], + }); + let merged = merge_into(child, parent); + let args = merged.arguments.expect("arguments present"); + assert!(args.game.is_empty()); + assert_eq!(args.jvm.len(), 1); +} + +#[test] +fn conditional_arguments_with_rules_survive_merge() { + // make sure the Argument::Conditional shape isn't accidentally + // flattened or filtered during merging - rule eval happens later + // at render time, not during merge. + let mut child = empty_profile("child"); + let parent = empty_profile("parent"); + child.arguments = Some(Arguments { + game: vec![Argument::Conditional { + rules: vec![allow_linux_rule()], + value: ArgumentValue::Single("--linux-only".into()), + }], + jvm: Vec::new(), + }); + let merged = merge_into(child, parent); + let args = merged.arguments.expect("arguments present"); + match &args.game[0] { + Argument::Conditional { rules, .. } => { + assert_eq!(rules.len(), 1); + assert_eq!(rules[0].action, RuleAction::Allow); + } + _ => panic!("expected conditional argument to survive merge"), + } +} + +#[test] +fn legacy_minecraft_arguments_child_overrides_parent() { + let mut child = empty_profile("child"); + let mut parent = empty_profile("parent"); + child.minecraft_arguments = Some("--child".into()); + parent.minecraft_arguments = Some("--parent".into()); + let merged = merge_into(child, parent); + assert_eq!(merged.minecraft_arguments.as_deref(), Some("--child")); +} + +#[test] +fn asset_index_inherits_from_parent_when_child_absent() { + let child = empty_profile("child"); + let mut parent = empty_profile("parent"); + parent.asset_index = Some(AssetIndex { + id: "5".into(), + url: "https://example.invalid/5.json".into(), + sha1: "0".repeat(40), + size: None, + total_size: None, + }); + let merged = merge_into(child, parent); + assert!(merged.asset_index.is_some()); + assert_eq!(merged.asset_index.unwrap().id, "5"); +} + +#[test] +fn java_version_inherits_from_parent_when_child_absent() { + let child = empty_profile("child"); + let mut parent = empty_profile("parent"); + parent.java_version = Some(JavaVersion { + component: Some("java-runtime-gamma".into()), + major_version: 17, + }); + let merged = merge_into(child, parent); + assert_eq!( + merged.java_version.as_ref().map(|j| j.major_version), + Some(17) + ); +} + +use tempfile::TempDir; + +fn write_profile(meta_dir: &Path, profile: &LaunchProfile) { + let path = meta_dir + .join("cache/minecraft/versions") + .join(&profile.id) + .join("meta.json"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + let json = serde_json::to_string_pretty(profile).unwrap(); + std::fs::write(&path, json).unwrap(); +} + +#[tokio::test] +async fn resolve_returns_unchanged_when_no_inherits_from() { + let tmp = TempDir::new().unwrap(); + let profile = empty_profile("standalone"); + let resolved = resolve(profile, tmp.path()).await.unwrap(); + assert_eq!(resolved.id, "standalone"); + assert!(resolved.inherits_from.is_none()); +} + +#[tokio::test] +async fn resolve_single_level_inheritance_merges_parent() { + let tmp = TempDir::new().unwrap(); + + let mut parent = empty_profile("1.20.1"); + parent.main_class = Some("net.minecraft.client.main.Main".into()); + parent.libraries = vec![lib("vanilla-lib")]; + write_profile(tmp.path(), &parent); + + let mut child = empty_profile("1.20.1-forge-47.2.0"); + child.inherits_from = Some("1.20.1".into()); + child.libraries = vec![lib("forge-lib")]; + + let resolved = resolve(child, tmp.path()).await.unwrap(); + assert_eq!(resolved.id, "1.20.1-forge-47.2.0"); + assert!(resolved.inherits_from.is_none()); + assert_eq!( + resolved.main_class.as_deref(), + Some("net.minecraft.client.main.Main") + ); + let names: Vec<_> = resolved.libraries.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, vec!["vanilla-lib", "forge-lib"]); +} + +#[tokio::test] +async fn resolve_errors_when_parent_missing() { + let tmp = TempDir::new().unwrap(); + + let mut child = empty_profile("1.20.1-forge-47.2.0"); + child.inherits_from = Some("1.20.1".into()); + + let err = resolve(child, tmp.path()).await.unwrap_err(); + assert!( + matches!(err, ResolveError::ParentNotFound(_)), + "expected ParentNotFound, got {err:?}" + ); +} + +#[tokio::test] +async fn resolve_errors_when_parent_is_invalid_json() { + let tmp = TempDir::new().unwrap(); + + let parent_path = tmp + .path() + .join("cache/minecraft/versions") + .join("1.20.1") + .join("meta.json"); + std::fs::create_dir_all(parent_path.parent().unwrap()).unwrap(); + std::fs::write(&parent_path, "{ not valid json").unwrap(); + + let mut child = empty_profile("1.20.1-forge-47.2.0"); + child.inherits_from = Some("1.20.1".into()); + + let err = resolve(child, tmp.path()).await.unwrap_err(); + assert!( + matches!(err, ResolveError::ParseError(_, _)), + "expected ParseError, got {err:?}" + ); +} + +#[tokio::test] +async fn resolve_multi_level_chain_merges_all_parents() { + let tmp = TempDir::new().unwrap(); + + // chain: grandchild -> child -> root (vanilla). + let mut root = empty_profile("1.20.1"); + root.main_class = Some("net.minecraft.client.main.Main".into()); + root.libraries = vec![lib("vanilla-lib")]; + write_profile(tmp.path(), &root); + + let mut child = empty_profile("1.20.1-forge-47.2.0"); + child.inherits_from = Some("1.20.1".into()); + child.libraries = vec![lib("forge-lib")]; + write_profile(tmp.path(), &child); + + let mut grandchild = empty_profile("1.20.1-forge-47.2.0-modpack"); + grandchild.inherits_from = Some("1.20.1-forge-47.2.0".into()); + grandchild.libraries = vec![lib("modpack-lib")]; + + let resolved = resolve(grandchild, tmp.path()).await.unwrap(); + assert_eq!(resolved.id, "1.20.1-forge-47.2.0-modpack"); + assert!(resolved.inherits_from.is_none()); + assert_eq!( + resolved.main_class.as_deref(), + Some("net.minecraft.client.main.Main") + ); + // libs: root ++ child ++ grandchild (each parent prepended) + let names: Vec<_> = resolved.libraries.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, vec!["vanilla-lib", "forge-lib", "modpack-lib"]); +} + +#[tokio::test] +async fn resolve_detects_circular_chain() { + let tmp = TempDir::new().unwrap(); + + // a -> b -> a (cycle) + let mut a = empty_profile("a"); + a.inherits_from = Some("b".into()); + write_profile(tmp.path(), &a); + + let mut b = empty_profile("b"); + b.inherits_from = Some("a".into()); + write_profile(tmp.path(), &b); + + // start from a fresh "a" profile that asks to inherit from b + let mut entry = empty_profile("a"); + entry.inherits_from = Some("b".into()); + + let err = resolve(entry, tmp.path()).await.unwrap_err(); + assert!( + matches!(err, ResolveError::CircularInheritance(ref s) if s == "a"), + "expected CircularInheritance(a), got {err:?}" + ); +} + +#[tokio::test] +async fn resolve_caps_depth() { + let tmp = TempDir::new().unwrap(); + + // build a chain 0 -> 1 -> 2 -> ... -> 10. with cap of 8, hitting 10 + // should fail with DepthExceeded. + for i in 0..=10 { + let mut p = empty_profile(&format!("v{i}")); + if i < 10 { + p.inherits_from = Some(format!("v{}", i + 1)); + } + write_profile(tmp.path(), &p); + } + + let mut entry = empty_profile("entry"); + entry.inherits_from = Some("v0".into()); + + let err = resolve(entry, tmp.path()).await.unwrap_err(); + assert!( + matches!(err, ResolveError::DepthExceeded(_)), + "expected DepthExceeded, got {err:?}" + ); +} diff --git a/src/launch_profile/tests/rules.rs b/src/launch_profile/tests/rules.rs new file mode 100644 index 0000000..db703ef --- /dev/null +++ b/src/launch_profile/tests/rules.rs @@ -0,0 +1,269 @@ +use super::*; + +fn linux_ctx<'a>(features: &'a FeatureSet) -> RuleContext<'a> { + RuleContext { + os_name: "linux", + os_version: "6.0", + arch: "x86_64", + features, + } +} + +#[test] +fn empty_rules_allow() { + let features = FeatureSet::default(); + let ctx = linux_ctx(&features); + assert!(evaluate(&[], &ctx)); +} + +#[test] +fn single_allow_matching_os_allows() { + let rules = vec![Rule { + action: RuleAction::Allow, + os: Some(OsCondition { + name: Some("linux".into()), + arch: None, + ..Default::default() + }), + features: None, + }]; + let features = FeatureSet::default(); + let ctx = linux_ctx(&features); + assert!(evaluate(&rules, &ctx)); +} + +#[test] +fn single_disallow_matching_os_disallows() { + let rules = vec![Rule { + action: RuleAction::Disallow, + os: Some(OsCondition { + name: Some("linux".into()), + arch: None, + ..Default::default() + }), + features: None, + }]; + let features = FeatureSet::default(); + let ctx = linux_ctx(&features); + assert!(!evaluate(&rules, &ctx)); +} + +#[test] +fn allow_without_os_match_disallows_by_default() { + // explicit allow for windows; we are on linux; nothing matches; + // default state remains disallow. + let rules = vec![Rule { + action: RuleAction::Allow, + os: Some(OsCondition { + name: Some("windows".into()), + arch: None, + ..Default::default() + }), + features: None, + }]; + let features = FeatureSet::default(); + let ctx = linux_ctx(&features); + assert!(!evaluate(&rules, &ctx)); +} + +#[test] +fn last_matching_rule_wins_when_allow_then_disallow() { + let rules = vec![ + Rule { + action: RuleAction::Allow, + os: None, + features: None, + }, + Rule { + action: RuleAction::Disallow, + os: Some(OsCondition { + name: Some("osx".into()), + arch: None, + ..Default::default() + }), + features: None, + }, + ]; + let features = FeatureSet::default(); + let osx_ctx = RuleContext { + os_name: "osx", + os_version: "6.0", + arch: "x86_64", + features: &features, + }; + assert!(!evaluate(&rules, &osx_ctx)); + // and on linux: only the first rule matches → still allow. + let lin = linux_ctx(&features); + assert!(evaluate(&rules, &lin)); +} + +#[test] +fn last_matching_rule_wins_when_disallow_then_allow() { + let rules = vec![ + Rule { + action: RuleAction::Disallow, + os: None, + features: None, + }, + Rule { + action: RuleAction::Allow, + os: Some(OsCondition { + name: Some("linux".into()), + arch: None, + ..Default::default() + }), + features: None, + }, + ]; + let features = FeatureSet::default(); + let ctx = linux_ctx(&features); + assert!(evaluate(&rules, &ctx)); +} + +#[test] +fn arch_mismatch_blocks_rule() { + let rules = vec![Rule { + action: RuleAction::Allow, + os: Some(OsCondition { + name: Some("linux".into()), + arch: Some("arm64".into()), + version: None, + }), + features: None, + }]; + let features = FeatureSet::default(); + let ctx = linux_ctx(&features); + // we are on linux but x86_64, not arm64 → rule does not match. + assert!(!evaluate(&rules, &ctx)); +} + +#[test] +fn feature_required_true_matches_when_ctx_true() { + let rules = vec![Rule { + action: RuleAction::Allow, + os: None, + features: Some(FeatureSet { + is_demo_user: Some(true), + ..Default::default() + }), + }]; + let demo_features = FeatureSet { + is_demo_user: Some(true), + ..Default::default() + }; + let ctx = RuleContext { + os_name: "linux", + os_version: "6.0", + arch: "x86_64", + features: &demo_features, + }; + assert!(evaluate(&rules, &ctx)); +} + +#[test] +fn feature_required_true_blocks_when_ctx_false() { + let rules = vec![Rule { + action: RuleAction::Allow, + os: None, + features: Some(FeatureSet { + is_demo_user: Some(true), + ..Default::default() + }), + }]; + let features = FeatureSet::default(); + let ctx = linux_ctx(&features); + assert!(!evaluate(&rules, &ctx)); +} + +#[test] +fn no_os_no_features_rule_always_matches() { + let rules = vec![Rule { + action: RuleAction::Allow, + os: None, + features: None, + }]; + let features = FeatureSet::default(); + let ctx = linux_ctx(&features); + assert!(evaluate(&rules, &ctx)); +} + +#[test] +fn os_version_pattern_matches_against_host() { + let rules = vec![Rule { + action: RuleAction::Allow, + os: Some(OsCondition { + name: Some("osx".into()), + arch: None, + version: Some("^10\\.".into()), + }), + features: None, + }]; + let features = FeatureSet::default(); + let ctx_match = RuleContext { + os_name: "osx", + os_version: "10.15.7", + arch: "x86_64", + features: &features, + }; + assert!(evaluate(&rules, &ctx_match)); + let ctx_mismatch = RuleContext { + os_name: "osx", + os_version: "13.2.1", + arch: "x86_64", + features: &features, + }; + assert!(!evaluate(&rules, &ctx_mismatch)); +} + +#[test] +fn os_version_pattern_does_not_match_when_host_unknown() { + let rules = vec![Rule { + action: RuleAction::Allow, + os: Some(OsCondition { + name: Some("windows".into()), + arch: None, + version: Some("^10\\.".into()), + }), + features: None, + }]; + let features = FeatureSet::default(); + let ctx = RuleContext { + os_name: "windows", + os_version: "", + arch: "x86_64", + features: &features, + }; + assert!(!evaluate(&rules, &ctx)); +} + +#[test] +fn rule_deserializes_from_mojang_json() { + // shape lifted from real mojang library rules. + let json = r#"{ + "action": "allow", + "os": { "name": "osx" } + }"#; + let rule: Rule = serde_json::from_str(json).unwrap(); + assert_eq!(rule.action, RuleAction::Allow); + assert_eq!( + rule.os.as_ref().and_then(|o| o.name.as_deref()), + Some("osx") + ); + assert!(rule.os.as_ref().and_then(|o| o.arch.as_ref()).is_none()); + assert!(rule.features.is_none()); +} + +#[test] +fn rule_deserializes_with_features() { + let json = r#"{ + "action": "allow", + "features": { "is_demo_user": true } + }"#; + let rule: Rule = serde_json::from_str(json).unwrap(); + assert_eq!(rule.action, RuleAction::Allow); + assert!(rule.os.is_none()); + assert_eq!( + rule.features.as_ref().and_then(|f| f.is_demo_user), + Some(true) + ); +} diff --git a/src/launch_profile/tests/templates.rs b/src/launch_profile/tests/templates.rs new file mode 100644 index 0000000..447196a --- /dev/null +++ b/src/launch_profile/tests/templates.rs @@ -0,0 +1,114 @@ +use super::*; +use std::path::PathBuf; + +// owns the path buffers so tests don't have to declare them inline; the +// ctx() method borrows from self to build a TemplateContext with the +// standard set of values. windows() returns a fixture with backslash +// paths so the OS-independence test stays self-contained. +struct Fixture { + lib: PathBuf, + nat: PathBuf, + game: PathBuf, + assets: PathBuf, + user_properties: String, +} + +impl Fixture { + fn unix() -> Self { + Self { + lib: PathBuf::from("/m/libraries"), + nat: PathBuf::from("/m/natives"), + game: PathBuf::from("/i/.minecraft"), + assets: PathBuf::from("/m/assets"), + user_properties: "{}".to_string(), + } + } + + fn windows() -> Self { + Self { + lib: PathBuf::from(r"C:\Users\test\.minecraft\libraries"), + nat: PathBuf::from(r"C:\Users\test\.minecraft\natives"), + game: PathBuf::from(r"C:\Users\test\.minecraft"), + assets: PathBuf::from(r"C:\Users\test\.minecraft\assets"), + user_properties: "{}".to_string(), + } + } + + fn ctx(&self) -> TemplateContext<'_> { + TemplateContext { + library_directory: &self.lib, + classpath_separator: ":", + version_name: "1.20.1", + version_type: "release", + natives_directory: &self.nat, + classpath: "a.jar:b.jar", + game_directory: &self.game, + assets_root: &self.assets, + assets_index_name: "5", + auth_player_name: "Player", + auth_uuid: "00000000-0000-0000-0000-000000000000", + auth_access_token: "token", + auth_xuid: "0", + user_type: "msa", + user_properties: &self.user_properties, + launcher_name: "rmcl", + launcher_version: "0.3.0", + clientid: "0", + quick_play_singleplayer: None, + } + } +} + +#[rstest::rstest] +#[case::no_placeholders("--add-modules ALL-MODULE-PATH", "--add-modules ALL-MODULE-PATH")] +#[case::single_known("v=${version_name}", "v=1.20.1")] +#[case::unknown_placeholder("x=${not_a_real_var}y", "x=${not_a_real_var}y")] +#[case::unclosed_placeholder("--prefix ${unclosed", "--prefix ${unclosed")] +#[case::dollar_without_brace("$$ literal $5 $", "$$ literal $5 $")] +#[case::multiple("${version_name}-${auth_player_name}", "1.20.1-Player")] +#[case::path( + "-DlibraryDirectory=${library_directory}", + "-DlibraryDirectory=/m/libraries" +)] +#[case::empty_input("", "")] +fn substitute_handles(#[case] input: &str, #[case] expected: &str) { + let fx = Fixture::unix(); + assert_eq!(substitute(input, &fx.ctx()), expected); +} + +#[test] +fn substituted_value_is_not_recursively_substituted() { + // simulate a user_properties value that happens to contain a ${...} + // pattern. it should NOT trigger another substitution pass. + let mut fx = Fixture::unix(); + fx.user_properties = "${version_name}".to_string(); + assert_eq!( + substitute("${user_properties}", &fx.ctx()), + "${version_name}" + ); +} + +#[test] +fn windows_style_backslashes_in_value_pass_through() { + // simulate a Windows install where library_directory is a path with + // backslashes. the substitution must not interpret backslashes as + // escape sequences or do anything else clever - it just copies the + // value into the output. + let fx = Fixture::windows(); + let result = substitute("-Dpath=${library_directory}", &fx.ctx()); + assert!( + result.contains(r"C:\Users\test\.minecraft\libraries"), + "expected backslashes preserved, got: {result}" + ); +} + +#[test] +fn quick_play_world_is_substituted_when_present() { + let fx = Fixture::unix(); + let mut context = fx.ctx(); + context.quick_play_singleplayer = Some("New World"); + assert_eq!( + substitute("${quickPlaySingleplayer}", &context), + "New World" + ); +} diff --git a/src/layout_migration.rs b/src/layout_migration.rs new file mode 100644 index 0000000..dacf891 --- /dev/null +++ b/src/layout_migration.rs @@ -0,0 +1,648 @@ +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::storage::{InstancePaths, LAYOUT_VERSION, MetadataPaths}; + +const LEGACY_MINECRAFT: &str = ".minecraft"; +const LEGACY_STATE: &str = ".rmcl"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MigrationProgress { + pub phase: String, + pub item: String, + pub current: u64, + pub total: u64, + pub item_current: Option, + pub item_total: Option, + pub backup_dir: Option, +} + +impl MigrationProgress { + fn new(phase: impl Into, item: impl Into, current: u64, total: u64) -> Self { + Self { + phase: phase.into(), + item: item.into(), + current, + total, + item_current: None, + item_total: None, + backup_dir: None, + } + } + + fn with_item_progress(mut self, current: u64, total: u64) -> Self { + self.item_current = Some(current); + self.item_total = Some(total); + self + } + + fn with_backup(mut self, backup_dir: &Path) -> Self { + self.backup_dir = Some(backup_dir.to_owned()); + self + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct MigrationJournal { + version: u32, + backup_dir: PathBuf, + completed: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct LayoutMarker { + version: u32, +} + +#[derive(Debug, thiserror::Error)] +pub enum MigrationError { + #[error("I/O error: {0}")] + Io(#[from] io::Error), + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + #[error("Cannot migrate {instance}: both {old} and {new} exist")] + PathConflict { + instance: String, + old: String, + new: String, + }, + #[error("Cannot merge migration data because both paths contain {path}")] + MergeConflict { path: String }, + #[error("Migration backup would be created inside the data being backed up: {0}")] + BackupOverlap(String), + #[error( + "Not enough free space for migration backup: need {required} bytes, have {available} bytes" + )] + InsufficientSpace { required: u64, available: u64 }, +} + +pub fn is_needed(instances_dir: &Path, meta_dir: &Path) -> bool { + let metadata = MetadataPaths::new(meta_dir); + if metadata.cache_rebuild_pending().exists() { + return true; + } + if marker_version(&metadata.layout_marker()) == Some(LAYOUT_VERSION) { + return false; + } + has_legacy_instances(instances_dir) + || [ + "versions", + "libraries", + "assets", + "loader-profiles", + "config-sync", + ] + .iter() + .any(|path| meta_dir.join(path).exists()) +} + +pub fn initialize_new_layout(meta_dir: &Path) -> Result<(), MigrationError> { + let metadata = MetadataPaths::new(meta_dir); + for directory in [ + metadata.profiles(), + metadata.backups(), + metadata.versions(), + metadata.libraries(), + metadata.assets(), + metadata.loader_profiles(), + metadata.provider_projects("modrinth"), + metadata.provider_versions("modrinth"), + metadata.provider_icons("modrinth"), + metadata.temporary(), + ] { + fs::create_dir_all(directory)?; + } + write_json_atomic( + &metadata.layout_marker(), + &LayoutMarker { + version: LAYOUT_VERSION, + }, + ) +} + +pub fn run( + instances_dir: &Path, + meta_dir: &Path, + config_file: &Path, + mut report: impl FnMut(MigrationProgress), +) -> Result { + fs::create_dir_all(instances_dir)?; + fs::create_dir_all(meta_dir)?; + let metadata = MetadataPaths::new(meta_dir); + if marker_version(&metadata.layout_marker()) == Some(LAYOUT_VERSION) + && metadata.cache_rebuild_pending().exists() + { + return Ok(latest_layout_backup(&metadata).unwrap_or_else(|| metadata.backups())); + } + if !is_needed(instances_dir, meta_dir) { + initialize_new_layout(meta_dir)?; + return Ok(metadata.backups()); + } + fs::create_dir_all(metadata.state())?; + fs::write(metadata.cache_rebuild_pending(), LAYOUT_VERSION.to_string())?; + + let mut journal = load_or_create_journal(instances_dir, &metadata)?; + let instances = instance_directories(instances_dir)?; + let total = instances.len() as u64 + 7; + let mut current = journal.completed.len() as u64; + + if !is_complete(&journal, "backup") { + let backup_dir = journal.backup_dir.clone(); + backup_user_data( + instances_dir, + config_file, + meta_dir, + &backup_dir, + |copied, bytes, path| { + report( + MigrationProgress::new( + "Backing up user data", + path.display().to_string(), + current, + total, + ) + .with_item_progress(copied, bytes) + .with_backup(&backup_dir), + ); + }, + )?; + complete(&mut journal, &metadata, "backup")?; + current += 1; + } + + validate_migration_conflicts(&instances, meta_dir)?; + + for instance in &instances { + let name = instance + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("instance") + .to_owned(); + let key = format!("instance:{name}"); + if is_complete(&journal, &key) { + continue; + } + report( + MigrationProgress::new("Migrating instances", name.clone(), current, total) + .with_backup(&journal.backup_dir), + ); + migrate_instance(instance, &name)?; + complete(&mut journal, &metadata, &key)?; + current += 1; + } + + let moves = [ + ( + "profiles", + meta_dir.join("config-sync").join("profiles"), + metadata.profiles(), + ), + ("versions", meta_dir.join("versions"), metadata.versions()), + ( + "libraries", + meta_dir.join("libraries"), + metadata.libraries(), + ), + ("assets", meta_dir.join("assets"), metadata.assets()), + ( + "loader-profiles", + meta_dir.join("loader-profiles"), + metadata.loader_profiles(), + ), + ]; + for (key, source, destination) in moves { + let journal_key = format!("shared:{key}"); + if is_complete(&journal, &journal_key) { + continue; + } + report( + MigrationProgress::new("Migrating shared data", key, current, total) + .with_backup(&journal.backup_dir), + ); + move_or_merge(&source, &destination)?; + complete(&mut journal, &metadata, &journal_key)?; + current += 1; + } + let legacy_config_sync = meta_dir.join("config-sync"); + if legacy_config_sync.exists() && fs::read_dir(&legacy_config_sync)?.next().is_none() { + fs::remove_dir(legacy_config_sync)?; + } + + report( + MigrationProgress::new( + "Finalizing migration", + "Writing layout marker", + total.saturating_sub(1), + total, + ) + .with_backup(&journal.backup_dir), + ); + initialize_new_layout(meta_dir)?; + if metadata.migration_journal().exists() { + fs::remove_file(metadata.migration_journal())?; + } + report( + MigrationProgress::new("Migration complete", "Layout updated", total, total) + .with_backup(&journal.backup_dir), + ); + Ok(journal.backup_dir) +} + +pub fn cache_rebuild_pending(meta_dir: &Path) -> bool { + MetadataPaths::new(meta_dir) + .cache_rebuild_pending() + .exists() +} + +pub fn finish_cache_rebuild(meta_dir: &Path) -> Result<(), MigrationError> { + let marker = MetadataPaths::new(meta_dir).cache_rebuild_pending(); + if marker.exists() { + fs::remove_file(marker)?; + } + Ok(()) +} + +fn migrate_instance(instance: &Path, name: &str) -> Result<(), MigrationError> { + let paths = InstancePaths::new(instance); + rename_visible_directory(instance, LEGACY_MINECRAFT, paths.minecraft(), name)?; + rename_visible_directory(instance, LEGACY_STATE, paths.state(), name)?; + + let old_config = paths.state().join("config-sync").join("local-config"); + move_or_merge(&old_config, &paths.local_config())?; + let old_config_root = paths.state().join("config-sync"); + if old_config_root.exists() && fs::read_dir(&old_config_root)?.next().is_none() { + fs::remove_dir(old_config_root)?; + } + fs::create_dir_all(paths.content())?; + Ok(()) +} + +fn rename_visible_directory( + instance: &Path, + legacy_name: &str, + destination: PathBuf, + instance_name: &str, +) -> Result<(), MigrationError> { + let source = instance.join(legacy_name); + if source.exists() && destination.exists() { + return Err(MigrationError::PathConflict { + instance: instance_name.to_owned(), + old: source.display().to_string(), + new: destination.display().to_string(), + }); + } + if source.exists() { + fs::rename(source, destination)?; + } + Ok(()) +} + +fn backup_user_data( + instances_dir: &Path, + config_file: &Path, + meta_dir: &Path, + backup_dir: &Path, + mut report: impl FnMut(u64, u64, &Path), +) -> Result<(), MigrationError> { + if backup_dir.exists() { + return Ok(()); + } + let legacy_profiles = meta_dir.join("config-sync").join("profiles"); + let current_profiles = MetadataPaths::new(meta_dir).profiles(); + let total = tree_size(instances_dir)? + .saturating_add(file_size(config_file)?) + .saturating_add(tree_size(&legacy_profiles)?) + .saturating_add(tree_size(¤t_profiles)?); + validate_backup_destination(instances_dir, backup_dir, total)?; + let partial = backup_dir.with_extension("partial"); + if partial.exists() { + fs::remove_dir_all(&partial)?; + } + let mut copied = 0; + report(copied, total, instances_dir); + fs::create_dir_all(&partial)?; + copy_dir_recursive_with_progress( + instances_dir, + &partial.join("instances"), + &mut |bytes, path| { + copied = copied.saturating_add(bytes); + report(copied, total, path); + }, + )?; + if config_file.exists() { + let destination = partial.join("config").join("config.toml"); + fs::create_dir_all(destination.parent().unwrap())?; + let bytes = fs::copy(config_file, destination)?; + copied = copied.saturating_add(bytes); + report(copied, total, config_file); + } + if legacy_profiles.exists() { + copy_dir_recursive_with_progress( + &legacy_profiles, + &partial.join("profiles/legacy"), + &mut |bytes, path| { + copied = copied.saturating_add(bytes); + report(copied, total, path); + }, + )?; + } + if current_profiles.exists() { + copy_dir_recursive_with_progress( + ¤t_profiles, + &partial.join("profiles/current"), + &mut |bytes, path| { + copied = copied.saturating_add(bytes); + report(copied, total, path); + }, + )?; + } + fs::rename(partial, backup_dir)?; + Ok(()) +} + +fn validate_migration_conflicts( + instances: &[PathBuf], + meta_dir: &Path, +) -> Result<(), MigrationError> { + for instance in instances { + let name = instance + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("instance"); + let paths = InstancePaths::new(instance); + for (legacy, destination) in [ + (instance.join(LEGACY_MINECRAFT), paths.minecraft()), + (instance.join(LEGACY_STATE), paths.state()), + ] { + if legacy.exists() && destination.exists() { + return Err(MigrationError::PathConflict { + instance: name.to_owned(), + old: legacy.display().to_string(), + new: destination.display().to_string(), + }); + } + } + validate_merge( + &paths.state().join("config-sync").join("local-config"), + &paths.local_config(), + )?; + } + let metadata = MetadataPaths::new(meta_dir); + for (source, destination) in [ + ( + meta_dir.join("config-sync").join("profiles"), + metadata.profiles(), + ), + (meta_dir.join("versions"), metadata.versions()), + (meta_dir.join("libraries"), metadata.libraries()), + (meta_dir.join("assets"), metadata.assets()), + (meta_dir.join("loader-profiles"), metadata.loader_profiles()), + ] { + validate_merge(&source, &destination)?; + } + Ok(()) +} + +fn validate_merge(source: &Path, destination: &Path) -> Result<(), MigrationError> { + if !source.exists() || !destination.exists() { + return Ok(()); + } + for entry in fs::read_dir(source)? { + let entry = entry?; + let target = destination.join(entry.file_name()); + if !target.exists() { + continue; + } + if entry.file_type()?.is_dir() && target.is_dir() { + validate_merge(&entry.path(), &target)?; + } else { + return Err(MigrationError::MergeConflict { + path: target.display().to_string(), + }); + } + } + Ok(()) +} + +fn validate_backup_destination( + instances_dir: &Path, + backup_dir: &Path, + required: u64, +) -> Result<(), MigrationError> { + let instances = instances_dir.canonicalize()?; + let backup_parent = backup_dir + .parent() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "backup has no parent"))?; + fs::create_dir_all(backup_parent)?; + let backup_parent = backup_parent.canonicalize()?; + let backup_name = backup_dir + .file_name() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "backup has no name"))?; + let canonical_backup = backup_parent.join(backup_name); + if canonical_backup.starts_with(&instances) { + return Err(MigrationError::BackupOverlap( + canonical_backup.display().to_string(), + )); + } + let available = fs2::available_space(&backup_parent)?; + let margin = required / 20; + let required_with_margin = required + .saturating_add(margin) + .saturating_add(16 * 1024 * 1024); + if available < required_with_margin { + return Err(MigrationError::InsufficientSpace { + required: required_with_margin, + available, + }); + } + Ok(()) +} + +fn tree_size(path: &Path) -> io::Result { + if !path.exists() { + return Ok(0); + } + let mut total = 0_u64; + for entry in fs::read_dir(path)? { + let entry = entry?; + let file_type = entry.file_type()?; + if file_type.is_dir() { + total = total.saturating_add(tree_size(&entry.path())?); + } else if file_type.is_file() { + total = total.saturating_add(entry.metadata()?.len()); + } + } + Ok(total) +} + +fn file_size(path: &Path) -> io::Result { + if path.exists() { + Ok(fs::metadata(path)?.len()) + } else { + Ok(0) + } +} + +fn move_or_merge(source: &Path, destination: &Path) -> Result<(), MigrationError> { + if !source.exists() { + return Ok(()); + } + if !destination.exists() { + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent)?; + } + fs::rename(source, destination)?; + return Ok(()); + } + merge_dir_without_overwrite(source, destination)?; + fs::remove_dir_all(source)?; + Ok(()) +} + +fn merge_dir_without_overwrite(source: &Path, destination: &Path) -> Result<(), MigrationError> { + fs::create_dir_all(destination)?; + for entry in fs::read_dir(source)? { + let entry = entry?; + let target = destination.join(entry.file_name()); + let file_type = entry.file_type()?; + if !target.exists() { + fs::rename(entry.path(), target)?; + } else if file_type.is_dir() && target.is_dir() { + merge_dir_without_overwrite(&entry.path(), &target)?; + fs::remove_dir(entry.path())?; + } else { + return Err(MigrationError::MergeConflict { + path: target.display().to_string(), + }); + } + } + Ok(()) +} + +fn copy_dir_recursive_with_progress( + source: &Path, + destination: &Path, + report: &mut impl FnMut(u64, &Path), +) -> io::Result<()> { + fs::create_dir_all(destination)?; + for entry in fs::read_dir(source)? { + let entry = entry?; + let file_type = entry.file_type()?; + let target = destination.join(entry.file_name()); + if file_type.is_symlink() { + copy_symlink(&entry.path(), &target)?; + } else if file_type.is_dir() { + copy_dir_recursive_with_progress(&entry.path(), &target, report)?; + } else { + let path = entry.path(); + let copied = fs::copy(&path, target)?; + report(copied, &path); + } + } + Ok(()) +} + +#[cfg(unix)] +fn copy_symlink(source: &Path, destination: &Path) -> io::Result<()> { + std::os::unix::fs::symlink(fs::read_link(source)?, destination) +} + +#[cfg(windows)] +fn copy_symlink(source: &Path, destination: &Path) -> io::Result<()> { + let target = fs::read_link(source)?; + if source.is_dir() { + std::os::windows::fs::symlink_dir(target, destination) + } else { + std::os::windows::fs::symlink_file(target, destination) + } +} + +fn load_or_create_journal( + _instances_dir: &Path, + metadata: &MetadataPaths, +) -> Result { + let legacy_journal = metadata.state().join("migration-v2.json"); + if metadata.migration_journal().exists() { + return Ok(serde_json::from_slice(&fs::read( + metadata.migration_journal(), + )?)?); + } + if legacy_journal.exists() { + let journal = serde_json::from_slice(&fs::read(&legacy_journal)?)?; + write_json_atomic(&metadata.migration_journal(), &journal)?; + fs::remove_file(legacy_journal)?; + return Ok(journal); + } + let timestamp = chrono::Local::now().format("%Y%m%d-%H%M%S").to_string(); + let journal = MigrationJournal { + version: LAYOUT_VERSION, + backup_dir: metadata.backups().join(format!("backup-{timestamp}")), + completed: Vec::new(), + }; + write_json_atomic(&metadata.migration_journal(), &journal)?; + Ok(journal) +} + +fn latest_layout_backup(metadata: &MetadataPaths) -> Option { + let mut backups = fs::read_dir(metadata.backups()) + .ok()? + .flatten() + .filter_map(|entry| { + let file_type = entry.file_type().ok()?; + let name = entry.file_name(); + let name = name.to_string_lossy(); + (file_type.is_dir() && (name.starts_with("backup-") || name.starts_with("layout-v2-"))) + .then(|| entry.path()) + }) + .collect::>(); + backups.sort(); + backups.pop() +} + +fn complete( + journal: &mut MigrationJournal, + metadata: &MetadataPaths, + key: &str, +) -> Result<(), MigrationError> { + journal.completed.push(key.to_owned()); + write_json_atomic(&metadata.migration_journal(), journal) +} + +fn is_complete(journal: &MigrationJournal, key: &str) -> bool { + journal.completed.iter().any(|completed| completed == key) +} + +fn instance_directories(instances_dir: &Path) -> io::Result> { + let mut instances = fs::read_dir(instances_dir)? + .flatten() + .filter_map(|entry| entry.file_type().ok()?.is_dir().then(|| entry.path())) + .collect::>(); + instances.sort(); + Ok(instances) +} + +fn has_legacy_instances(instances_dir: &Path) -> bool { + instance_directories(instances_dir).is_ok_and(|instances| { + instances.iter().any(|instance| { + instance.join(LEGACY_MINECRAFT).exists() || instance.join(LEGACY_STATE).exists() + }) + }) +} + +fn marker_version(path: &Path) -> Option { + serde_json::from_slice::(&fs::read(path).ok()?) + .ok() + .map(|marker| marker.version) +} + +fn write_json_atomic(path: &Path, value: &impl Serialize) -> Result<(), MigrationError> { + crate::storage::write_atomic(path, &serde_json::to_vec_pretty(value)?)?; + Ok(()) +} + +#[cfg(test)] +#[path = "tests/layout_migration.rs"] +mod tests; diff --git a/src/lib.rs b/src/lib.rs index be43188..8a1f5e2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,13 +7,18 @@ pub mod auth; mod cli; pub mod config; +pub mod feedback; pub mod instance; -pub mod instance_logs; pub mod launch_profile; +pub mod layout_migration; mod migrate; pub mod net; -pub mod running; +pub mod storage; +mod time; pub mod tui; +#[cfg(test)] +pub(crate) mod tests; + pub use cli::init as cli_init; pub use migrate::run_legacy_rename as migrate_legacy_rename; diff --git a/src/migrate.rs b/src/migrate.rs index b23b647..e9b73d7 100644 --- a/src/migrate.rs +++ b/src/migrate.rs @@ -97,7 +97,7 @@ fn cleanup_instance_leftovers(instances_dir: &Path) { return; }; for entry in entries.flatten() { - let mc = entry.path().join(".minecraft"); + let mc = entry.path().join(crate::storage::MINECRAFT_DIR_NAME); for leftover in [".mcl-shim.jar", ".mcl-log4j2.xml"] { let p = mc.join(leftover); if p.exists() { @@ -173,125 +173,5 @@ fn sanitize(name: &str) -> String { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn rename_top_level_moves_when_only_old_exists() { - let tmp = tempfile::tempdir().unwrap(); - let old = tmp.path().join("mcl"); - let new = tmp.path().join("rmcl"); - fs::create_dir_all(old.join("sub")).unwrap(); - fs::write(old.join("sub").join("f.txt"), b"hi").unwrap(); - - rename_top_level(&old, &new); - - assert!(!old.exists()); - assert!(new.exists()); - assert_eq!(fs::read(new.join("sub").join("f.txt")).unwrap(), b"hi"); - } - - #[test] - fn rename_top_level_skips_when_only_new_exists() { - let tmp = tempfile::tempdir().unwrap(); - let old = tmp.path().join("mcl"); - let new = tmp.path().join("rmcl"); - fs::create_dir_all(&new).unwrap(); - fs::write(new.join("marker.txt"), b"keep").unwrap(); - - rename_top_level(&old, &new); - - assert!(!old.exists()); - assert_eq!(fs::read(new.join("marker.txt")).unwrap(), b"keep"); - } - - #[test] - fn rename_top_level_skips_when_both_exist() { - let tmp = tempfile::tempdir().unwrap(); - let old = tmp.path().join("mcl"); - let new = tmp.path().join("rmcl"); - fs::create_dir_all(&old).unwrap(); - fs::create_dir_all(&new).unwrap(); - fs::write(old.join("a"), b"old").unwrap(); - fs::write(new.join("b"), b"new").unwrap(); - - rename_top_level(&old, &new); - - assert!(old.exists(), "old should remain when both exist"); - assert!(new.exists(), "new should remain when both exist"); - assert_eq!(fs::read(old.join("a")).unwrap(), b"old"); - assert_eq!(fs::read(new.join("b")).unwrap(), b"new"); - } - - #[test] - fn rename_top_level_noop_when_neither_exists() { - let tmp = tempfile::tempdir().unwrap(); - let old = tmp.path().join("mcl"); - let new = tmp.path().join("rmcl"); - - rename_top_level(&old, &new); - - assert!(!old.exists()); - assert!(!new.exists()); - } - - #[test] - fn cleanup_instance_leftovers_removes_shim_and_log4j() { - let tmp = tempfile::tempdir().unwrap(); - let instances = tmp.path().join("instances"); - let mc = instances.join("Test").join(".minecraft"); - fs::create_dir_all(&mc).unwrap(); - fs::write(mc.join(".mcl-shim.jar"), b"jar").unwrap(); - fs::write(mc.join(".mcl-log4j2.xml"), b"xml").unwrap(); - fs::write(mc.join("keep.txt"), b"keep").unwrap(); - - cleanup_instance_leftovers(&instances); - - assert!(!mc.join(".mcl-shim.jar").exists()); - assert!(!mc.join(".mcl-log4j2.xml").exists()); - assert!(mc.join("keep.txt").exists()); - } - - #[test] - #[cfg(target_os = "linux")] - fn rewrite_linux_desktop_entries_renames_and_rewrites_exec() { - let tmp = tempfile::tempdir().unwrap(); - let data = tmp.path(); - let instances = data.join("rmcl").join("instances"); - let apps = data.join("applications"); - fs::create_dir_all(instances.join("MyPack")).unwrap(); - fs::create_dir_all(&apps).unwrap(); - let old_entry = apps.join("mcl-MyPack.desktop"); - fs::write( - &old_entry, - "[Desktop Entry]\nName=Test\nExec=mcl instance launch \"MyPack\"\n", - ) - .unwrap(); - - rewrite_linux_desktop_entries(data, &instances); - - let new_entry = apps.join("rmcl-MyPack.desktop"); - assert!(!old_entry.exists(), "old .desktop should be removed"); - assert!(new_entry.exists(), "new .desktop should exist"); - let content = fs::read_to_string(&new_entry).unwrap(); - assert!(content.contains("Exec=rmcl instance launch \"MyPack\"")); - } - - #[test] - fn copy_dir_recursive_copies_nested_tree() { - let tmp = tempfile::tempdir().unwrap(); - let src = tmp.path().join("a"); - let dst = tmp.path().join("b"); - fs::create_dir_all(src.join("nested")).unwrap(); - fs::write(src.join("top.txt"), b"top").unwrap(); - fs::write(src.join("nested").join("inner.txt"), b"inner").unwrap(); - - copy_dir_recursive(&src, &dst).unwrap(); - - assert_eq!(fs::read(dst.join("top.txt")).unwrap(), b"top"); - assert_eq!( - fs::read(dst.join("nested").join("inner.txt")).unwrap(), - b"inner" - ); - } -} +#[path = "tests/migrate.rs"] +mod tests; diff --git a/src/net/curseforge.rs b/src/net/curseforge.rs new file mode 100644 index 0000000..eecbb24 --- /dev/null +++ b/src/net/curseforge.rs @@ -0,0 +1,538 @@ +// curseforge api client. responses are mapped into the same app-facing +// project/version types used by discovery, so the tui stays provider-neutral. + +use serde::Deserialize; + +use crate::instance::{ContentKind, ModLoader}; +use crate::net::modrinth::{ + DependencyType, DiscoveryProject, DiscoveryResults, ProjectInfo, VersionDependency, + VersionFile, VersionInfo, VersionType, url_encode, +}; +use crate::net::{HttpClient, NetError}; + +const API_BASE: &str = "https://api.curseforge.com/v1"; +const MINECRAFT_GAME_ID: u32 = 432; +const MODS_CLASS_ID: u32 = 6; +const RESOURCE_PACKS_CLASS_ID: u32 = 12; +const SHADERS_CLASS_ID: u32 = 6552; +const DATA_PACKS_CLASS_ID: u32 = 6945; +pub const MODPACKS_CLASS_ID: u32 = 4471; + +pub fn api_key() -> Option<&'static str> { + option_env!("CURSEFORGE_API_KEY") + .map(str::trim) + .filter(|key| !key.is_empty()) +} + +#[derive(Debug, Deserialize)] +struct ApiResponse { + data: T, + #[serde(default)] + pagination: Pagination, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Pagination { + total_count: usize, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Mod { + id: u64, + #[serde(default)] + class_id: u32, + name: String, + slug: String, + #[serde(default)] + summary: String, + #[serde(default)] + download_count: u64, + logo: Option, + #[serde(default)] + categories: Vec, +} + +#[derive(Debug, Deserialize)] +struct Category { + name: String, + slug: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Logo { + thumbnail_url: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct File { + id: u64, + mod_id: u64, + display_name: String, + file_name: String, + #[serde(default)] + file_date: String, + #[serde(default)] + file_length: u64, + download_url: Option, + #[serde(default)] + game_versions: Vec, + #[serde(default = "default_release_type")] + release_type: u8, + #[serde(default)] + dependencies: Vec, + #[serde(default)] + hashes: Vec, +} + +fn default_release_type() -> u8 { + 1 +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct FileDependency { + mod_id: u64, + relation_type: u8, +} + +#[derive(Debug, Deserialize)] +struct FileHash { + value: String, + algo: u8, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct FingerprintResponse { + exact_matches: Vec, +} + +#[derive(Debug, Deserialize)] +struct FingerprintMatch { + id: u32, + file: File, +} + +#[derive(Debug, serde::Serialize)] +struct FingerprintRequest<'a> { + fingerprints: &'a [u32], +} + +#[derive(Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct FilesRequest<'a> { + file_ids: &'a [u64], +} + +async fn get( + client: &HttpClient, + api_key: &str, + url: &str, +) -> Result { + let response = client + .inner() + .get(url) + .header("x-api-key", api_key) + .send() + .await?; + if !response.status().is_success() { + return Err(NetError::StatusError { + status: response.status().as_u16(), + url: url.to_owned(), + }); + } + Ok(response.json().await?) +} + +async fn post( + client: &HttpClient, + api_key: &str, + url: &str, + body: &B, +) -> Result { + let response = client + .inner() + .post(url) + .header("x-api-key", api_key) + .json(body) + .send() + .await?; + if !response.status().is_success() { + return Err(NetError::StatusError { + status: response.status().as_u16(), + url: url.to_owned(), + }); + } + Ok(response.json().await?) +} + +#[allow(clippy::too_many_arguments)] +pub async fn search_discovery( + client: &HttpClient, + api_key: &str, + kind: ContentKind, + query: &str, + game_version: &str, + loader: ModLoader, + offset: usize, + limit: usize, +) -> Result { + search( + client, + api_key, + class_id(kind), + query, + game_version, + (kind == ContentKind::Mod).then_some(loader), + offset, + limit, + ) + .await +} + +pub async fn search_modpacks( + client: &HttpClient, + api_key: &str, + query: &str, + offset: usize, + limit: usize, +) -> Result { + search( + client, + api_key, + MODPACKS_CLASS_ID, + query, + "", + None, + offset, + limit, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +async fn search( + client: &HttpClient, + api_key: &str, + class_id: u32, + query: &str, + game_version: &str, + loader: Option, + offset: usize, + limit: usize, +) -> Result { + let loader = loader.and_then(loader_type); + let mut projects = Vec::new(); + let mut total_hits = 0; + while projects.len() < limit { + let page_size = (limit - projects.len()).min(50); + let mut params = vec![ + format!("gameId={MINECRAFT_GAME_ID}"), + format!("classId={class_id}"), + format!("index={}", offset + projects.len()), + format!("pageSize={page_size}"), + "sortField=6".to_owned(), + "sortOrder=desc".to_owned(), + ]; + if !query.trim().is_empty() { + params.push(format!("searchFilter={}", url_encode(query.trim()))); + } + if !game_version.is_empty() { + params.push(format!("gameVersion={}", url_encode(game_version))); + } + if let Some(loader) = loader { + params.push(format!("modLoaderType={loader}")); + } + let response: ApiResponse> = get( + client, + api_key, + &format!("{API_BASE}/mods/search?{}", params.join("&")), + ) + .await?; + total_hits = response.pagination.total_count; + let received = response.data.len(); + projects.extend(response.data); + if received < page_size { + break; + } + } + Ok(DiscoveryResults { + total_hits, + projects: projects + .into_iter() + .map(|project| DiscoveryProject { + id: project.id.to_string(), + slug: project.slug, + title: project.name, + description: project.summary, + downloads: project.download_count, + icon_url: project + .logo + .map(|logo| logo.thumbnail_url) + .filter(|url| !url.trim().is_empty()), + icon_bytes: None, + }) + .collect(), + }) +} + +pub async fn fetch_project( + client: &HttpClient, + api_key: &str, + project_id: &str, +) -> Result { + let project: ApiResponse = get( + client, + api_key, + &format!("{API_BASE}/mods/{}", url_encode(project_id)), + ) + .await?; + let description: ApiResponse = get( + client, + api_key, + &format!( + "{API_BASE}/mods/{}/description?raw=true", + url_encode(project_id) + ), + ) + .await?; + Ok(project_info(project.data, description.data)) +} + +fn project_info(project: Mod, body: String) -> ProjectInfo { + ProjectInfo { + id: project.id.to_string(), + slug: project.slug, + title: project.name, + description: project.summary, + body, + icon_url: project.logo.map(|logo| logo.thumbnail_url), + categories: project + .categories + .into_iter() + .map(|category| { + if category.slug.is_empty() { + category.name + } else { + category.slug + } + }) + .collect(), + additional_categories: Vec::new(), + project_type: match project.class_id { + MODS_CLASS_ID => "mod", + RESOURCE_PACKS_CLASS_ID => "resourcepack", + SHADERS_CLASS_ID => "shader", + DATA_PACKS_CLASS_ID => "datapack", + _ => "", + } + .to_owned(), + loaders: Vec::new(), + } +} + +pub async fn fetch_versions( + client: &HttpClient, + api_key: &str, + project_id: &str, + game_version: &str, + loader: Option, +) -> Result, NetError> { + fetch_versions_from(client, api_key, API_BASE, project_id, game_version, loader).await +} + +async fn fetch_versions_from( + client: &HttpClient, + api_key: &str, + api_base: &str, + project_id: &str, + game_version: &str, + loader: Option, +) -> Result, NetError> { + let loader = loader.and_then(loader_type); + let mut files = Vec::new(); + loop { + let mut params = vec![format!("index={}", files.len()), "pageSize=50".to_owned()]; + if !game_version.is_empty() { + params.push(format!("gameVersion={}", url_encode(game_version))); + } + if let Some(loader) = loader { + params.push(format!("modLoaderType={loader}")); + } + let response: ApiResponse> = get( + client, + api_key, + &format!( + "{api_base}/mods/{}/files?{}", + url_encode(project_id), + params.join("&") + ), + ) + .await?; + let total_count = response.pagination.total_count.min(10_000); + let received = response.data.len(); + files.extend(response.data); + if received < 50 || files.len() >= total_count { + break; + } + } + Ok(files.into_iter().map(version_info).collect()) +} + +pub async fn fetch_file_versions( + client: &HttpClient, + api_key: &str, + file_ids: &[u64], +) -> Result, NetError> { + let mut versions = Vec::with_capacity(file_ids.len()); + for ids in file_ids.chunks(50) { + let response: ApiResponse> = post( + client, + api_key, + &format!("{API_BASE}/mods/files"), + &FilesRequest { file_ids: ids }, + ) + .await?; + versions.extend(response.data.into_iter().map(version_info)); + } + Ok(versions) +} + +pub async fn resolve_fingerprints( + client: &HttpClient, + api_key: &str, + fingerprints: &[u32], +) -> Result, NetError> { + if fingerprints.is_empty() { + return Ok(Vec::new()); + } + let response: ApiResponse = post( + client, + api_key, + &format!("{API_BASE}/fingerprints/{MINECRAFT_GAME_ID}"), + &FingerprintRequest { fingerprints }, + ) + .await?; + Ok(response + .data + .exact_matches + .into_iter() + .map(|matched| { + ( + matched.id, + matched.file.mod_id.to_string(), + matched.file.id.to_string(), + ) + }) + .collect()) +} + +pub async fn ensure_download_url( + client: &HttpClient, + api_key: &str, + version: &mut VersionInfo, +) -> Result<(), NetError> { + let Some(file) = version.files.first_mut() else { + return Err(NetError::Parse("No files in version".to_owned())); + }; + if !file.url.is_empty() { + return Ok(()); + } + let response: ApiResponse = get( + client, + api_key, + &format!( + "{API_BASE}/mods/{}/files/{}/download-url", + url_encode(&version.project_id), + url_encode(&version.id) + ), + ) + .await?; + file.url = response.data; + Ok(()) +} + +fn version_info(file: File) -> VersionInfo { + let loaders = file + .game_versions + .iter() + .filter_map(|version| match version.to_ascii_lowercase().as_str() { + "fabric" | "forge" | "neoforge" | "quilt" => Some(version.to_ascii_lowercase()), + _ => None, + }) + .collect(); + let hashes = file + .hashes + .into_iter() + .filter_map(|hash| match hash.algo { + 1 => Some(("sha1".to_owned(), hash.value)), + 2 => Some(("md5".to_owned(), hash.value)), + _ => None, + }) + .collect(); + VersionInfo { + id: file.id.to_string(), + project_id: file.mod_id.to_string(), + name: file.display_name.clone(), + version_number: file.display_name, + game_versions: file.game_versions, + loaders, + version_type: match file.release_type { + 1 => VersionType::Release, + 2 => VersionType::Beta, + 3 => VersionType::Alpha, + _ => VersionType::Unknown, + }, + dependencies: file + .dependencies + .into_iter() + .map(|dependency| VersionDependency { + version_id: None, + project_id: Some(dependency.mod_id.to_string()), + file_name: None, + dependency_type: match dependency.relation_type { + 2 => DependencyType::Optional, + 3 => DependencyType::Required, + 5 => DependencyType::Incompatible, + 1 | 6 => DependencyType::Embedded, + _ => DependencyType::Unknown, + }, + }) + .collect(), + date_published: file.file_date, + files: vec![VersionFile { + url: file.download_url.unwrap_or_default(), + filename: file.file_name, + size: file.file_length, + primary: true, + hashes, + }], + } +} + +fn class_id(kind: ContentKind) -> u32 { + match kind { + ContentKind::Mod => MODS_CLASS_ID, + ContentKind::ResourcePack => RESOURCE_PACKS_CLASS_ID, + ContentKind::Shader => SHADERS_CLASS_ID, + ContentKind::DataPack => DATA_PACKS_CLASS_ID, + } +} + +fn loader_type(loader: ModLoader) -> Option { + match loader { + ModLoader::Vanilla => None, + ModLoader::Forge => Some(1), + ModLoader::Fabric => Some(4), + ModLoader::Quilt => Some(5), + ModLoader::NeoForge => Some(6), + } +} + +#[cfg(test)] +#[path = "tests/curseforge.rs"] +mod tests; diff --git a/src/net/fabric.rs b/src/net/fabric.rs index 197cdca..d8211bc 100644 --- a/src/net/fabric.rs +++ b/src/net/fabric.rs @@ -5,9 +5,9 @@ use std::path::Path; use serde::{Deserialize, Serialize}; +use crate::feedback::progress::set_sub_action; use crate::instance::loader::GameVersion; use crate::net::{HttpClient, NetError, download_file}; -use crate::tui::progress::set_sub_action; const FABRIC_META_BASE: &str = "https://meta.fabricmc.net/v2"; @@ -155,7 +155,7 @@ pub async fn download_fabric_libraries( profile: &FabricProfile, meta_dir: &Path, ) -> Result<(), NetError> { - let libraries_dir = meta_dir.join("libraries"); + let libraries_dir = crate::storage::MetadataPaths::new(meta_dir).libraries(); tracing::debug!( "Resolving {} Fabric libraries into {}", profile.libraries.len(), @@ -163,7 +163,7 @@ pub async fn download_fabric_libraries( ); for lib in &profile.libraries { - let maven_path = match crate::net::maven_coord_to_path(&lib.name) { + let maven_path = match crate::instance::loader::maven::maven_coord_to_path(&lib.name) { Some(p) => p, None => { return Err(NetError::Parse(format!( @@ -193,41 +193,3 @@ pub async fn download_fabric_libraries( tracing::debug!("Fabric library resolution complete for {}", profile.id); Ok(()) } - -#[cfg(test)] -mod tests { - use super::*; - use crate::net::HttpClient; - - #[tokio::test] - #[ignore = "hits live Fabric API"] - async fn test_fetch_versions() { - let client = HttpClient::new(); - match fetch_fabric_versions(&client, "1.20.1").await { - Ok(versions) => { - assert!( - !versions.is_empty(), - "Should have Fabric versions for 1.20.1" - ); - assert!( - versions[0].loader.version.contains('.'), - "Version should be semver-like" - ); - } - Err(e) => panic!("fetch_fabric_versions failed: {}", e), - } - } - - #[tokio::test] - #[ignore = "hits live Fabric API"] - async fn test_fetch_game_versions() { - let client = HttpClient::new(); - match fetch_fabric_game_versions(&client).await { - Ok(versions) => { - assert!(!versions.is_empty(), "Should have Fabric game versions"); - assert!(versions.iter().any(|version| version.id == "1.20.1")); - } - Err(e) => panic!("fetch_fabric_game_versions failed: {}", e), - } - } -} diff --git a/src/net/forge.rs b/src/net/forge.rs index 85d884c..8edeb02 100644 --- a/src/net/forge.rs +++ b/src/net/forge.rs @@ -7,9 +7,9 @@ use std::path::Path; use serde::Deserialize; -use crate::instance::loader::{GameVersion, InstallError, InstallerError}; +use crate::feedback::progress::set_action; +use crate::instance::loader::GameVersion; use crate::net::{HttpClient, NetError, download_file}; -use crate::tui::progress::{set_action, set_sub_action}; const FORGE_PROMOTIONS_URL: &str = "https://files.minecraftforge.net/net/minecraftforge/forge/promotions_slim.json"; @@ -115,7 +115,7 @@ pub async fn download_forge_installer( let url = format!("{}/{slug}/forge-{slug}-installer.jar", FORGE_MAVEN_BASE,); tracing::debug!("Trying Forge installer slug '{}'", slug); match download_file(client, &url, dest, |downloaded, total| { - crate::tui::progress::set_progress(downloaded, total); + crate::feedback::progress::set_progress(downloaded, total); }) .await { @@ -136,326 +136,3 @@ pub async fn download_forge_installer( )) })) } - -pub async fn run_forge_installer( - installer_path: &Path, - instance_dir: &Path, - java_path: &str, -) -> Result<(), InstallerError> { - use tokio::process::Command; - - set_action("Running Forge installer..."); - - let output = match Command::new(java_path) - .arg("-jar") - .arg(installer_path) - .arg("--installClient") - .current_dir(instance_dir.join(".minecraft")) - .output() - .await - { - Ok(o) => o, - Err(e) => { - tracing::debug!( - "Failed to spawn Forge installer {} with Java {}: {}", - installer_path.display(), - java_path, - e - ); - return Err(InstallerError::Io(e)); - } - }; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - let detail = if stderr.trim().is_empty() { - format!("exit code {:?}", output.status.code()) - } else { - stderr.lines().last().unwrap_or("unknown error").to_string() - }; - tracing::debug!( - "Forge installer {} failed with status {:?}: {}", - installer_path.display(), - output.status.code(), - detail - ); - return Err(InstallerError::ProcessFailed(detail)); - } - - tracing::debug!("Forge installer completed successfully"); - Ok(()) -} - -// old forge installers have an install_profile.json with a "versionInfo" key -// containing everything needed. modern ones don't have this structure. -pub(crate) fn has_legacy_install_profile(installer_path: &Path) -> bool { - let file = match std::fs::File::open(installer_path) { - Ok(f) => f, - Err(_) => return false, - }; - let mut archive = match zip::ZipArchive::new(file) { - Ok(a) => a, - Err(_) => return false, - }; - let entry = match archive.by_name("install_profile.json") { - Ok(e) => e, - Err(_) => return false, - }; - let value: serde_json::Value = match serde_json::from_reader(entry) { - Ok(v) => v, - Err(_) => return false, - }; - value.get("versionInfo").is_some() -} - -// handles old-style forge installation by extracting the universal jar and -// library info directly from the installer, bypassing the GUI-only installer -pub(crate) async fn install_forge_from_profile( - client: &HttpClient, - installer_path: &Path, - meta_dir: &Path, - profile_filename: &str, -) -> Result<(), InstallError> { - use std::io::Read; - - set_action("Installing legacy Forge from profile..."); - tracing::debug!( - "Installing legacy Forge from {} into {}", - installer_path.display(), - meta_dir.display() - ); - - let file = std::fs::File::open(installer_path) - .map_err(|e| InstallError::Installer(InstallerError::Io(e)))?; - let mut archive = zip::ZipArchive::new(file).map_err(|e| { - InstallError::Installer(InstallerError::Profile(format!( - "Failed to open installer as ZIP: {e}" - ))) - })?; - - let profile_data: serde_json::Value = { - let entry = archive.by_name("install_profile.json").map_err(|e| { - InstallError::Installer(InstallerError::Profile(format!( - "install_profile.json not found in installer: {e}" - ))) - })?; - serde_json::from_reader(entry).map_err(|e| { - InstallError::Installer(InstallerError::Profile(format!( - "Failed to parse install_profile.json: {e}" - ))) - })? - }; - - let version_info = profile_data.get("versionInfo").ok_or_else(|| { - InstallError::Installer(InstallerError::Profile( - "install_profile.json missing versionInfo".into(), - )) - })?; - let install_info = profile_data.get("install").ok_or_else(|| { - InstallError::Installer(InstallerError::Profile( - "install_profile.json missing install section".into(), - )) - })?; - - let libraries = version_info - .get("libraries") - .and_then(|v| v.as_array()) - .ok_or_else(|| { - InstallError::Installer(InstallerError::Profile( - "missing versionInfo.libraries".into(), - )) - })?; - - let file_path = install_info - .get("filePath") - .and_then(|v| v.as_str()) - .ok_or_else(|| { - InstallError::Installer(InstallerError::Profile("missing install.filePath".into())) - })?; - - let install_path_coord = install_info - .get("path") - .and_then(|v| v.as_str()) - .ok_or_else(|| { - InstallError::Installer(InstallerError::Profile("missing install.path".into())) - })?; - - // extract the universal jar to the correct maven location - let universal_maven_path = - crate::net::maven_coord_to_path(install_path_coord).ok_or_else(|| { - InstallError::Installer(InstallerError::Profile(format!( - "Invalid maven coord in install.path: {install_path_coord}" - ))) - })?; - - set_sub_action("Extracting universal JAR..."); - let universal_dest = meta_dir.join("libraries").join(&universal_maven_path); - if let Some(parent) = universal_dest.parent() { - std::fs::create_dir_all(parent) - .map_err(|e| InstallError::Installer(InstallerError::Io(e)))?; - } - - { - let mut entry = archive.by_name(file_path).map_err(|e| { - InstallError::Installer(InstallerError::Profile(format!( - "Universal JAR '{file_path}' not found in installer: {e}" - ))) - })?; - let mut buf = Vec::new(); - entry - .read_to_end(&mut buf) - .map_err(|e| InstallError::Installer(InstallerError::Io(e)))?; - std::fs::write(&universal_dest, &buf) - .map_err(|e| InstallError::Installer(InstallerError::Io(e)))?; - tracing::debug!( - "Extracted legacy Forge universal JAR to {} ({} bytes)", - universal_dest.display(), - buf.len() - ); - } - - // download libraries needed by this forge version. libs with a url field - // are forge-hosted, libs without one are typically from mojang's library - // server. old forge versions reference libs like launchwrapper that aren't - // in mojang's modern version metadata, so we fetch those too. - let libraries_dir = meta_dir.join("libraries"); - for lib in libraries { - let name = lib.get("name").and_then(|v| v.as_str()).unwrap_or_default(); - - let maven_path = match crate::net::maven_coord_to_path(name) { - Some(p) => p, - None => { - return Err(InstallError::Installer(InstallerError::Profile(format!( - "Invalid Maven coordinate: {name}" - )))); - } - }; - - let dest = libraries_dir.join(&maven_path); - if dest.exists() { - tracing::trace!("Legacy Forge library already cached: {}", name); - continue; - } - - let base_url = lib - .get("url") - .and_then(|v| v.as_str()) - .unwrap_or("https://libraries.minecraft.net/") - .trim_end_matches('/'); - let download_url = format!("{base_url}/{maven_path}"); - - set_sub_action(name); - tracing::debug!("Downloading legacy Forge library {}", name); - download_file(client, &download_url, &dest, |_, _| {}).await?; - } - - set_action("Saving Forge profile..."); - // write the installer's versionInfo as compact JSON. it already has the - // mainClass, the full library list (with name + url for forge-hosted - // libs), and minecraftArguments (the legacy --tweakClass etc). the - // launch flow parses this as a LaunchProfile and - if there's no - // inheritsFrom field - implicitly inherits from the configured game - // version so vanilla libraries layer in via resolve(). - // - // we use serde_json::to_vec (not the pretty-print variant via - // save_profile_json) so the written file is content-faithful: every - // field present in the installer's versionInfo round-trips. key order - // and whitespace may differ from the original installer JSON because - // the source is a serde_json::Value (which doesn't preserve order), - // but no field is silently dropped. - let serialized = serde_json::to_vec(version_info).map_err(|e| { - InstallError::Installer(InstallerError::Profile(format!( - "Failed to serialize Forge profile: {e}" - ))) - })?; - crate::instance::loader::save_profile_bytes(meta_dir, profile_filename, &serialized) - .map_err(|e| InstallError::Installer(InstallerError::Io(e)))?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::net::HttpClient; - - #[tokio::test] - #[ignore = "hits live Forge API"] - async fn test_fetch_versions() { - let client = HttpClient::new(); - match fetch_forge_versions(&client, "1.20.1").await { - Ok(versions) => { - assert!( - !versions.is_empty(), - "Should have Forge versions for 1.20.1" - ); - } - Err(e) => panic!("fetch_forge_versions failed: {}", e), - } - } - - #[tokio::test] - #[ignore = "hits live Forge API"] - async fn test_fetch_game_versions() { - let client = HttpClient::new(); - match fetch_forge_game_versions(&client).await { - Ok(versions) => { - assert!(!versions.is_empty(), "Should have Forge game versions"); - assert!(versions.iter().any(|version| version.id == "1.20.1")); - } - Err(e) => panic!("fetch_forge_game_versions failed: {}", e), - } - } - - // builds an in-memory zip in a tempdir with the given json as - // install_profile.json. lets the legacy-install-profile detector be - // tested without an actual forge installer. - fn make_installer_zip(tmp: &std::path::Path, json: &serde_json::Value) -> std::path::PathBuf { - use std::io::Write; - let path = tmp.join("installer.jar"); - let file = std::fs::File::create(&path).unwrap(); - let mut zip = zip::ZipWriter::new(file); - let opts: zip::write::SimpleFileOptions = Default::default(); - zip.start_file("install_profile.json", opts).unwrap(); - zip.write_all(serde_json::to_string(json).unwrap().as_bytes()) - .unwrap(); - zip.finish().unwrap(); - path - } - - #[test] - fn has_legacy_install_profile_true_when_version_info_present() { - let tmp = tempfile::tempdir().unwrap(); - let jar = make_installer_zip( - tmp.path(), - &serde_json::json!({ - "install": {}, - "versionInfo": { - "id": "1.7.10-Forge10.13.4.1614-1.7.10", - "mainClass": "net.minecraft.launchwrapper.Launch" - } - }), - ); - assert!(has_legacy_install_profile(&jar)); - } - - #[test] - fn has_legacy_install_profile_false_when_version_info_absent() { - let tmp = tempfile::tempdir().unwrap(); - let jar = make_installer_zip( - tmp.path(), - &serde_json::json!({ - "spec": 1, - "minecraft": "1.20.1", - "data": {} - }), - ); - assert!(!has_legacy_install_profile(&jar)); - } - - #[test] - fn has_legacy_install_profile_false_for_missing_jar() { - assert!(!has_legacy_install_profile(std::path::Path::new( - "/nonexistent/installer.jar" - ))); - } -} diff --git a/src/net/mod.rs b/src/net/mod.rs index 1a27b4b..56a8b7c 100644 --- a/src/net/mod.rs +++ b/src/net/mod.rs @@ -1,6 +1,7 @@ // networking layer: http client, file downloads, and shared utilities // for fetching game assets from mojang, mod loaders, and modrinth. +pub mod curseforge; pub mod fabric; pub mod forge; pub mod modrinth; @@ -9,10 +10,13 @@ pub mod neoforge; pub mod quilt; use reqwest::Client; +use serde::Serialize; use serde::de::DeserializeOwned; use std::path::Path; use thiserror::Error; +pub const MAX_PROVIDER_ASSET_BYTES: usize = 16 * 1024 * 1024; + #[derive(Debug, Error)] pub enum NetError { #[error("HTTP request failed: {0}")] @@ -38,6 +42,18 @@ impl Default for HttpClient { } } +impl From for HttpClient { + fn from(inner: Client) -> Self { + Self { inner } + } +} + +impl NetError { + pub(crate) fn is_retryable(&self) -> bool { + matches!(self, Self::StatusError { status: 429, .. }) || is_retryable(self) + } +} + impl HttpClient { pub fn new() -> Self { let user_agent = format!("rmcl/{} (Minecraft Launcher)", env!("CARGO_PKG_VERSION")); @@ -91,6 +107,59 @@ impl HttpClient { .await } + pub async fn get_bytes_limited(&self, url: &str, limit: usize) -> Result, NetError> { + get_with_retry(self, url, move |mut response| async move { + if response + .content_length() + .is_some_and(|length| length > limit as u64) + { + return Err(NetError::Parse(format!( + "Response exceeds the {limit}-byte limit" + ))); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await? { + if bytes.len().saturating_add(chunk.len()) > limit { + return Err(NetError::Parse(format!( + "Response exceeds the {limit}-byte limit" + ))); + } + bytes.extend_from_slice(&chunk); + } + Ok(bytes) + }) + .await + } + + pub async fn post_json(&self, url: &str, body: &B) -> Result + where + B: Serialize + ?Sized, + T: DeserializeOwned, + { + for attempt in 0..=MAX_RETRIES { + tracing::trace!("HTTP POST {}", url); + let result = async { + let response = self.inner.post(url).json(body).send().await?; + if !response.status().is_success() { + return Err(NetError::StatusError { + status: response.status().as_u16(), + url: url.to_owned(), + }); + } + Ok(response.json().await?) + } + .await; + match result { + Ok(value) => return Ok(value), + Err(error) if is_retryable(&error) && attempt < MAX_RETRIES => { + sleep_before_retry("request", url, attempt, &error).await; + } + Err(error) => return Err(error), + } + } + unreachable!("retry loop returns on success or final error") + } + // fetch JSON and also keep the raw bytes. used by install paths that // want both the parsed shape (for downloading libraries from it) and // the original bytes (to write byte-for-byte to the loader-profiles @@ -146,7 +215,7 @@ const RETRY_BASE_DELAY_MS: u64 = 500; async fn sleep_before_retry(kind: &str, url: &str, attempt: u32, err: &NetError) { let delay = RETRY_BASE_DELAY_MS * 2u64.pow(attempt); - tracing::warn!( + tracing::debug!( "{} failed, retrying after {}ms (attempt {}/{}): {}: {}", kind, delay, @@ -169,23 +238,28 @@ pub async fn download_file( ) -> Result<(), NetError> { tracing::debug!("Downloading {} to {}", url, dest.display()); - for attempt in 0..=MAX_RETRIES { - match download_file_once(client, url, dest, &progress_cb).await { - Ok(()) => { - tracing::debug!("Downloaded {} to {}", url, dest.display()); - return Ok(()); - } - Err(e) if is_retryable(&e) => { - if attempt == MAX_RETRIES { - return Err(e); + let result = 'download: { + for attempt in 0..=MAX_RETRIES { + match download_file_once(client, url, dest, &progress_cb).await { + Ok(()) => { + tracing::debug!("Downloaded {} to {}", url, dest.display()); + break 'download Ok(()); } - sleep_before_retry("download", url, attempt, &e).await; + Err(e) if is_retryable(&e) => { + if attempt == MAX_RETRIES { + break 'download Err(e); + } + sleep_before_retry("download", url, attempt, &e).await; + } + Err(e) => break 'download Err(e), } - Err(e) => return Err(e), } + unreachable!("retry loop returns on success or final error") + }; + if result.is_err() { + let _ = tokio::fs::remove_file(dest).await; } - - unreachable!("retry loop returns on success or final error") + result } // single attempt at downloading a file to disk @@ -230,108 +304,3 @@ fn is_retryable(err: &NetError) -> bool { _ => false, } } - -// tries JAVA_HOME first, then PATH, then just yolos "java" and hopes for the best -#[must_use] -pub fn detect_java_path() -> String { - if let Ok(java_home) = std::env::var("JAVA_HOME") { - let java_name = if cfg!(windows) { "java.exe" } else { "java" }; - let bin = std::path::Path::new(&java_home).join("bin").join(java_name); - if bin.exists() { - tracing::trace!("Detected Java from JAVA_HOME: {}", bin.display()); - return bin.to_string_lossy().to_string(); - } - tracing::warn!( - "JAVA_HOME is set to {}, but {} does not exist", - java_home, - bin.display() - ); - } - match which::which("java") { - Ok(path) => { - tracing::trace!("Detected Java from PATH: {}", path.display()); - path.to_string_lossy().to_string() - } - Err(e) => { - tracing::warn!( - "Could not find java on PATH, falling back to literal 'java': {}", - e - ); - "java".to_string() - } - } -} - -// converts maven coordinates like "org.example:artifact:1.0" into a -// filesystem path like "org/example/artifact/1.0/artifact-1.0.jar". -// supports optional classifier as a 4th component. -#[must_use] -pub fn maven_coord_to_path(coord: &str) -> Option { - let parts: Vec<&str> = coord.split(':').collect(); - match parts.as_slice() { - [group, artifact, version] => { - let group_path = group.replace('.', "/"); - Some(format!( - "{}/{}/{}/{}-{}.jar", - group_path, artifact, version, artifact, version - )) - } - [group, artifact, version, classifier] => { - let group_path = group.replace('.', "/"); - Some(format!( - "{}/{}/{}/{}-{}-{}.jar", - group_path, artifact, version, artifact, version, classifier - )) - } - _ => None, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn maven_3_part_coord() { - assert_eq!( - maven_coord_to_path("org.example:artifact:1.0"), - Some("org/example/artifact/1.0/artifact-1.0.jar".to_string()) - ); - } - - #[test] - fn maven_4_part_coord_with_classifier() { - assert_eq!( - maven_coord_to_path("org.example:artifact:1.0:sources"), - Some("org/example/artifact/1.0/artifact-1.0-sources.jar".to_string()) - ); - } - - #[test] - fn maven_nested_group() { - assert_eq!( - maven_coord_to_path("com.google.code.gson:gson:2.10"), - Some("com/google/code/gson/gson/2.10/gson-2.10.jar".to_string()) - ); - } - - #[test] - fn maven_invalid_too_few_parts() { - assert_eq!(maven_coord_to_path("org.example:artifact"), None); - } - - #[test] - fn maven_invalid_too_many_parts() { - assert_eq!(maven_coord_to_path("a:b:c:d:e"), None); - } - - #[test] - fn maven_invalid_single_part() { - assert_eq!(maven_coord_to_path("just-a-string"), None); - } - - #[test] - fn maven_empty_string() { - assert_eq!(maven_coord_to_path(""), None); - } -} diff --git a/src/net/modrinth.rs b/src/net/modrinth.rs index 555789d..8995169 100644 --- a/src/net/modrinth.rs +++ b/src/net/modrinth.rs @@ -1,93 +1,273 @@ -// modrinth modpack support: fetches project metadata, downloads .mrpack files, -// and extracts loader info from pack manifests. +// modrinth api client and provider file downloads. use serde::Deserialize; use std::collections::HashMap; -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, serde::Serialize)] pub struct ProjectInfo { pub id: String, pub slug: String, pub title: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub body: String, + #[serde(default)] + pub icon_url: Option, + #[serde(default)] + pub categories: Vec, + #[serde(default)] + pub additional_categories: Vec, + #[serde(default)] + pub project_type: String, + #[serde(default)] + pub loaders: Vec, +} + +impl ProjectInfo { + pub fn is_library_only(&self) -> bool { + let mut categories = self + .categories + .iter() + .chain(self.additional_categories.iter()) + .peekable(); + categories.peek().is_some() + && categories.all(|category| { + matches!( + category + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + .as_str(), + "library" | "libraries" | "apiandlibrary" | "libraryapi" + ) + }) + } } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, serde::Serialize)] pub struct VersionInfo { pub id: String, + #[serde(default)] + pub project_id: String, pub name: String, pub version_number: String, pub game_versions: Vec, pub loaders: Vec, + #[serde(default)] + pub version_type: VersionType, + #[serde(default)] + pub dependencies: Vec, + #[serde(default)] + pub date_published: String, pub files: Vec, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, serde::Serialize)] +#[serde(rename_all = "lowercase")] +pub enum VersionType { + #[default] + Release, + Beta, + Alpha, + #[serde(other)] + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, serde::Serialize)] +pub struct VersionDependency { + #[serde(default)] + pub version_id: Option, + #[serde(default)] + pub project_id: Option, + #[serde(default)] + pub file_name: Option, + pub dependency_type: DependencyType, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, serde::Serialize)] +#[serde(rename_all = "lowercase")] +pub enum DependencyType { + #[default] + Required, + Optional, + Incompatible, + Embedded, + #[serde(other)] + Unknown, +} + +#[derive(Debug, Clone, Deserialize, serde::Serialize)] pub struct VersionFile { pub url: String, pub filename: String, pub size: u64, pub primary: bool, + #[serde(default)] + pub hashes: HashMap, } -#[derive(Debug, Clone, Deserialize)] -pub struct MrpackIndex { - #[serde(rename = "formatVersion")] - pub format_version: u32, - pub game: String, - #[serde(rename = "versionId")] - pub version_id: String, - pub name: String, +use crate::instance::{ContentKind, ModLoader}; + +pub type DiscoveryKind = ContentKind; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DiscoveryProject { + pub id: String, + pub slug: String, + pub title: String, + pub description: String, + pub downloads: u64, + pub icon_url: Option, + pub icon_bytes: Option>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DiscoveryResults { + pub projects: Vec, + pub total_hits: usize, +} + +#[derive(Debug, Deserialize)] +struct DiscoverySearchResponse { + hits: Vec, + #[serde(default)] + total_hits: i64, +} + +#[derive(Debug, Deserialize)] +struct DiscoverySearchHit { + project_id: String, + slug: String, + title: String, + description: String, #[serde(default)] - pub dependencies: HashMap, + downloads: i64, #[serde(default)] - pub files: Vec, + icon_url: Option, } -#[derive(Debug, Clone, Deserialize)] -pub struct MrpackFile { - pub path: String, - pub downloads: Vec, - #[serde(rename = "fileSize")] - pub file_size: u64, +impl From for DiscoveryProject { + fn from(project: DiscoverySearchHit) -> Self { + Self { + id: project.project_id, + slug: project.slug, + title: project.title, + description: project.description, + downloads: project.downloads.max(0) as u64, + icon_url: project.icon_url.filter(|url| !url.trim().is_empty()), + icon_bytes: None, + } + } } -use crate::instance::models::ModLoader; +pub async fn search_discovery( + client: &crate::net::HttpClient, + kind: ContentKind, + query: &str, + game_version: &str, + loader: ModLoader, + offset: usize, + limit: usize, +) -> Result { + let facets = discovery_facets(kind, game_version, loader); + let query = query.trim(); + let index = if query.is_empty() { + "downloads" + } else { + "relevance" + }; + let url = format!( + "{API_BASE}/search?query={}&facets={}&index={index}&offset={offset}&limit={limit}", + url_encode(query), + url_encode(&facets), + ); + let results: DiscoverySearchResponse = client.get_json(&url).await?; -// mrpack dependencies use keys like "fabric-loader", "forge", etc. -// checks in priority order and returns the first match. -pub fn loader_from_dependencies( - deps: &HashMap, -) -> (Option, Option) { - let loaders = [ - ("fabric-loader", ModLoader::Fabric), - ("forge", ModLoader::Forge), - ("neoforge", ModLoader::NeoForge), - ("quilt-loader", ModLoader::Quilt), - ]; - for (key, loader) in &loaders { - if let Some(version) = deps.get(*key) { - tracing::trace!( - "Resolved Modrinth loader dependency {}={} as {}", - key, - version, - loader - ); - return (Some(*loader), Some(version.clone())); - } + Ok(DiscoveryResults { + total_hits: results.total_hits.max(0) as usize, + projects: results + .hits + .into_iter() + .map(DiscoveryProject::from) + .collect(), + }) +} + +pub async fn search_modpacks( + client: &crate::net::HttpClient, + query: &str, + offset: usize, + limit: usize, +) -> Result { + let query = query.trim(); + let index = if query.is_empty() { + "downloads" + } else { + "relevance" + }; + let facets = r#"[["project_type:modpack"]]"#; + let url = format!( + "{API_BASE}/search?query={}&facets={}&index={index}&offset={offset}&limit={limit}", + url_encode(query), + url_encode(facets), + ); + let results: DiscoverySearchResponse = client.get_json(&url).await?; + Ok(DiscoveryResults { + total_hits: results.total_hits.max(0) as usize, + projects: results + .hits + .into_iter() + .map(DiscoveryProject::from) + .collect(), + }) +} + +fn discovery_facets(kind: ContentKind, game_version: &str, loader: ModLoader) -> String { + let mut facets = vec![vec![project_type_facet(kind)]]; + if !game_version.is_empty() { + facets.push(vec![format!("versions:{game_version}")]); + } + if kind == ContentKind::Mod + && let Some(loader) = loader_facet(loader) + { + facets.push(vec![format!("categories:{loader}")]); + } + serde_json::to_string(&facets).unwrap_or_else(|_| "[]".to_string()) +} + +fn project_type_facet(kind: ContentKind) -> String { + match kind { + ContentKind::DataPack => "all_project_types:datapack".to_owned(), + _ => format!("project_type:{}", project_type(kind)), } - tracing::trace!("No Modrinth loader dependency found; treating pack as vanilla"); - (None, None) } -pub fn game_version_from_dependencies(deps: &HashMap) -> Option { - deps.get("minecraft").cloned() +fn project_type(kind: ContentKind) -> &'static str { + match kind { + ContentKind::Mod => "mod", + ContentKind::ResourcePack => "resourcepack", + ContentKind::Shader => "shader", + ContentKind::DataPack => "datapack", + } +} + +fn loader_facet(loader: ModLoader) -> Option<&'static str> { + match loader { + ModLoader::Vanilla => None, + ModLoader::Fabric => Some("fabric"), + ModLoader::Forge => Some("forge"), + ModLoader::NeoForge => Some("neoforge"), + ModLoader::Quilt => Some("quilt"), + } } const API_BASE: &str = "https://api.modrinth.com/v2"; // hand-rolled percent encoding because pulling in a crate for RFC 3986 // unreserved chars felt like overkill -fn url_encode(s: &str) -> String { +pub(crate) fn url_encode(s: &str) -> String { use std::fmt::Write; let mut encoded = String::with_capacity(s.len()); for byte in s.bytes() { @@ -122,11 +302,7 @@ pub async fn fetch_versions( client: &crate::net::HttpClient, slug_or_id: &str, ) -> Result, crate::net::NetError> { - let url = format!( - "{}/project/{}/version?loaders=[\"fabric\",\"forge\",\"neoforge\",\"quilt\"]", - API_BASE, - url_encode(slug_or_id) - ); + let url = versions_url(API_BASE, slug_or_id); tracing::debug!("Fetching Modrinth versions for project '{}'", slug_or_id); let versions: Vec = client.get_json(&url).await?; tracing::debug!( @@ -137,6 +313,209 @@ pub async fn fetch_versions( Ok(versions) } +fn versions_url(api_base: &str, slug_or_id: &str) -> String { + format!("{api_base}/project/{}/version", url_encode(slug_or_id)) +} + +pub async fn fetch_content_versions( + client: &crate::net::HttpClient, + project_id: &str, + kind: ContentKind, + game_version: &str, + loader: ModLoader, +) -> Result, crate::net::NetError> { + let url = content_versions_url(API_BASE, project_id, kind, game_version, loader); + tracing::debug!( + "Fetching compatible Modrinth versions for '{}' ({}, {})", + project_id, + game_version, + loader + ); + client.get_json(&url).await +} + +fn content_versions_url( + api_base: &str, + project_id: &str, + kind: ContentKind, + game_version: &str, + loader: ModLoader, +) -> String { + let mut params = vec![ + "include_changelog=false".to_owned(), + format!( + "game_versions={}", + url_encode(&serde_json::to_string(&[game_version]).unwrap_or_default()) + ), + ]; + if kind == ContentKind::Mod + && let Some(loader) = loader_facet(loader) + { + params.push(format!( + "loaders={}", + url_encode(&serde_json::to_string(&[loader]).unwrap_or_default()) + )); + } else if kind == ContentKind::DataPack { + params.push(format!( + "loaders={}", + url_encode(&serde_json::to_string(&["datapack"]).unwrap_or_default()) + )); + } + format!( + "{api_base}/project/{}/version?{}", + url_encode(project_id), + params.join("&") + ) +} + +pub fn select_primary_file(version: &VersionInfo) -> Result<&VersionFile, crate::net::NetError> { + version + .files + .iter() + .find(|file| file.primary) + .or_else(|| version.files.first()) + .ok_or_else(|| crate::net::NetError::Parse("No files in version".to_owned())) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DownloadOutcome { + Downloaded(std::path::PathBuf), + SkippedExisting(std::path::PathBuf), +} + +pub async fn download_version_file( + client: &crate::net::HttpClient, + version: &VersionInfo, + destination: &std::path::Path, +) -> Result { + let file = select_primary_file(version)?; + validate_path_component(&file.filename, "provider filename")?; + validate_path_component(&version.id, "provider version id")?; + let path = destination.join(&file.filename); + if path.exists() { + if verify_version_file(&path, file)? { + return Ok(DownloadOutcome::SkippedExisting(path)); + } + return Err(crate::net::NetError::Parse(format!( + "Existing file '{}' does not match the selected provider version", + path.display() + ))); + } + let temporary = destination.join(format!(".{}.{}.rmcl-download", file.filename, version.id)); + if temporary.exists() { + tokio::fs::remove_file(&temporary).await?; + } + let progress = + crate::feedback::progress::ProgressTask::start(format!("Downloading {}", file.filename)); + crate::net::download_file(client, &file.url, &temporary, |current, total| { + progress.set_progress(current, total); + }) + .await?; + if !verify_version_file(&temporary, file)? { + let _ = tokio::fs::remove_file(&temporary).await; + return Err(crate::net::NetError::Parse(format!( + "Downloaded file '{}' failed its size or hash verification", + file.filename + ))); + } + tokio::fs::rename(&temporary, &path).await?; + progress.finish(); + Ok(DownloadOutcome::Downloaded(path)) +} + +pub async fn download_version_file_for_update( + client: &crate::net::HttpClient, + version: &VersionInfo, + destination: &std::path::Path, + installed_path: &std::path::Path, +) -> Result { + let file = select_primary_file(version)?; + validate_path_component(&file.filename, "provider filename")?; + validate_path_component(&version.id, "provider version id")?; + let target = destination.join(&file.filename); + if target != installed_path { + return download_version_file(client, version, destination).await; + } + + let temporary = destination.join(format!(".{}.{}.rmcl-download", file.filename, version.id)); + let backup = destination.join(format!(".{}.{}.rmcl-backup", file.filename, version.id)); + if backup.exists() { + if !installed_path.exists() { + tokio::fs::rename(&backup, installed_path).await?; + } else { + tokio::fs::remove_file(&backup).await?; + } + } + if temporary.exists() { + tokio::fs::remove_file(&temporary).await?; + } + + let progress = + crate::feedback::progress::ProgressTask::start(format!("Downloading {}", file.filename)); + crate::net::download_file(client, &file.url, &temporary, |current, total| { + progress.set_progress(current, total); + }) + .await?; + if !verify_version_file(&temporary, file)? { + let _ = tokio::fs::remove_file(&temporary).await; + return Err(crate::net::NetError::Parse(format!( + "Downloaded file '{}' failed its size or hash verification", + file.filename + ))); + } + replace_installed_file(&temporary, &target, installed_path, &backup).await?; + progress.finish(); + Ok(DownloadOutcome::Downloaded(target)) +} + +fn validate_path_component(value: &str, label: &str) -> Result<(), crate::net::NetError> { + let mut components = std::path::Path::new(value).components(); + if !matches!(components.next(), Some(std::path::Component::Normal(_))) + || components.next().is_some() + { + return Err(crate::net::NetError::Parse(format!( + "Invalid {label} '{value}'" + ))); + } + Ok(()) +} + +fn verify_version_file( + path: &std::path::Path, + expected: &VersionFile, +) -> Result { + let fingerprint = crate::instance::content::manifest::fingerprint(path)?; + if expected.size > 0 && fingerprint.size != expected.size { + return Ok(false); + } + for algorithm in ["sha512", "sha1"] { + if let Some(expected_hash) = expected.hashes.get(algorithm) + && fingerprint.hash(algorithm) != Some(expected_hash.as_str()) + { + return Ok(false); + } + } + Ok(true) +} + +async fn replace_installed_file( + temporary: &std::path::Path, + target: &std::path::Path, + installed_path: &std::path::Path, + backup: &std::path::Path, +) -> Result<(), crate::net::NetError> { + tokio::fs::rename(installed_path, &backup).await?; + if let Err(error) = tokio::fs::rename(&temporary, &target).await { + let _ = tokio::fs::rename(&backup, installed_path).await; + let _ = tokio::fs::remove_file(&temporary).await; + return Err(error.into()); + } + if let Err(error) = tokio::fs::remove_file(&backup).await { + tracing::warn!("Failed to remove provider update backup: {error}"); + } + Ok(()) +} + pub async fn fetch_version( client: &crate::net::HttpClient, version_id: &str, @@ -153,6 +532,28 @@ pub async fn fetch_version( Ok(version) } +#[derive(Debug, serde::Serialize)] +struct VersionFilesRequest<'a> { + hashes: &'a [String], + algorithm: &'a str, +} + +pub async fn resolve_version_files( + client: &crate::net::HttpClient, + hashes: &[String], + algorithm: &str, +) -> Result, crate::net::NetError> { + if hashes.is_empty() { + return Ok(HashMap::new()); + } + client + .post_json( + &format!("{API_BASE}/version_files"), + &VersionFilesRequest { hashes, algorithm }, + ) + .await +} + // grabs the primary file from a version, falling back to the first file // if none is marked primary (some projects are sloppy about that) pub async fn download_mrpack( @@ -160,18 +561,7 @@ pub async fn download_mrpack( version: &VersionInfo, dest: &std::path::Path, ) -> Result { - let file = version - .files - .iter() - .find(|f| f.primary) - .or_else(|| { - tracing::warn!( - "Modrinth version '{}' has no primary file; using first file", - version.id - ); - version.files.first() - }) - .ok_or_else(|| crate::net::NetError::Parse("No files in version".to_string()))?; + let file = select_primary_file(version)?; let mrpack_path = dest.join(&file.filename); tracing::info!( @@ -184,138 +574,6 @@ pub async fn download_mrpack( Ok(mrpack_path) } -// .mrpack is just a zip with modrinth.index.json at the root -pub fn parse_mrpack(path: &std::path::Path) -> Result { - tracing::debug!("Parsing .mrpack manifest from {}", path.display()); - let file = std::fs::File::open(path).map_err(|e| format!("Cannot open .mrpack: {e}"))?; - let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("Invalid ZIP: {e}"))?; - let entry = archive - .by_name("modrinth.index.json") - .map_err(|_| "Missing modrinth.index.json in .mrpack".to_string())?; - let index: MrpackIndex = - serde_json::from_reader(entry).map_err(|e| format!("Invalid manifest JSON: {e}"))?; - tracing::debug!( - "Parsed .mrpack '{}' version_id={} files={} deps={}", - index.name, - index.version_id, - index.files.len(), - index.dependencies.len() - ); - Ok(index) -} - #[cfg(test)] -mod tests { - use super::*; - - // covers each branch of url_encode: unreserved bytes pass through; the - // reserved set + spaces + non-ascii bytes get percent-encoded. emoji - // exercises multi-byte UTF-8 since the encoder operates on bytes, not - // chars, so each byte of the codepoint encodes separately. - #[rstest::rstest] - #[case::ascii_unreserved("abcXYZ0-9_.~", "abcXYZ0-9_.~")] - #[case::space("hello world", "hello%20world")] - #[case::reserved("/?&=#", "%2F%3F%26%3D%23")] - #[case::utf8_emoji("\u{2603}", "%E2%98%83")] - #[case::empty("", "")] - fn url_encode_handles(#[case] input: &str, #[case] expected: &str) { - assert_eq!(url_encode(input), expected); - } - - #[test] - fn loader_from_fabric_deps() { - let mut deps = HashMap::new(); - deps.insert("minecraft".to_string(), "1.21.4".to_string()); - deps.insert("fabric-loader".to_string(), "0.16.10".to_string()); - let (loader, version) = loader_from_dependencies(&deps); - assert_eq!(loader, Some(ModLoader::Fabric)); - assert_eq!(version, Some("0.16.10".to_string())); - } - - #[test] - fn loader_from_forge_deps() { - let mut deps = HashMap::new(); - deps.insert("minecraft".to_string(), "1.20.1".to_string()); - deps.insert("forge".to_string(), "47.2.0".to_string()); - let (loader, version) = loader_from_dependencies(&deps); - assert_eq!(loader, Some(ModLoader::Forge)); - assert_eq!(version, Some("47.2.0".to_string())); - } - - #[test] - fn loader_from_vanilla_deps() { - let mut deps = HashMap::new(); - deps.insert("minecraft".to_string(), "1.21.4".to_string()); - let (loader, version) = loader_from_dependencies(&deps); - assert!(loader.is_none()); - assert!(version.is_none()); - } - - #[test] - fn game_version_from_deps() { - let mut deps = HashMap::new(); - deps.insert("minecraft".to_string(), "1.21.4".to_string()); - assert_eq!( - game_version_from_dependencies(&deps), - Some("1.21.4".to_string()) - ); - } - - #[test] - fn parse_mrpack_index_json() { - let json = r#"{ - "formatVersion": 1, - "game": "minecraft", - "versionId": "6.5.0", - "name": "Fabulously Optimized", - "dependencies": { - "minecraft": "1.21.4", - "fabric-loader": "0.16.10" - }, - "files": [ - { - "path": "mods/fabric-api.jar", - "downloads": ["https://cdn.modrinth.com/data/abc/fabric-api.jar"], - "fileSize": 12345 - } - ] - }"#; - let index: MrpackIndex = serde_json::from_str(json).unwrap(); - assert_eq!(index.name, "Fabulously Optimized"); - assert_eq!(index.version_id, "6.5.0"); - assert_eq!(index.files.len(), 1); - assert_eq!(index.files[0].path, "mods/fabric-api.jar"); - assert_eq!( - game_version_from_dependencies(&index.dependencies), - Some("1.21.4".to_string()) - ); - } - - #[tokio::test] - #[ignore = "hits live Modrinth API"] - async fn test_fetch_project() { - let client = crate::net::HttpClient::new(); - let project = fetch_project(&client, "fabulously-optimized").await; - match project { - Ok(p) => { - assert_eq!(p.slug, "fabulously-optimized"); - assert!(!p.title.is_empty()); - } - Err(e) => panic!("fetch_project failed: {e}"), - } - } - - #[tokio::test] - #[ignore = "hits live Modrinth API"] - async fn test_fetch_versions() { - let client = crate::net::HttpClient::new(); - let versions = fetch_versions(&client, "fabulously-optimized").await; - match versions { - Ok(v) => { - assert!(!v.is_empty()); - assert!(!v[0].files.is_empty()); - } - Err(e) => panic!("fetch_versions failed: {e}"), - } - } -} +#[path = "tests/modrinth.rs"] +mod tests; diff --git a/src/net/mojang.rs b/src/net/mojang.rs index 647cdf6..c8a82b7 100644 --- a/src/net/mojang.rs +++ b/src/net/mojang.rs @@ -15,7 +15,7 @@ use serde::{Deserialize, Serialize}; use tokio::task::JoinSet; use super::{HttpClient, NetError, download_file}; -use crate::tui::progress::{clear, set_action, set_progress, set_sub_action}; +use crate::feedback::progress::{clear, set_action, set_progress, set_sub_action}; const MANIFEST_URL: &str = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"; const ASSETS_BASE_URL: &str = "https://resources.download.minecraft.net"; @@ -152,8 +152,8 @@ pub async fn download_client_jar( meta: &VersionMeta, meta_dir: &Path, ) -> Result<(), NetError> { - let jar_path = meta_dir - .join("versions") + let jar_path = crate::storage::MetadataPaths::new(meta_dir) + .versions() .join(&meta.id) .join(format!("{}.jar", meta.id)); @@ -225,7 +225,9 @@ pub async fn download_libraries( } }; - let destination = meta_dir.join("libraries").join(&artifact.path); + let destination = crate::storage::MetadataPaths::new(meta_dir) + .libraries() + .join(&artifact.path); if destination.exists() { tracing::trace!("Library already cached: {}", artifact.path); @@ -264,26 +266,28 @@ pub async fn download_assets_from( assets_base: &str, ) -> Result<(), NetError> { set_action("Downloading assets..."); - tracing::debug!( - "Fetching asset index {} from {}", - meta.asset_index.id, - meta.asset_index.url - ); - - let asset_index: AssetIndexContent = match client.get_json(&meta.asset_index.url).await { - Ok(index) => index, - Err(e) => { - clear(); - return Err(e); - } - }; - - let index_path = meta_dir - .join("assets") + let index_path = crate::storage::MetadataPaths::new(meta_dir) + .assets() .join("indexes") .join(format!("{}.json", meta.asset_index.id)); - if !index_path.exists() { - match serde_json::to_string(&asset_index) { + let asset_index: AssetIndexContent = if index_path.exists() { + let bytes = tokio::fs::read(&index_path).await?; + serde_json::from_slice(&bytes) + .map_err(|error| NetError::Parse(format!("Invalid cached asset index: {error}")))? + } else { + tracing::debug!( + "Fetching asset index {} from {}", + meta.asset_index.id, + meta.asset_index.url + ); + let index = match client.get_json(&meta.asset_index.url).await { + Ok(index) => index, + Err(e) => { + clear(); + return Err(e); + } + }; + match serde_json::to_string(&index) { Ok(json) => { if let Some(parent) = index_path.parent() { match tokio::fs::create_dir_all(parent).await { @@ -310,7 +314,8 @@ pub async fn download_assets_from( tracing::debug!("Failed to serialize asset index: {}", e); } } - } + index + }; // assets are stored by hash with the first 2 chars as a directory prefix, // e.g. "ab/ab1234..." - same layout mojang uses on their CDN @@ -326,8 +331,8 @@ pub async fn download_assets_from( let prefix = &object.hash[..2]; let url = format!("{}/{}/{}", assets_base, prefix, object.hash); - let destination = meta_dir - .join("assets") + let destination = crate::storage::MetadataPaths::new(meta_dir) + .assets() .join("objects") .join(prefix) .join(&object.hash); @@ -442,22 +447,3 @@ fn spawn_download_task( }) }); } - -#[cfg(test)] -mod tests { - use super::*; - use crate::net::HttpClient; - - #[tokio::test] - #[ignore = "hits live Mojang API"] - async fn test_fetch_manifest_contains_1_20_1() { - let client = HttpClient::new(); - match fetch_version_manifest(&client).await { - Ok(manifest) => { - let found = manifest.versions.iter().any(|v| v.id == "1.20.1"); - assert!(found, "1.20.1 should be in the manifest"); - } - Err(e) => panic!("fetch_version_manifest failed: {}", e), - } - } -} diff --git a/src/net/neoforge.rs b/src/net/neoforge.rs index 26bc78b..334bda7 100644 --- a/src/net/neoforge.rs +++ b/src/net/neoforge.rs @@ -7,10 +7,9 @@ use std::path::Path; use serde::Deserialize; +use crate::feedback::progress::set_action; use crate::instance::loader::GameVersion; -use crate::instance::loader::InstallerError; use crate::net::{HttpClient, NetError, download_file}; -use crate::tui::progress::set_action; const NEOFORGE_MAVEN_BASE: &str = "https://maven.neoforged.net/releases/net/neoforged/neoforge"; const NEOFORGE_API_BASE: &str = @@ -163,111 +162,11 @@ pub async fn download_neoforge_installer( ); download_file(client, &url, dest, |downloaded, total| { - crate::tui::progress::set_progress(downloaded, total); + crate::feedback::progress::set_progress(downloaded, total); }) .await } -pub async fn run_neoforge_installer( - installer_path: &Path, - instance_dir: &Path, - java_path: &str, -) -> Result<(), InstallerError> { - use tokio::process::Command; - - set_action("Running NeoForge installer..."); - - let output = match Command::new(java_path) - .arg(format!("-Duser.home={}", instance_dir.display())) - .arg("-jar") - .arg(installer_path) - .arg("--installClient") - .current_dir(instance_dir.join(".minecraft")) - .output() - .await - { - Ok(o) => o, - Err(e) => { - tracing::debug!( - "Failed to spawn NeoForge installer {} with Java {}: {}", - installer_path.display(), - java_path, - e - ); - return Err(InstallerError::Io(e)); - } - }; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - let detail = stderr.lines().last().unwrap_or("").trim(); - tracing::debug!( - "NeoForge installer {} failed with status {:?}: {}", - installer_path.display(), - output.status.code(), - detail - ); - return Err(InstallerError::ProcessFailed(format!( - "NeoForge installer exited with {:?}", - output.status.code() - ))); - } - - tracing::debug!("NeoForge installer completed successfully"); - Ok(()) -} - #[cfg(test)] -mod tests { - use super::*; - use crate::net::HttpClient; - - #[tokio::test] - #[ignore = "hits live NeoForge API"] - async fn test_fetch_versions() { - let client = HttpClient::new(); - match fetch_neoforge_versions(&client, "1.21").await { - Ok(versions) => { - assert!( - !versions.is_empty(), - "Should have NeoForge versions for 1.21" - ); - } - Err(e) => panic!("fetch_neoforge_versions failed: {}", e), - } - } - - #[tokio::test] - #[ignore = "hits live NeoForge API"] - async fn test_fetch_game_versions() { - let client = HttpClient::new(); - match fetch_neoforge_game_versions(&client).await { - Ok(versions) => { - assert!(!versions.is_empty(), "Should have NeoForge game versions"); - assert!(versions.iter().any(|version| version.id == "1.21")); - } - Err(e) => panic!("fetch_neoforge_game_versions failed: {}", e), - } - } - - #[test] - fn test_game_version_to_neoforge_prefix() { - assert_eq!( - game_version_to_neoforge_prefix("1.21"), - Some("21.0.".to_string()) - ); - assert_eq!( - game_version_to_neoforge_prefix("1.20.4"), - Some("20.4.".to_string()) - ); - assert_eq!( - game_version_to_neoforge_prefix("1.21.1"), - Some("21.1.".to_string()) - ); - assert_eq!( - game_version_to_neoforge_prefix("26.1.2"), - Some("26.1.2.".to_string()) - ); - assert_eq!(game_version_to_neoforge_prefix("invalid"), None); - } -} +#[path = "tests/neoforge.rs"] +mod tests; diff --git a/src/net/quilt.rs b/src/net/quilt.rs index dc0f2c6..a1eac82 100644 --- a/src/net/quilt.rs +++ b/src/net/quilt.rs @@ -5,9 +5,9 @@ use std::path::Path; use serde::{Deserialize, Serialize}; +use crate::feedback::progress::set_sub_action; use crate::instance::loader::GameVersion; use crate::net::{HttpClient, NetError, download_file}; -use crate::tui::progress::set_sub_action; const QUILT_META_BASE: &str = "https://meta.quiltmc.org/v3"; @@ -149,7 +149,7 @@ pub async fn download_quilt_libraries( profile: &QuiltProfile, meta_dir: &Path, ) -> Result<(), NetError> { - let libraries_dir = meta_dir.join("libraries"); + let libraries_dir = crate::storage::MetadataPaths::new(meta_dir).libraries(); tracing::debug!( "Resolving {} Quilt libraries into {}", profile.libraries.len(), @@ -157,7 +157,7 @@ pub async fn download_quilt_libraries( ); for lib in &profile.libraries { - let maven_path = match crate::net::maven_coord_to_path(&lib.name) { + let maven_path = match crate::instance::loader::maven::maven_coord_to_path(&lib.name) { Some(p) => p, None => { return Err(NetError::Parse(format!( @@ -187,37 +187,3 @@ pub async fn download_quilt_libraries( tracing::debug!("Quilt library resolution complete for {}", profile.id); Ok(()) } - -#[cfg(test)] -mod tests { - use super::*; - use crate::net::HttpClient; - - #[tokio::test] - #[ignore = "hits live Quilt API"] - async fn test_fetch_versions() { - let client = HttpClient::new(); - match fetch_quilt_versions(&client, "1.20.1").await { - Ok(versions) => { - assert!( - !versions.is_empty(), - "Should have Quilt versions for 1.20.1" - ); - } - Err(e) => panic!("fetch_quilt_versions failed: {}", e), - } - } - - #[tokio::test] - #[ignore = "hits live Quilt API"] - async fn test_fetch_game_versions() { - let client = HttpClient::new(); - match fetch_quilt_game_versions(&client).await { - Ok(versions) => { - assert!(!versions.is_empty(), "Should have Quilt game versions"); - assert!(versions.iter().any(|version| version.id == "1.20.1")); - } - Err(e) => panic!("fetch_quilt_game_versions failed: {}", e), - } - } -} diff --git a/src/net/tests/curseforge.rs b/src/net/tests/curseforge.rs new file mode 100644 index 0000000..d0a5f8e --- /dev/null +++ b/src/net/tests/curseforge.rs @@ -0,0 +1,108 @@ +use super::*; + +#[test] +fn curseforge_file_maps_to_shared_version() { + let file: File = serde_json::from_str( + r#"{ + "id": 9, + "modId": 7, + "displayName": "Example 1.0", + "fileName": "example.jar", + "fileLength": 12, + "downloadUrl": "https://example.invalid/example.jar", + "gameVersions": ["1.21.1", "Fabric"], + "releaseType": 2, + "dependencies": [ + {"modId": 8, "relationType": 3}, + {"modId": 9, "relationType": 2}, + {"modId": 10, "relationType": 5} + ], + "hashes": [{"value": "abc", "algo": 1}] + }"#, + ) + .unwrap(); + let version = version_info(file); + assert_eq!(version.project_id, "7"); + assert_eq!(version.loaders, ["fabric"]); + assert_eq!(version.version_type, VersionType::Beta); + assert_eq!( + version + .dependencies + .iter() + .map(|dependency| dependency.dependency_type) + .collect::>(), + [ + DependencyType::Required, + DependencyType::Optional, + DependencyType::Incompatible + ] + ); + assert_eq!(version.files[0].hashes["sha1"], "abc"); +} + +#[test] +fn curseforge_library_category_maps_to_cleanup_metadata() { + let project: Mod = serde_json::from_str( + r#"{ + "id": 7, + "name": "Library", + "slug": "library", + "categories": [{ + "name": "API and Library", + "slug": "library-api" + }] + }"#, + ) + .unwrap(); + + assert!(project_info(project, String::new()).is_library_only()); +} + +#[test] +fn datapack_discovery_uses_the_curseforge_data_packs_class() { + assert_eq!(class_id(ContentKind::DataPack), 6945); +} + +#[tokio::test] +async fn curseforge_versions_follow_pagination() { + use wiremock::matchers::{method, path, query_param}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + let files = |range: std::ops::Range| { + range + .map(|id| { + serde_json::json!({ + "id": id, + "modId": 7, + "displayName": format!("Version {id}"), + "fileName": format!("{id}.jar") + }) + }) + .collect::>() + }; + Mock::given(method("GET")) + .and(path("/mods/7/files")) + .and(query_param("index", "0")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": files(0..50), + "pagination": {"totalCount": 51} + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/mods/7/files")) + .and(query_param("index", "50")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": files(50..51), + "pagination": {"totalCount": 51} + }))) + .mount(&server) + .await; + + let versions = + fetch_versions_from(&HttpClient::new(), "test-key", &server.uri(), "7", "", None) + .await + .unwrap(); + assert_eq!(versions.len(), 51); +} diff --git a/src/net/tests/modrinth.rs b/src/net/tests/modrinth.rs new file mode 100644 index 0000000..9685349 --- /dev/null +++ b/src/net/tests/modrinth.rs @@ -0,0 +1,313 @@ +use super::*; + +fn version_with_files(files: Vec) -> VersionInfo { + VersionInfo { + id: "version-id".to_owned(), + project_id: "project-id".to_owned(), + name: "Version 1".to_owned(), + version_number: "1.0.0".to_owned(), + game_versions: vec!["1.21.1".to_owned()], + loaders: vec!["fabric".to_owned()], + version_type: VersionType::Release, + dependencies: Vec::new(), + date_published: String::new(), + files, + } +} + +#[test] +fn version_dependencies_and_release_type_are_deserialized() { + let version: VersionInfo = serde_json::from_str( + r#"{ + "id": "version", + "project_id": "project", + "name": "Beta", + "version_number": "2.0.0", + "game_versions": ["1.21.1"], + "loaders": ["fabric"], + "version_type": "beta", + "dependencies": [{ + "project_id": "fabric-api", + "dependency_type": "required" + }], + "files": [] + }"#, + ) + .unwrap(); + + assert_eq!(version.version_type, VersionType::Beta); + assert_eq!(version.dependencies.len(), 1); + assert_eq!( + version.dependencies[0].project_id.as_deref(), + Some("fabric-api") + ); + assert_eq!( + version.dependencies[0].dependency_type, + DependencyType::Required + ); +} + +#[test] +fn only_exclusively_library_categorized_projects_are_cleanup_eligible() { + let project = |categories: &[&str]| ProjectInfo { + id: "project".to_owned(), + slug: "project".to_owned(), + title: "Project".to_owned(), + description: String::new(), + body: String::new(), + icon_url: None, + categories: categories + .iter() + .map(|category| (*category).to_owned()) + .collect(), + additional_categories: Vec::new(), + project_type: "mod".to_owned(), + loaders: Vec::new(), + }; + + assert!(project(&["library"]).is_library_only()); + assert!(project(&["api-and-library"]).is_library_only()); + assert!(!project(&[]).is_library_only()); + assert!(!project(&["library", "utility"]).is_library_only()); +} + +#[test] +fn discovery_mod_facets_include_instance_compatibility() { + let facets = discovery_facets(ContentKind::Mod, "1.21.1", ModLoader::Fabric); + assert_eq!( + serde_json::from_str::>>(&facets).unwrap(), + vec![ + vec!["project_type:mod"], + vec!["versions:1.21.1"], + vec!["categories:fabric"], + ] + ); +} + +#[test] +fn discovery_resource_pack_facets_do_not_require_loader() { + let facets = discovery_facets(ContentKind::ResourcePack, "1.20.1", ModLoader::Forge); + assert_eq!( + serde_json::from_str::>>(&facets).unwrap(), + vec![vec!["project_type:resourcepack"], vec!["versions:1.20.1"]] + ); +} + +#[test] +fn discovery_datapack_facets_use_the_datapack_project_type() { + let facets = discovery_facets(ContentKind::DataPack, "1.21.1", ModLoader::Fabric); + assert_eq!( + serde_json::from_str::>>(&facets).unwrap(), + vec![vec!["all_project_types:datapack"], vec!["versions:1.21.1"]] + ); +} + +#[test] +fn compatible_mod_versions_filter_by_game_and_loader() { + let url = content_versions_url( + "https://example.test/v2", + "fabric-api", + ContentKind::Mod, + "1.21.1", + ModLoader::Fabric, + ); + assert_eq!( + url, + "https://example.test/v2/project/fabric-api/version?include_changelog=false&game_versions=%5B%221.21.1%22%5D&loaders=%5B%22fabric%22%5D" + ); +} + +#[test] +fn compatible_resource_pack_versions_do_not_filter_by_loader() { + let url = content_versions_url( + "https://example.test/v2", + "stay-true", + ContentKind::ResourcePack, + "1.21.1", + ModLoader::Fabric, + ); + assert_eq!( + url, + "https://example.test/v2/project/stay-true/version?include_changelog=false&game_versions=%5B%221.21.1%22%5D" + ); +} + +#[test] +fn compatible_datapack_versions_filter_by_datapack_loader() { + let url = content_versions_url( + "https://example.test/v2", + "terralith", + ContentKind::DataPack, + "1.21.1", + ModLoader::Fabric, + ); + assert_eq!( + url, + "https://example.test/v2/project/terralith/version?include_changelog=false&game_versions=%5B%221.21.1%22%5D&loaders=%5B%22datapack%22%5D" + ); +} + +#[test] +fn primary_file_selection_falls_back_to_first_file() { + let version = version_with_files(vec![ + VersionFile { + url: "https://example.test/first.jar".to_owned(), + filename: "first.jar".to_owned(), + size: 1, + primary: false, + hashes: HashMap::new(), + }, + VersionFile { + url: "https://example.test/primary.jar".to_owned(), + filename: "primary.jar".to_owned(), + size: 1, + primary: true, + hashes: HashMap::new(), + }, + ]); + assert_eq!( + select_primary_file(&version).unwrap().filename, + "primary.jar" + ); + + let fallback = version_with_files(vec![VersionFile { + url: "https://example.test/first.jar".to_owned(), + filename: "first.jar".to_owned(), + size: 1, + primary: false, + hashes: HashMap::new(), + }]); + assert_eq!( + select_primary_file(&fallback).unwrap().filename, + "first.jar" + ); +} + +#[test] +fn modpack_versions_are_not_limited_to_hardcoded_loaders() { + assert_eq!( + versions_url("https://example.test/v2", "vanilla pack"), + "https://example.test/v2/project/vanilla%20pack/version" + ); +} + +#[tokio::test] +async fn content_download_skips_an_existing_filename() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("example.jar"); + std::fs::write(&path, b"existing").unwrap(); + let version = version_with_files(vec![VersionFile { + url: "https://example.test/example.jar".to_owned(), + filename: "example.jar".to_owned(), + size: b"existing".len() as u64, + primary: true, + hashes: HashMap::new(), + }]); + + let outcome = download_version_file(&crate::net::HttpClient::new(), &version, directory.path()) + .await + .unwrap(); + + assert_eq!(outcome, DownloadOutcome::SkippedExisting(path)); + assert_eq!( + std::fs::read(directory.path().join("example.jar")).unwrap(), + b"existing" + ); +} + +#[tokio::test] +async fn content_download_rejects_provider_path_components() { + let directory = tempfile::tempdir().unwrap(); + let version = version_with_files(vec![VersionFile { + url: "https://example.test/escape.jar".to_owned(), + filename: "../escape.jar".to_owned(), + size: 1, + primary: true, + hashes: HashMap::new(), + }]); + + let error = download_version_file(&crate::net::HttpClient::new(), &version, directory.path()) + .await + .unwrap_err(); + + assert!(error.to_string().contains("Invalid provider filename")); + assert!( + !directory + .path() + .parent() + .unwrap() + .join("escape.jar") + .exists() + ); +} + +#[tokio::test] +async fn staged_update_replaces_the_old_file_and_cleans_its_backup() { + let directory = tempfile::tempdir().unwrap(); + let installed = directory.path().join("example.jar"); + let temporary = directory.path().join(".example.jar.rmcl-download"); + let backup = directory.path().join(".example.jar.rmcl-backup"); + std::fs::write(&installed, b"old version").unwrap(); + std::fs::write(&temporary, b"new version").unwrap(); + + replace_installed_file(&temporary, &installed, &installed, &backup) + .await + .unwrap(); + + assert_eq!(std::fs::read(installed).unwrap(), b"new version"); + assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 1); +} + +#[test] +fn discovery_search_ignores_new_modrinth_enum_values() { + let response: DiscoverySearchResponse = serde_json::from_str( + r#"{ + "hits": [{ + "project_id": "project-id", + "slug": "example", + "title": "Example", + "description": "Example project", + "downloads": 42, + "icon_url": null, + "client_side": "unknown", + "server_side": "unknown" + }], + "total_hits": 1 + }"#, + ) + .unwrap(); + + assert_eq!(response.hits.len(), 1); + assert_eq!(response.hits[0].project_id, "project-id"); + assert_eq!(response.total_hits, 1); +} + +#[test] +fn discovery_search_treats_blank_icon_urls_as_missing() { + let hit: DiscoverySearchHit = serde_json::from_str( + r#"{ + "project_id": "project-id", + "slug": "example", + "title": "Example", + "description": "Example project", + "icon_url": " " + }"#, + ) + .unwrap(); + + assert!(DiscoveryProject::from(hit).icon_url.is_none()); +} + +// covers each branch of url_encode: unreserved bytes pass through; the +// reserved set + spaces + non-ascii bytes get percent-encoded. emoji +// exercises multi-byte UTF-8 since the encoder operates on bytes, not +// chars, so each byte of the codepoint encodes separately. +#[rstest::rstest] +#[case::ascii_unreserved("abcXYZ0-9_.~", "abcXYZ0-9_.~")] +#[case::space("hello world", "hello%20world")] +#[case::reserved("/?&=#", "%2F%3F%26%3D%23")] +#[case::utf8_emoji("\u{2603}", "%E2%98%83")] +#[case::empty("", "")] +fn url_encode_handles(#[case] input: &str, #[case] expected: &str) { + assert_eq!(url_encode(input), expected); +} diff --git a/src/net/tests/neoforge.rs b/src/net/tests/neoforge.rs new file mode 100644 index 0000000..c3d078d --- /dev/null +++ b/src/net/tests/neoforge.rs @@ -0,0 +1,22 @@ +use super::*; + +#[test] +fn test_game_version_to_neoforge_prefix() { + assert_eq!( + game_version_to_neoforge_prefix("1.21"), + Some("21.0.".to_string()) + ); + assert_eq!( + game_version_to_neoforge_prefix("1.20.4"), + Some("20.4.".to_string()) + ); + assert_eq!( + game_version_to_neoforge_prefix("1.21.1"), + Some("21.1.".to_string()) + ); + assert_eq!( + game_version_to_neoforge_prefix("26.1.2"), + Some("26.1.2.".to_string()) + ); + assert_eq!(game_version_to_neoforge_prefix("invalid"), None); +} diff --git a/src/storage.rs b/src/storage.rs new file mode 100644 index 0000000..38352cd --- /dev/null +++ b/src/storage.rs @@ -0,0 +1,187 @@ +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +static TEMP_FILE_ID: AtomicU64 = AtomicU64::new(1); + +pub const MINECRAFT_DIR_NAME: &str = "minecraft"; +pub const INSTANCE_STATE_DIR_NAME: &str = "rmcl"; +pub const LAYOUT_VERSION: u32 = 2; + +#[derive(Debug, Clone)] +pub struct InstancePaths { + root: PathBuf, +} + +impl InstancePaths { + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + pub fn root(&self) -> &Path { + &self.root + } + + pub fn minecraft(&self) -> PathBuf { + self.root.join(MINECRAFT_DIR_NAME) + } + + pub fn state(&self) -> PathBuf { + self.root.join(INSTANCE_STATE_DIR_NAME) + } + + pub fn content(&self) -> PathBuf { + self.state().join("content") + } + + pub fn content_manifest(&self) -> PathBuf { + self.content().join("manifest.json") + } + + pub fn content_updates(&self) -> PathBuf { + self.content().join("updates.json") + } + + pub fn modpack_state(&self) -> PathBuf { + self.state().join("modpack.json") + } + + pub fn local_config(&self) -> PathBuf { + self.content().join("config") + } +} + +#[derive(Debug, Clone)] +pub struct MetadataPaths { + root: PathBuf, +} + +impl MetadataPaths { + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + pub fn root(&self) -> &Path { + &self.root + } + + pub fn state(&self) -> PathBuf { + self.root.join("state") + } + + pub fn profiles(&self) -> PathBuf { + self.state().join("profiles") + } + + pub fn backups(&self) -> PathBuf { + self.state().join("backups") + } + + pub fn cache(&self) -> PathBuf { + self.root.join("cache") + } + + pub fn minecraft_cache(&self) -> PathBuf { + self.cache().join("minecraft") + } + + pub fn versions(&self) -> PathBuf { + self.minecraft_cache().join("versions") + } + + pub fn libraries(&self) -> PathBuf { + self.minecraft_cache().join("libraries") + } + + pub fn assets(&self) -> PathBuf { + self.minecraft_cache().join("assets") + } + + pub fn loader_profiles(&self) -> PathBuf { + self.cache().join("loaders").join("profiles") + } + + pub fn provider_cache(&self, provider: &str) -> PathBuf { + self.cache().join("providers").join(provider) + } + + pub fn provider_projects(&self, provider: &str) -> PathBuf { + self.provider_cache(provider).join("projects") + } + + pub fn provider_versions(&self, provider: &str) -> PathBuf { + self.provider_cache(provider).join("versions") + } + + pub fn provider_icons(&self, provider: &str) -> PathBuf { + self.provider_cache(provider).join("icons") + } + + pub fn temporary(&self) -> PathBuf { + self.root.join("tmp") + } + + pub fn layout_marker(&self) -> PathBuf { + self.state().join("layout.json") + } + + pub fn migration_journal(&self) -> PathBuf { + self.state().join("migration.json") + } + + pub fn cache_rebuild_pending(&self) -> PathBuf { + self.state().join("cache-rebuild.pending") + } +} + +pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> { + let parent = path + .parent() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "path has no parent"))?; + std::fs::create_dir_all(parent)?; + let id = TEMP_FILE_ID.fetch_add(1, Ordering::Relaxed); + let name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("data"); + let temporary = parent.join(format!(".{name}.{}.{}.tmp", std::process::id(), id)); + let result = (|| { + use std::io::Write; + + let mut file = std::fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&temporary)?; + file.write_all(bytes)?; + file.sync_all()?; + replace_file(&temporary, path) + })(); + if result.is_err() { + let _ = std::fs::remove_file(&temporary); + } + result +} + +#[cfg(not(windows))] +fn replace_file(source: &Path, destination: &Path) -> io::Result<()> { + std::fs::rename(source, destination) +} + +#[cfg(windows)] +fn replace_file(source: &Path, destination: &Path) -> io::Result<()> { + if !destination.exists() { + return std::fs::rename(source, destination); + } + let id = TEMP_FILE_ID.fetch_add(1, Ordering::Relaxed); + let backup = destination.with_extension(format!("rmcl-replaced-{id}")); + std::fs::rename(destination, &backup)?; + if let Err(error) = std::fs::rename(source, destination) { + let _ = std::fs::rename(&backup, destination); + return Err(error); + } + std::fs::remove_file(backup) +} + +#[cfg(test)] +#[path = "tests/storage.rs"] +mod tests; diff --git a/src/tests/layout_migration.rs b/src/tests/layout_migration.rs new file mode 100644 index 0000000..4e097b0 --- /dev/null +++ b/src/tests/layout_migration.rs @@ -0,0 +1,209 @@ +use super::*; + +#[test] +fn migration_backs_up_and_renames_instance_directories() { + let temp = tempfile::tempdir().unwrap(); + let instances = temp.path().join("instances"); + let meta = temp.path().join("meta"); + let config = temp.path().join("config.toml"); + let instance = instances.join("Example"); + fs::create_dir_all(instance.join(".minecraft/saves/world")).unwrap(); + fs::create_dir_all(instance.join(".rmcl/config-sync/local-config/config")).unwrap(); + fs::write(instance.join(".minecraft/saves/world/level.dat"), b"world").unwrap(); + fs::write( + instance.join(".rmcl/config-sync/local-config/options.txt"), + b"options", + ) + .unwrap(); + fs::write(&config, b"[paths]").unwrap(); + fs::create_dir_all(meta.join("config-sync/profiles/main")).unwrap(); + fs::write( + meta.join("config-sync/profiles/main/options.txt"), + b"profile", + ) + .unwrap(); + + let mut progress = Vec::new(); + let backup = run(&instances, &meta, &config, |update| progress.push(update)).unwrap(); + + assert_eq!( + fs::read(instance.join("minecraft/saves/world/level.dat")).unwrap(), + b"world" + ); + assert_eq!( + fs::read(instance.join("rmcl/content/config/options.txt")).unwrap(), + b"options" + ); + assert!(backup.join("instances/Example/.minecraft").exists()); + assert!(backup.join("profiles/legacy/main/options.txt").exists()); + assert!( + backup + .file_name() + .unwrap() + .to_string_lossy() + .starts_with("backup-") + ); + assert!(!meta.join("config-sync").exists()); + assert!(progress.iter().any(|update| { + update.item_total.is_some_and(|total| total > 0) + && update.item_current.is_some_and(|current| current > 0) + })); + assert_eq!( + marker_version(&MetadataPaths::new(&meta).layout_marker()), + Some(LAYOUT_VERSION) + ); + assert!(cache_rebuild_pending(&meta)); + assert_eq!(run(&instances, &meta, &config, |_| {}).unwrap(), backup); + finish_cache_rebuild(&meta).unwrap(); + assert!(!cache_rebuild_pending(&meta)); +} + +#[test] +fn migration_resumes_from_the_recorded_journal() { + let temp = tempfile::tempdir().unwrap(); + let instances = temp.path().join("instances"); + let meta = temp.path().join("meta"); + let config = temp.path().join("config.toml"); + let done = instances.join("Done"); + let pending = instances.join("Pending"); + fs::create_dir_all(done.join("minecraft")).unwrap(); + fs::create_dir_all(pending.join(".minecraft/saves")).unwrap(); + + let metadata = MetadataPaths::new(&meta); + fs::create_dir_all(metadata.state()).unwrap(); + fs::create_dir_all(metadata.backups()).unwrap(); + let backup = metadata.backups().join("backup-resume"); + fs::create_dir_all(&backup).unwrap(); + write_json_atomic( + &metadata.migration_journal(), + &MigrationJournal { + version: LAYOUT_VERSION, + backup_dir: backup.clone(), + completed: vec!["backup".to_owned(), "instance:Done".to_owned()], + }, + ) + .unwrap(); + + assert_eq!(run(&instances, &meta, &config, |_| {}).unwrap(), backup); + assert!(done.join("minecraft").exists()); + assert!(pending.join("minecraft/saves").exists()); + assert!(!metadata.migration_journal().exists()); +} + +#[test] +fn stale_partial_backup_is_replaced() { + let temp = tempfile::tempdir().unwrap(); + let instances = temp.path().join("instances"); + let backup = temp.path().join("backups/backup-test"); + let partial = backup.with_extension("partial"); + fs::create_dir_all(instances.join("Example")).unwrap(); + fs::write(instances.join("Example/data.txt"), b"current").unwrap(); + fs::create_dir_all(&partial).unwrap(); + fs::write(partial.join("stale.txt"), b"stale").unwrap(); + + backup_user_data( + &instances, + &temp.path().join("missing-config.toml"), + &temp.path().join("meta"), + &backup, + |_, _, _| {}, + ) + .unwrap(); + + assert_eq!( + fs::read(backup.join("instances/Example/data.txt")).unwrap(), + b"current" + ); + assert!(!backup.join("stale.txt").exists()); + assert!(!partial.exists()); +} + +#[test] +fn backup_rejects_overlap_and_insufficient_space() { + let temp = tempfile::tempdir().unwrap(); + let instances = temp.path().join("instances"); + fs::create_dir_all(&instances).unwrap(); + + let overlap = + validate_backup_destination(&instances, &instances.join("backup"), 0).unwrap_err(); + assert!(matches!(overlap, MigrationError::BackupOverlap(_))); + + let outside = temp.path().join("backups/backup"); + let insufficient = validate_backup_destination(&instances, &outside, u64::MAX).unwrap_err(); + assert!(matches!( + insufficient, + MigrationError::InsufficientSpace { .. } + )); +} + +#[test] +fn migration_need_follows_legacy_data_marker_and_pending_rebuild() { + let temp = tempfile::tempdir().unwrap(); + let instances = temp.path().join("instances"); + let meta = temp.path().join("meta"); + fs::create_dir_all(instances.join("Example/.minecraft")).unwrap(); + + assert!(is_needed(&instances, &meta)); + initialize_new_layout(&meta).unwrap(); + assert!(!is_needed(&instances, &meta)); + fs::write(MetadataPaths::new(&meta).layout_marker(), b"invalid marker").unwrap(); + assert!(is_needed(&instances, &meta)); + initialize_new_layout(&meta).unwrap(); + fs::write( + MetadataPaths::new(&meta).cache_rebuild_pending(), + LAYOUT_VERSION.to_string(), + ) + .unwrap(); + assert!(is_needed(&instances, &meta)); +} + +#[cfg(unix)] +#[test] +fn backup_preserves_symlinks() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + let destination = temp.path().join("destination"); + fs::create_dir_all(&source).unwrap(); + fs::write(source.join("target.txt"), b"target").unwrap(); + std::os::unix::fs::symlink("target.txt", source.join("link.txt")).unwrap(); + + copy_dir_recursive_with_progress(&source, &destination, &mut |_, _| {}).unwrap(); + + assert_eq!( + fs::read_link(destination.join("link.txt")).unwrap(), + PathBuf::from("target.txt") + ); +} + +#[test] +fn conflicting_visible_and_hidden_directories_are_not_merged() { + let temp = tempfile::tempdir().unwrap(); + let instance = temp.path().join("Example"); + fs::create_dir_all(instance.join(".minecraft")).unwrap(); + fs::create_dir_all(instance.join("minecraft")).unwrap(); + let error = migrate_instance(&instance, "Example").unwrap_err(); + assert!(matches!(error, MigrationError::PathConflict { .. })); +} + +#[test] +fn merge_conflicts_do_not_overwrite_existing_profiles() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("legacy"); + let destination = temp.path().join("current"); + fs::create_dir_all(source.join("nested")).unwrap(); + fs::create_dir_all(destination.join("nested")).unwrap(); + fs::write(source.join("nested/options.txt"), b"legacy").unwrap(); + fs::write(destination.join("nested/options.txt"), b"current").unwrap(); + + let error = move_or_merge(&source, &destination).unwrap_err(); + + assert!(matches!(error, MigrationError::MergeConflict { .. })); + assert_eq!( + fs::read(destination.join("nested/options.txt")).unwrap(), + b"current" + ); + assert_eq!( + fs::read(source.join("nested/options.txt")).unwrap(), + b"legacy" + ); +} diff --git a/src/tests/migrate.rs b/src/tests/migrate.rs new file mode 100644 index 0000000..ffce813 --- /dev/null +++ b/src/tests/migrate.rs @@ -0,0 +1,122 @@ +use super::*; + +#[test] +fn rename_top_level_moves_when_only_old_exists() { + let tmp = tempfile::tempdir().unwrap(); + let old = tmp.path().join("mcl"); + let new = tmp.path().join("rmcl"); + fs::create_dir_all(old.join("sub")).unwrap(); + fs::write(old.join("sub").join("f.txt"), b"hi").unwrap(); + + rename_top_level(&old, &new); + + assert!(!old.exists()); + assert!(new.exists()); + assert_eq!(fs::read(new.join("sub").join("f.txt")).unwrap(), b"hi"); +} + +#[test] +fn rename_top_level_skips_when_only_new_exists() { + let tmp = tempfile::tempdir().unwrap(); + let old = tmp.path().join("mcl"); + let new = tmp.path().join("rmcl"); + fs::create_dir_all(&new).unwrap(); + fs::write(new.join("marker.txt"), b"keep").unwrap(); + + rename_top_level(&old, &new); + + assert!(!old.exists()); + assert_eq!(fs::read(new.join("marker.txt")).unwrap(), b"keep"); +} + +#[test] +fn rename_top_level_skips_when_both_exist() { + let tmp = tempfile::tempdir().unwrap(); + let old = tmp.path().join("mcl"); + let new = tmp.path().join("rmcl"); + fs::create_dir_all(&old).unwrap(); + fs::create_dir_all(&new).unwrap(); + fs::write(old.join("a"), b"old").unwrap(); + fs::write(new.join("b"), b"new").unwrap(); + + rename_top_level(&old, &new); + + assert!(old.exists(), "old should remain when both exist"); + assert!(new.exists(), "new should remain when both exist"); + assert_eq!(fs::read(old.join("a")).unwrap(), b"old"); + assert_eq!(fs::read(new.join("b")).unwrap(), b"new"); +} + +#[test] +fn rename_top_level_noop_when_neither_exists() { + let tmp = tempfile::tempdir().unwrap(); + let old = tmp.path().join("mcl"); + let new = tmp.path().join("rmcl"); + + rename_top_level(&old, &new); + + assert!(!old.exists()); + assert!(!new.exists()); +} + +#[test] +fn cleanup_instance_leftovers_removes_shim_and_log4j() { + let tmp = tempfile::tempdir().unwrap(); + let instances = tmp.path().join("instances"); + let mc = instances + .join("Test") + .join(crate::storage::MINECRAFT_DIR_NAME); + fs::create_dir_all(&mc).unwrap(); + fs::write(mc.join(".mcl-shim.jar"), b"jar").unwrap(); + fs::write(mc.join(".mcl-log4j2.xml"), b"xml").unwrap(); + fs::write(mc.join("keep.txt"), b"keep").unwrap(); + + cleanup_instance_leftovers(&instances); + + assert!(!mc.join(".mcl-shim.jar").exists()); + assert!(!mc.join(".mcl-log4j2.xml").exists()); + assert!(mc.join("keep.txt").exists()); +} + +#[test] +#[cfg(target_os = "linux")] +fn rewrite_linux_desktop_entries_renames_and_rewrites_exec() { + let tmp = tempfile::tempdir().unwrap(); + let data = tmp.path(); + let instances = data.join("rmcl").join("instances"); + let apps = data.join("applications"); + fs::create_dir_all(instances.join("MyPack")).unwrap(); + fs::create_dir_all(&apps).unwrap(); + let old_entry = apps.join("mcl-MyPack.desktop"); + fs::write( + &old_entry, + "[Desktop Entry]\nName=Test\nExec=mcl instance launch \"MyPack\"\n", + ) + .unwrap(); + + rewrite_linux_desktop_entries(data, &instances); + + let new_entry = apps.join("rmcl-MyPack.desktop"); + assert!(!old_entry.exists(), "old .desktop should be removed"); + assert!(new_entry.exists(), "new .desktop should exist"); + let content = fs::read_to_string(&new_entry).unwrap(); + assert!(content.contains("Exec=rmcl instance launch \"MyPack\"")); +} + +#[test] +fn copy_dir_recursive_copies_nested_tree() { + let tmp = tempfile::tempdir().unwrap(); + let src = tmp.path().join("a"); + let dst = tmp.path().join("b"); + fs::create_dir_all(src.join("nested")).unwrap(); + fs::write(src.join("top.txt"), b"top").unwrap(); + fs::write(src.join("nested").join("inner.txt"), b"inner").unwrap(); + + copy_dir_recursive(&src, &dst).unwrap(); + + assert_eq!(fs::read(dst.join("top.txt")).unwrap(), b"top"); + assert_eq!( + fs::read(dst.join("nested").join("inner.txt")).unwrap(), + b"inner" + ); +} diff --git a/src/tests/mod.rs b/src/tests/mod.rs new file mode 100644 index 0000000..4250d35 --- /dev/null +++ b/src/tests/mod.rs @@ -0,0 +1,3 @@ +use std::sync::Mutex; + +pub(crate) static TEST_LOCK: Mutex<()> = Mutex::new(()); diff --git a/src/tests/storage.rs b/src/tests/storage.rs new file mode 100644 index 0000000..8f02ab0 --- /dev/null +++ b/src/tests/storage.rs @@ -0,0 +1,45 @@ +use super::*; + +#[test] +fn instance_paths_use_visible_directories() { + let paths = InstancePaths::new("/instances/example"); + assert_eq!( + paths.minecraft(), + PathBuf::from("/instances/example/minecraft") + ); + assert_eq!( + paths.content_manifest(), + PathBuf::from("/instances/example/rmcl/content/manifest.json") + ); + assert_eq!( + paths.local_config(), + PathBuf::from("/instances/example/rmcl/content/config") + ); +} + +#[test] +fn metadata_paths_separate_state_and_cache() { + let paths = MetadataPaths::new("/meta"); + assert_eq!(paths.profiles(), PathBuf::from("/meta/state/profiles")); + assert_eq!( + paths.provider_icons("modrinth"), + PathBuf::from("/meta/cache/providers/modrinth/icons") + ); + assert_eq!( + paths.versions(), + PathBuf::from("/meta/cache/minecraft/versions") + ); + assert_eq!( + paths.migration_journal(), + PathBuf::from("/meta/state/migration.json") + ); +} + +#[test] +fn atomic_write_replaces_existing_file() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("state.json"); + std::fs::write(&path, b"old").unwrap(); + write_atomic(&path, b"new").unwrap(); + assert_eq!(std::fs::read(path).unwrap(), b"new"); +} diff --git a/src/time.rs b/src/time.rs new file mode 100644 index 0000000..26afa79 --- /dev/null +++ b/src/time.rs @@ -0,0 +1,26 @@ +use chrono::{DateTime, Utc}; + +// Rough human-friendly time delta. "2 months ago" is close enough when months +// are about 30 days. +pub(crate) fn format_relative_time(timestamp: Option>) -> String { + let Some(timestamp) = timestamp else { + return "Never played".to_string(); + }; + let seconds = Utc::now() + .signed_duration_since(timestamp) + .num_seconds() + .max(0) as u64; + match seconds { + 0..=59 => "Just now".to_string(), + 60..=3599 => ago(seconds / 60, "minute"), + 3600..=86399 => ago(seconds / 3600, "hour"), + 86400..=2591999 => ago(seconds / 86400, "day"), + 2592000..=31535999 => ago(seconds / 2592000, "month"), + _ => "Over a year ago".to_string(), + } +} + +fn ago(value: u64, unit: &str) -> String { + let plural = if value == 1 { "" } else { "s" }; + format!("{value} {unit}{plural} ago") +} diff --git a/src/tui/app.rs b/src/tui/app.rs index 1049bdf..bd81595 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -3,7 +3,8 @@ // kinda pushes you into this pattern since you need mutable access // to all the widget states during rendering. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; use std::sync::{Arc, LazyLock, Mutex}; use tachyonfx::Effect; @@ -21,11 +22,19 @@ pub struct App { pub(super) focused: FocusedArea, pub(super) pre_overlay_focused: FocusedArea, pub(super) content_tab: widgets::content::ContentTab, + pub(super) content_mode: widgets::content::ContentMode, pub(super) instances_state: instances::State, pub(super) mods_state: widgets::content::list::ContentListState, + pub(super) mods_discovery_state: widgets::content::DiscoveryState, pub(super) resource_packs_state: widgets::content::list::ContentListState, + pub(super) resource_packs_discovery_state: widgets::content::DiscoveryState, pub(super) shaders_state: widgets::content::list::ContentListState, + pub(super) shaders_discovery_state: widgets::content::DiscoveryState, + pub(super) datapacks_discovery_state: widgets::content::DiscoveryState, pub(super) worlds_state: widgets::content::list::ContentListState, + pub(super) world_datapacks_state: widgets::content::list::ContentListState, + pub(super) open_world_datapacks: Option<(String, PathBuf)>, + pub(super) world_quick_play_support: Option<(String, String, bool)>, pub(super) screenshots_state: widgets::screenshots_grid::ScreenshotsState, pub(super) logs_state: widgets::logs_viewer::LogsState, pub(super) account_state: widgets::account::AccountState, @@ -40,6 +49,21 @@ pub struct App { pub(super) throbber_tick: u8, pub(super) error_effects: HashMap, pub(super) pending_editor: Option, + pub(super) reconciliation_for: Option<(String, chrono::DateTime)>, + pub(super) content_manifest: Option<(String, crate::instance::ContentManifest)>, + pub(super) content_update_snapshot: + Option<(String, crate::instance::content::updates::UpdateSnapshot)>, + pub(super) content_update_popup: Option, + pub(super) modpack_versions_state: Option, + pub(super) modpack_update_popup: Option, + pub(super) provider_conflict: Option, + pub(super) dismissed_provider_conflicts: HashSet, +} + +pub(super) struct ProviderConflictState { + pub relative_path: PathBuf, + pub candidates: Vec, + pub selected: usize, } // lifecycle of an error toast animation: slide in -> sit there -> fade out @@ -65,33 +89,88 @@ pub enum FocusedArea { } impl App { + pub(super) fn selected_instance_supports_quick_play(&mut self) -> bool { + let Some((name, game_version)) = self + .instances_state + .selected_instance() + .map(|instance| (instance.name.clone(), instance.game_version.clone())) + else { + return false; + }; + if let Some((cached_name, cached_version, supported)) = &self.world_quick_play_support + && cached_name == &name + && cached_version == &game_version + { + return *supported; + } + let supported = crate::instance::launch::supports_quick_play( + &self.instance_manager.meta_dir, + &game_version, + ); + self.world_quick_play_support = Some((name, game_version, supported)); + supported + } + pub fn new(picker: ratatui_image::picker::Picker) -> Self { let instances_dir = crate::config::SETTINGS.paths.resolve_instances_dir(); let meta_dir = crate::config::SETTINGS.paths.resolve_meta_dir(); let _ = std::fs::create_dir_all(&instances_dir); let _ = std::fs::create_dir_all(&meta_dir); + crate::instance::import::refresh::recover_interrupted(&instances_dir); let manager = InstanceManager::new(instances_dir, meta_dir); let instances = manager.load_all(); + instances::spawn_modpack_update_checks(&instances); let instances_state = instances::State::with_instances(instances); + let mut mods_state = widgets::content::list::ContentListState::default(); + let mut resource_packs_state = widgets::content::list::ContentListState::default(); + let mut shaders_state = widgets::content::list::ContentListState::default(); + let mut world_datapacks_state = widgets::content::list::ContentListState::default(); + let provider_icon_client = crate::net::HttpClient::new(); + for state in [ + &mut mods_state, + &mut resource_packs_state, + &mut shaders_state, + &mut world_datapacks_state, + ] { + state.enable_provider_icons(manager.meta_dir.clone(), provider_icon_client.clone()); + } + App { exit: false, focused: FocusedArea::default(), pre_overlay_focused: FocusedArea::default(), content_tab: widgets::content::ContentTab::default(), + content_mode: widgets::content::ContentMode::default(), instances_state, - mods_state: widgets::content::list::ContentListState::default(), - resource_packs_state: widgets::content::list::ContentListState::default(), - shaders_state: widgets::content::list::ContentListState::default(), + mods_state, + mods_discovery_state: widgets::content::DiscoveryState::new( + crate::instance::ContentKind::Mod, + ), + resource_packs_state, + resource_packs_discovery_state: widgets::content::DiscoveryState::new( + crate::instance::ContentKind::ResourcePack, + ), + shaders_state, + shaders_discovery_state: widgets::content::DiscoveryState::new( + crate::instance::ContentKind::Shader, + ), + datapacks_discovery_state: widgets::content::DiscoveryState::new( + crate::instance::ContentKind::DataPack, + ), worlds_state: widgets::content::list::ContentListState::default(), + world_datapacks_state, + open_world_datapacks: None, + world_quick_play_support: None, logs_state: widgets::logs_viewer::LogsState::default(), account_state: widgets::account::AccountState::default(), settings_state: widgets::settings::SettingsState::new(manager.meta_dir.clone()), screenshots_state: { let mut s = widgets::screenshots_grid::ScreenshotsState::default(); - s.font_size = picker.font_size(); + let font_size = picker.font_size(); + s.font_size = (font_size.width, font_size.height); s }, picker, @@ -104,6 +183,18 @@ impl App { throbber_tick: 0, error_effects: HashMap::new(), pending_editor: None, + reconciliation_for: None, + content_manifest: None, + content_update_snapshot: None, + content_update_popup: None, + modpack_versions_state: None, + modpack_update_popup: None, + provider_conflict: None, + dismissed_provider_conflicts: HashSet::new(), } } + + pub(super) fn into_picker(self) -> ratatui_image::picker::Picker { + self.picker + } } diff --git a/src/tui/error_buffer.rs b/src/tui/error_buffer.rs deleted file mode 100644 index 77bd074..0000000 --- a/src/tui/error_buffer.rs +++ /dev/null @@ -1,177 +0,0 @@ -// thread-safe FIFO queue for error/warning toasts displayed in the UI. -// also (ab)used for INFO toasts like "desktop shortcut created" because -// why build a separate notification system when this one works fine. -// -// callers pass id: 0 and push_error assigns a real unique id. the id is -// used by the render layer to track per-toast animation state. - -use std::collections::VecDeque; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex}; -use std::time::Instant; - -use std::sync::LazyLock; -use tracing::Level; - -const MAX_ERROR_EVENTS: usize = 50; -static NEXT_ERROR_ID: AtomicU64 = AtomicU64::new(1); - -#[derive(Debug, Clone)] -pub struct ErrorEvent { - pub id: u64, - pub level: Level, - pub message: String, - pub pushed_at: Instant, -} - -pub static ERROR_EVENTS: LazyLock>>> = - LazyLock::new(|| Arc::new(Mutex::new(VecDeque::new()))); - -pub fn push_error(event: ErrorEvent) { - match ERROR_EVENTS.lock() { - Ok(mut events) => { - let mut event = event; - event.id = NEXT_ERROR_ID.fetch_add(1, Ordering::Relaxed); - - events.push_back(event); - - while events.len() > MAX_ERROR_EVENTS { - events.pop_front(); - } - crate::tui::request_redraw(); - } - Err(e) => { - tracing::error!("Error buffer lock poisoned: {}", e); - } - } -} - -#[must_use] -pub fn has_errors() -> bool { - match ERROR_EVENTS.lock() { - Ok(events) => !events.is_empty(), - Err(_) => false, - } -} - -#[must_use] -pub fn pop_error() -> Option { - match ERROR_EVENTS.lock() { - Ok(mut events) => { - let event = events.pop_front(); - if event.is_some() { - crate::tui::request_redraw(); - } - event - } - Err(_) => None, - } -} - -#[must_use] -pub fn peek_error() -> Option { - match ERROR_EVENTS.lock() { - Ok(events) => events.front().cloned(), - Err(_) => None, - } -} - -#[must_use] -// returned in reverse order (newest first) so they stack top-down in the UI -pub fn peek_all_errors() -> Vec { - match ERROR_EVENTS.lock() { - Ok(events) => events.iter().rev().cloned().collect(), - Err(_) => Vec::new(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::sync::Mutex; - - static TEST_LOCK: Mutex<()> = Mutex::new(()); - - fn clear_errors_for_test() { - ERROR_EVENTS.lock().unwrap().clear(); - } - - fn make_event(msg: &str) -> ErrorEvent { - ErrorEvent { - id: 0, - level: Level::ERROR, - message: msg.to_string(), - pushed_at: Instant::now(), - } - } - - #[test] - fn peek_does_not_remove() { - let _guard = TEST_LOCK.lock().unwrap(); - clear_errors_for_test(); - - push_error(make_event("peek-test")); - - let count_before = peek_all_errors().len(); - let peeked = peek_error(); - let count_after = peek_all_errors().len(); - - assert_eq!( - count_before, count_after, - "peek should not change queue length" - ); - - assert!(peeked.is_some()); - assert_eq!(peeked.unwrap().message, "peek-test"); - } - - #[test] - fn peek_all_returns_newest_first() { - let _guard = TEST_LOCK.lock().unwrap(); - clear_errors_for_test(); - - push_error(make_event("newest_a")); - push_error(make_event("newest_b")); - - let all = peek_all_errors(); - - assert_eq!(all.len(), 2); - assert!(all[0].message.ends_with("_b")); - assert!(all[1].message.ends_with("_a")); - assert!(all[0].id > all[1].id); - } - - #[test] - fn auto_assigned_ids_are_unique() { - let _guard = TEST_LOCK.lock().unwrap(); - clear_errors_for_test(); - - push_error(make_event("unique_1")); - push_error(make_event("unique_2")); - - let all = peek_all_errors(); - - assert_eq!(all.len(), 2); - assert_ne!(all[0].id, all[1].id); - } - - #[test] - fn overflow_drops_oldest() { - let _guard = TEST_LOCK.lock().unwrap(); - clear_errors_for_test(); - - for i in 0..(MAX_ERROR_EVENTS + 10) { - push_error(make_event(&format!("overflow_{i}"))); - } - - let all = peek_all_errors(); - - assert_eq!(all.len(), MAX_ERROR_EVENTS); - - assert!(!all.iter().any(|e| e.message == "overflow_0")); - assert!(!all.iter().any(|e| e.message == "overflow_9")); - - assert!(all.iter().any(|e| e.message == "overflow_10")); - assert!(all.iter().any(|e| e.message == "overflow_59")); - } -} diff --git a/src/tui/event.rs b/src/tui/event.rs index 6bba606..a7e9f5a 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -1,14 +1,17 @@ use color_eyre::eyre::Context; use crossterm::event::{self, Event}; -use ratatui::crossterm::event::KeyEventKind; +use ratatui::{ + buffer::{Buffer, CellDiffOption}, + crossterm::event::KeyEventKind, +}; use std::time::Duration; use super::Tui; -use super::app::{App, PENDING_INSTANCES}; +use super::app::{App, FocusedArea, PENDING_INSTANCES}; use super::widgets::{self, popups::import_modpack, popups::new_instance}; +use crate::feedback::errors as error_buffer; +use crate::feedback::progress; use crate::instance::InstanceManager; -use crate::tui::error_buffer; -use crate::tui::progress; impl App { /// main loop: poll async results and input at ~60Hz, drawing only when state changes @@ -16,8 +19,11 @@ impl App { let mut last_draw = std::time::Instant::now() .checked_sub(Duration::from_secs(1)) .unwrap_or_else(std::time::Instant::now); + let mut drawn_overlay_count = self.overlay_count(); + let mut drawn_image_skips = Vec::new(); + let mut image_redraw_marker = false; while !self.exit { - let redraw_requested = super::take_redraw_request(); + let redraw_requested = crate::feedback::take_redraw_request(); // check if any popup wizard finished and wants to create/import if let Some(params) = new_instance::take_result() { self.spawn_create(params); @@ -26,6 +32,7 @@ impl App { if let Some(result) = import_modpack::take_result() { self.spawn_import(result); } + import_modpack::drain(&self.picker); self.dismiss_expired_errors(); @@ -33,19 +40,93 @@ impl App { // every content type has its own pending queue because they each // get scanned/loaded on separate tokio tasks self.drain_pending_instances(); + self.instances_state.drain_modpack_updates(); self.drain_pending_last_played(); - self.mods_state.drain_pending(); - self.mods_state.drain_watcher(); - self.mods_state.request_image_loads(&self.picker); - self.mods_state.drain_image_loads(&self.picker); - self.resource_packs_state.drain_pending(); - self.resource_packs_state.drain_watcher(); - self.resource_packs_state.request_image_loads(&self.picker); - self.resource_packs_state.drain_image_loads(&self.picker); - self.shaders_state.drain_pending(); - self.shaders_state.drain_watcher(); - self.shaders_state.request_image_loads(&self.picker); - self.shaders_state.drain_image_loads(&self.picker); + if let Some(state) = self.modpack_versions_state.as_mut() { + state.drain_pending(); + } + let content_update_completed = self.content_update_popup.as_mut().and_then(|update| { + update.drain(); + update.list.request_image_loads(&self.picker); + update.list.drain_image_loads(&self.picker); + update.completed.then_some(update.applied) + }); + if let Some(applied) = content_update_completed { + self.content_update_popup = None; + if applied { + self.content_update_snapshot = None; + self.apply_content_update_snapshot(); + } + } + let completed_modpack = self.modpack_update_popup.as_mut().and_then(|update| { + update.drain(); + update.completed.take() + }); + if let Some(instance) = completed_modpack { + let name = instance.name.clone(); + self.instances_state + .replace_instance(&name, instance.clone()); + self.instances_state.modpack_updates.remove(&name); + widgets::instances::spawn_modpack_update_check(&instance); + self.modpack_update_popup = None; + self.reconciliation_for = None; + self.content_manifest = None; + self.content_update_snapshot = None; + } + let mut local_streamed = false; + let mut content_changed = false; + let mut toggles = Vec::new(); + let mut orphan_cleanup = None; + for (local, discovery) in [ + (&mut self.mods_state, &mut self.mods_discovery_state), + ( + &mut self.resource_packs_state, + &mut self.resource_packs_discovery_state, + ), + (&mut self.shaders_state, &mut self.shaders_discovery_state), + ] { + local_streamed |= local.drain_pending(); + let update = local.drain_watcher(); + content_changed |= update.requires_reconcile; + toggles.extend(update.toggles); + local.drain_provider_icons(); + local.request_image_loads(&self.picker); + local.drain_image_loads(&self.picker); + discovery.drain_pending(); + if self.focused == FocusedArea::Content { + orphan_cleanup = orphan_cleanup.or_else(|| discovery.take_orphan_cleanup()); + } + discovery.list.drain_pending(); + discovery.list.request_image_loads(&self.picker); + discovery.list.drain_image_loads(&self.picker); + } + self.datapacks_discovery_state.drain_pending(); + if self.focused == FocusedArea::Content { + orphan_cleanup = + orphan_cleanup.or_else(|| self.datapacks_discovery_state.take_orphan_cleanup()); + } + self.datapacks_discovery_state.list.drain_pending(); + self.datapacks_discovery_state + .list + .request_image_loads(&self.picker); + self.datapacks_discovery_state + .list + .drain_image_loads(&self.picker); + if let Some(popup) = self.datapacks_discovery_state.version_popup.as_mut() { + popup.worlds.request_image_loads(&self.picker); + popup.worlds.drain_image_loads(&self.picker); + } + local_streamed |= self.world_datapacks_state.drain_pending(); + let update = self.world_datapacks_state.drain_watcher(); + content_changed |= update.requires_reconcile; + toggles.extend(update.toggles); + self.world_datapacks_state.drain_provider_icons(); + self.world_datapacks_state.request_image_loads(&self.picker); + self.world_datapacks_state.drain_image_loads(&self.picker); + if let Some(paths) = orphan_cleanup { + widgets::popups::confirm::set_pending_orphan_dependencies(paths); + self.focused = FocusedArea::ConfirmDelete; + } self.worlds_state.drain_pending(); self.worlds_state.drain_watcher(); self.worlds_state.request_image_loads(&self.picker); @@ -57,8 +138,26 @@ impl App { self.screenshots_state.drain_pending_entries(); self.screenshots_state.request_visible_loads(); self.create_screenshot_protocols(); + if !toggles.is_empty() { + content_changed |= self.persist_content_toggles(&toggles); + } + if content_changed + && let Some(instance) = self.instances_state.selected_instance() + && let Ok(mut results) = + crate::instance::content::reconcile::PENDING_RECONCILIATIONS.lock() + { + results.retain(|result| result.instance_name != instance.name); + } + self.drain_content_reconciliation(); + self.drain_content_update_snapshots(); + self.ensure_content_reconciliation(content_changed); + if local_streamed { + self.apply_cached_content_manifest(); + } + self.ensure_provider_conflict_popup(); + self.ensure_active_discovery_loaded(); let progress_active = progress::is_active(); - let spinner_active = progress_active || crate::running::has_active(); + let spinner_active = progress_active || crate::instance::runtime::has_active(); if spinner_active { // only advance the spinner every 8 ticks to keep it readable self.throbber_tick = self.throbber_tick.wrapping_add(1); @@ -68,11 +167,28 @@ impl App { } let input_changed = self.handle_events().wrap_err("handle events failed")?; + let overlay_count = self.overlay_count(); + let overlay_closed = overlay_count < drawn_overlay_count; let continuously_animated = spinner_active || error_buffer::has_errors(); let safety_refresh = last_draw.elapsed() >= Duration::from_secs(1); - if input_changed || continuously_animated || safety_refresh || redraw_requested { - terminal.draw(|frame| self.render_frame(frame))?; + if input_changed + || continuously_animated + || safety_refresh + || redraw_requested + || overlay_closed + { + let mut image_skips = Vec::new(); + terminal.draw(|frame| { + self.render_frame(frame); + image_skips = terminal_image_skips(frame.buffer_mut()); + if terminal_image_cells_changed(&drawn_image_skips, &image_skips) { + image_redraw_marker = !image_redraw_marker; + } + mark_terminal_images(frame.buffer_mut(), image_redraw_marker); + })?; last_draw = std::time::Instant::now(); + drawn_overlay_count = overlay_count; + drawn_image_skips = image_skips; } if let Some(path) = self.pending_editor.take() @@ -84,6 +200,351 @@ impl App { Ok(()) } + fn overlay_count(&self) -> usize { + error_buffer::peek_all_errors().len() + + usize::from(self.instances_state.show_popup) + + usize::from(self.instances_state.show_import_popup) + + usize::from(self.focused == super::app::FocusedArea::OverviewExpanded) + + usize::from(self.focused == super::app::FocusedArea::ConfirmDelete) + + usize::from(self.provider_conflict.is_some()) + + usize::from( + self.content_update_popup + .as_ref() + .is_some_and(widgets::content::update::State::visible), + ) + + usize::from(self.modpack_update_popup.is_some()) + + usize::from(self.modpack_versions_state.is_some()) + + usize::from(!matches!( + &self.account_state.add_mode, + widgets::account::AddMode::None + )) + + usize::from(!matches!( + &self.settings_state.add_mode, + widgets::settings::AddMode::None + )) + + [ + &self.mods_discovery_state, + &self.resource_packs_discovery_state, + &self.shaders_discovery_state, + &self.datapacks_discovery_state, + ] + .iter() + .filter(|state| state.version_popup.is_some()) + .count() + + usize::from(import_modpack::has_version_popup()) + } + + fn persist_content_toggles( + &mut self, + toggles: &[widgets::content::list::ContentToggle], + ) -> bool { + let Some(instance) = self.instances_state.selected_instance() else { + return true; + }; + let instance_name = instance.name.clone(); + let paths = crate::storage::InstancePaths::new( + self.instance_manager.instances_dir.join(&instance_name), + ); + let minecraft_dir = paths.minecraft(); + let updated = + crate::instance::ContentManifest::update(&paths.content_manifest(), |manifest| { + let mut complete = true; + for toggle in toggles { + let Ok(old_path) = toggle.old_path.strip_prefix(&minecraft_dir) else { + complete = false; + continue; + }; + let Ok(new_path) = toggle.new_path.strip_prefix(&minecraft_dir) else { + complete = false; + continue; + }; + complete &= manifest.rename_record(old_path, new_path, toggle.enabled); + } + Ok((manifest.clone(), complete)) + }); + let (manifest, complete) = match updated { + Ok(updated) => updated, + Err(error) => { + tracing::warn!("Failed to update toggled content metadata: {error}"); + return true; + } + }; + + self.mods_discovery_state + .refresh_installed_manifest(&manifest, &minecraft_dir); + self.resource_packs_discovery_state + .refresh_installed_manifest(&manifest, &minecraft_dir); + self.shaders_discovery_state + .refresh_installed_manifest(&manifest, &minecraft_dir); + self.datapacks_discovery_state + .refresh_installed_manifest(&manifest, &minecraft_dir); + self.content_manifest = Some((instance_name, manifest)); + !complete + } + + fn ensure_content_reconciliation(&mut self, changed: bool) { + let Some(instance) = self.instances_state.selected_instance().cloned() else { + self.reconciliation_for = None; + self.content_manifest = None; + return; + }; + let instance_id = (instance.name.clone(), instance.created); + if !changed && self.reconciliation_for.as_ref() == Some(&instance_id) { + return; + } + let instance_changed = self.reconciliation_for.as_ref() != Some(&instance_id); + if instance_changed { + self.provider_conflict = None; + self.dismissed_provider_conflicts.clear(); + self.content_manifest = None; + self.content_update_snapshot = None; + for discovery in [ + &mut self.mods_discovery_state, + &mut self.resource_packs_discovery_state, + &mut self.shaders_discovery_state, + &mut self.datapacks_discovery_state, + ] { + discovery.refresh_installed_manifest( + &crate::instance::ContentManifest::default(), + &crate::storage::InstancePaths::new( + self.instance_manager.instances_dir.join(&instance.name), + ) + .minecraft(), + ); + } + } + self.reconciliation_for = Some(instance_id); + if changed { + crate::instance::content::reconcile::spawn_after_change( + instance, + self.instance_manager.instances_dir.clone(), + crate::net::HttpClient::new(), + ); + } else { + crate::instance::content::reconcile::spawn( + instance, + self.instance_manager.instances_dir.clone(), + crate::net::HttpClient::new(), + ); + } + } + + fn drain_content_reconciliation(&mut self) { + let Some(selected) = self.instances_state.selected_instance().cloned() else { + return; + }; + let result = match crate::instance::content::reconcile::PENDING_RECONCILIATIONS.lock() { + Ok(mut results) => { + results.retain(|result| { + result.instance_name != selected.name + || result.instance_created == selected.created + }); + results + .iter() + .position(|result| { + result.instance_name == selected.name + && result.instance_created == selected.created + }) + .map(|index| results.remove(index)) + } + Err(_) => return, + }; + let Some(result) = result else { + return; + }; + self.reconciliation_for = Some((result.instance_name.clone(), result.instance_created)); + if let Some(error) = result.error { + tracing::warn!( + "Content reconciliation for {} was incomplete: {}", + result.instance_name, + error + ); + } + let minecraft_dir = crate::storage::InstancePaths::new( + self.instance_manager.instances_dir.join(&selected.name), + ) + .minecraft(); + self.mods_state.apply_manifest( + &result.manifest, + &minecraft_dir, + crate::instance::ContentKind::Mod, + ); + self.resource_packs_state.apply_manifest( + &result.manifest, + &minecraft_dir, + crate::instance::ContentKind::ResourcePack, + ); + self.shaders_state.apply_manifest( + &result.manifest, + &minecraft_dir, + crate::instance::ContentKind::Shader, + ); + self.world_datapacks_state.apply_manifest( + &result.manifest, + &minecraft_dir, + crate::instance::ContentKind::DataPack, + ); + self.mods_discovery_state + .refresh_installed_manifest(&result.manifest, &minecraft_dir); + self.resource_packs_discovery_state + .refresh_installed_manifest(&result.manifest, &minecraft_dir); + self.shaders_discovery_state + .refresh_installed_manifest(&result.manifest, &minecraft_dir); + self.datapacks_discovery_state + .refresh_installed_manifest(&result.manifest, &minecraft_dir); + for world in &mut self.worlds_state.entries { + if let Some(details) = world.world_details.as_mut() { + details.datapacks = crate::instance::content::worlds::datapack_names(&world.path); + } + } + let paths = crate::storage::InstancePaths::new( + self.instance_manager + .instances_dir + .join(&result.instance_name), + ); + self.content_update_snapshot = + crate::instance::content::updates::UpdateSnapshot::load(&paths.content_updates()) + .filter(|snapshot| { + snapshot.applies_to(&selected) && snapshot.matches_manifest(&result.manifest) + }) + .map(|snapshot| (result.instance_name.clone(), snapshot)); + self.content_manifest = Some((result.instance_name.clone(), result.manifest.clone())); + self.apply_content_update_snapshot(); + crate::instance::content::updates::spawn( + selected, + result.manifest, + paths.content_updates(), + ); + } + + fn apply_cached_content_manifest(&mut self) { + let Some((instance_name, manifest)) = &self.content_manifest else { + return; + }; + if self + .instances_state + .selected_instance() + .is_none_or(|instance| instance.name != *instance_name) + { + return; + } + let minecraft_dir = crate::storage::InstancePaths::new( + self.instance_manager.instances_dir.join(instance_name), + ) + .minecraft(); + self.mods_state + .apply_manifest(manifest, &minecraft_dir, crate::instance::ContentKind::Mod); + self.resource_packs_state.apply_manifest( + manifest, + &minecraft_dir, + crate::instance::ContentKind::ResourcePack, + ); + self.shaders_state.apply_manifest( + manifest, + &minecraft_dir, + crate::instance::ContentKind::Shader, + ); + self.world_datapacks_state.apply_manifest( + manifest, + &minecraft_dir, + crate::instance::ContentKind::DataPack, + ); + self.apply_content_update_snapshot(); + } + + fn drain_content_update_snapshots(&mut self) { + let Some(selected) = self.instances_state.selected_instance() else { + return; + }; + let snapshot = match crate::instance::content::updates::PENDING_UPDATE_SNAPSHOTS.lock() { + Ok(mut pending) => { + pending.retain(|pending| { + pending.instance_name != selected.name + || pending.instance_created == selected.created + }); + pending + .iter() + .rposition(|pending| { + pending.instance_name == selected.name + && pending.instance_created == selected.created + }) + .map(|index| pending.remove(index)) + } + Err(_) => return, + }; + let Some(snapshot) = snapshot else { + return; + }; + let current_manifest = self + .content_manifest + .as_ref() + .filter(|(name, _)| name == &snapshot.instance_name) + .map(|(_, manifest)| manifest); + if current_manifest.is_none_or(|manifest| !snapshot.snapshot.matches_manifest(manifest)) { + return; + } + self.content_update_snapshot = Some((snapshot.instance_name, snapshot.snapshot)); + self.apply_content_update_snapshot(); + } + + fn apply_content_update_snapshot(&mut self) { + let snapshot = self + .content_update_snapshot + .as_ref() + .and_then(|(name, snapshot)| { + self.instances_state + .selected_instance() + .filter(|instance| instance.name == *name && snapshot.applies_to(instance)) + .and_then(|_| { + self.content_manifest + .as_ref() + .filter(|(manifest_name, manifest)| { + manifest_name == name && snapshot.matches_manifest(manifest) + }) + .map(|_| snapshot) + }) + }); + self.mods_state.apply_update_snapshot(snapshot); + self.resource_packs_state.apply_update_snapshot(snapshot); + self.shaders_state.apply_update_snapshot(snapshot); + self.world_datapacks_state.apply_update_snapshot(snapshot); + } + + fn ensure_provider_conflict_popup(&mut self) { + if !crate::config::SETTINGS.content.ask_on_provider_conflict + || self.focused != super::app::FocusedArea::Content + || self.provider_conflict.is_some() + { + return; + } + let Some((instance_name, manifest)) = &self.content_manifest else { + return; + }; + if self + .instances_state + .selected_instance() + .is_none_or(|instance| instance.name != *instance_name) + { + return; + } + self.provider_conflict = manifest.files.iter().find_map(|record| { + if self + .dismissed_provider_conflicts + .contains(&record.relative_path) + { + return None; + } + let crate::instance::Resolution::Ambiguous { candidates } = &record.resolution else { + return None; + }; + Some(super::app::ProviderConflictState { + relative_path: record.relative_path.clone(), + candidates: candidates.clone(), + selected: 0, + }) + }); + } + // polls for input with a 16ms timeout (~60fps). only key presses are handled, // releases and repeats are ignored thanks to the enhanced keyboard protocol fn handle_events(&mut self) -> color_eyre::Result { @@ -94,6 +555,10 @@ impl App { .wrap_err_with(|| format!("handling key event failed:\n{key_event:#?}"))?; Ok(true) } + Ok(Event::Mouse(mouse_event)) => { + self.handle_mouse_event(mouse_event); + Ok(true) + } Ok(_) => Ok(true), Err(e) => { tracing::error!("Event read error: {}", e); @@ -130,7 +595,7 @@ impl App { Ok(config) => { if let Ok(mut pending) = pending_instances.lock() { pending.push(config); - crate::tui::request_redraw(); + crate::feedback::request_redraw(); } } Err(e) => { @@ -157,11 +622,11 @@ impl App { Ok(config) => { if let Ok(mut pending) = pending_instances.lock() { pending.push(config); - crate::tui::request_redraw(); + crate::feedback::request_redraw(); } } Err(e) => { - crate::tui::progress::clear(); + crate::feedback::progress::clear(); error_buffer::push_error(error_buffer::ErrorEvent { id: 0, level: tracing::Level::ERROR, @@ -179,6 +644,7 @@ impl App { fn run_editor(terminal: &mut ratatui::DefaultTerminal, path: &std::path::Path) -> bool { use ratatui::crossterm::{ ExecutableCommand, + event::{DisableMouseCapture, EnableMouseCapture}, terminal::{ EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, }, @@ -190,26 +656,10 @@ impl App { .or_else(|_| std::env::var("VISUAL")) .unwrap_or_else(|_| default_editor.to_owned()); - let editor_name = std::path::Path::new(&editor) - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or(&editor); - let is_tui_editor = matches!( - editor_name, - "vi" | "vim" - | "nvim" - | "neovim" - | "nano" - | "micro" - | "helix" - | "hx" - | "emacs" - | "ne" - | "joe" - | "mcedit" - ); + let is_tui_editor = editor_runs_in_terminal(&editor); if is_tui_editor { + let _ = stdout().execute(DisableMouseCapture); let _ = stdout().execute(LeaveAlternateScreen); let _ = disable_raw_mode(); @@ -221,6 +671,7 @@ impl App { .status(); let _ = stdout().execute(EnterAlternateScreen); + let _ = stdout().execute(EnableMouseCapture); let _ = enable_raw_mode(); let _ = terminal.clear(); @@ -272,9 +723,13 @@ impl App { } } - pub(super) fn spawn_launch(&self, instance: crate::instance::InstanceConfig) { + pub(super) fn spawn_launch( + &self, + instance: crate::instance::InstanceConfig, + quick_play_world: Option, + ) { use crate::instance::launch; - use crate::running; + use crate::instance::runtime; let instance = match self.instance_manager.load_one(&instance.name) { Ok(config) => config, @@ -289,15 +744,32 @@ impl App { } }; - running::set_state(&instance.name, running::RunState::Authenticating); + let can_launch = matches!( + runtime::get(&instance.name), + None | Some(runtime::RunState::Crashed(_)) + ); + if !can_launch { + return; + } + runtime::remove(&instance.name); + crate::instance::logs::live::clear(&instance.name); + + runtime::set_state(&instance.name, runtime::RunState::Authenticating); let instances_dir = self.instance_manager.instances_dir.clone(); let meta_dir = self.instance_manager.meta_dir.clone(); tokio::spawn(async move { - if let Err(e) = launch::launch(&instance, &instances_dir, &meta_dir).await { + if let Err(e) = launch::launch( + &instance, + &instances_dir, + &meta_dir, + quick_play_world.as_deref(), + ) + .await + { tracing::error!("Failed to launch '{}': {}", instance.name, e); - running::remove(&instance.name); + runtime::remove(&instance.name); } }); } @@ -322,13 +794,40 @@ impl App { fn drain_pending_instances(&mut self) { if let Ok(mut pending) = PENDING_INSTANCES.lock() { for config in pending.drain(..) { + self.forget_instance_content(&config.name); + widgets::instances::spawn_modpack_update_check(&config); self.instances_state.add_instance(config); } } } + pub(super) fn forget_instance_content(&mut self, instance_name: &str) { + for state in [ + &mut self.mods_state, + &mut self.resource_packs_state, + &mut self.shaders_state, + &mut self.worlds_state, + &mut self.world_datapacks_state, + ] { + state.forget_instance(instance_name); + } + if self.logs_state.loaded_for.as_deref() == Some(instance_name) { + self.logs_state.loaded_for = None; + } + if self.screenshots_state.loaded_for.as_deref() == Some(instance_name) { + self.screenshots_state.loaded_for = None; + } + self.reconciliation_for = None; + self.content_manifest = None; + self.content_update_snapshot = None; + self.content_update_popup = None; + self.provider_conflict = None; + self.dismissed_provider_conflicts.clear(); + self.apply_content_update_snapshot(); + } + fn drain_pending_last_played(&mut self) { - for (name, time) in crate::running::drain_last_played() { + for (name, time) in crate::instance::runtime::drain_last_played() { for inst in &mut self.instances_state.instances { if inst.name == name { inst.last_played = Some(time); @@ -346,3 +845,59 @@ impl App { } } } + +fn mark_terminal_images(buffer: &mut Buffer, alternate: bool) { + // toggling an invisible suffix lets the normal cell diff redraw exposed + // images before later popup cells, without clearing or repainting the screen + let marker = if alternate { + "\u{200b}\u{200b}" + } else { + "\u{200b}" + }; + for cell in &mut buffer.content { + if matches!(cell.diff_option, CellDiffOption::ForcedWidth(_)) + && cell.symbol().contains('\x1b') + { + let mut symbol = cell.symbol().to_owned(); + symbol.push_str(marker); + cell.set_symbol(&symbol); + } + } +} + +fn terminal_image_skips(buffer: &Buffer) -> Vec { + buffer + .content + .iter() + .map(|cell| matches!(cell.diff_option, CellDiffOption::Skip)) + .collect() +} + +fn terminal_image_cells_changed(previous: &[bool], current: &[bool]) -> bool { + !previous.is_empty() && previous != current +} + +fn editor_runs_in_terminal(editor: &str) -> bool { + let editor_name = std::path::Path::new(editor) + .file_stem() + .and_then(|name| name.to_str()) + .unwrap_or(editor); + matches!( + editor_name, + "vi" | "vim" + | "nvim" + | "neovim" + | "nano" + | "micro" + | "helix" + | "hx" + | "emacs" + | "ne" + | "joe" + | "mcedit" + ) +} + +#[cfg(test)] +#[path = "tests/event.rs"] +mod tests; diff --git a/src/tui/input.rs b/src/tui/input.rs index 8fce574..8df7d01 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -3,16 +3,133 @@ // keypress, and fall through to global bindings if nobody claimed it. // vim-style navigation (j/k/g/G) where it makes sense. -use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::crossterm::event::{ + KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, +}; use super::app::{App, FocusedArea}; use super::widgets::{ self, WidgetKey, popups::confirm as confirm_popup, popups::import_modpack, popups::new_instance, }; -use crate::tui::error_buffer; +use crate::feedback::errors as error_buffer; impl App { + pub(super) fn handle_mouse_event(&mut self, event: MouseEvent) { + let scroll_key = match event.kind { + MouseEventKind::ScrollUp => Some(KeyCode::Up), + MouseEventKind::ScrollDown => Some(KeyCode::Down), + _ => None, + }; + if let Some(code) = scroll_key { + if let Err(error) = self.handle_key_event(KeyEvent::new(code, event.modifiers)) { + tracing::error!("Mouse scroll handling failed: {error}"); + } + return; + } + if event.kind != MouseEventKind::Down(MouseButton::Left) { + return; + } + if self.focused == FocusedArea::ImportPopup + && import_modpack::handle_discovery_click(event.column, event.row) + { + return; + } + if self.focused != FocusedArea::Content + || self.content_mode != widgets::content::ContentMode::Discover + { + return; + } + let page_clicked = self.active_discovery_state_mut().is_some_and(|state| { + !state.search.active + && !state.project_page_open() + && state.version_popup.is_none() + && state.list.click_page(event.column, event.row) + }); + if page_clicked { + self.spawn_active_discovery_page(); + return; + } + let link = self + .active_discovery_state_mut() + .and_then(|state| state.project_link_at(event.column, event.row)); + if let Some(link) = link + && let Err(error) = open::that_detached(link) + { + tracing::warn!("Failed to open project link {link}: {error}"); + } + } + pub(super) fn handle_key_event(&mut self, key_event: KeyEvent) -> color_eyre::Result<()> { + if self + .content_update_popup + .as_ref() + .is_some_and(widgets::content::update::State::visible) + { + self.handle_content_update_key(key_event); + return Ok(()); + } + if self.modpack_versions_state.is_some() { + self.handle_modpack_versions_key(key_event); + return Ok(()); + } + if self.modpack_update_popup.is_some() { + self.handle_modpack_update_key(key_event); + return Ok(()); + } + if let Some(conflict) = self.provider_conflict.as_mut() { + match key_event.code { + KeyCode::Char('j') | KeyCode::Down => { + conflict.selected = + (conflict.selected + 1).min(conflict.candidates.len().saturating_sub(1)); + } + KeyCode::Char('k') | KeyCode::Up => { + conflict.selected = conflict.selected.saturating_sub(1); + } + KeyCode::Enter => { + let relative_path = conflict.relative_path.clone(); + let project = conflict.candidates.get(conflict.selected).cloned(); + let aliases = conflict + .candidates + .iter() + .enumerate() + .filter(|(index, _)| *index != conflict.selected) + .map(|(_, candidate)| candidate.clone()) + .collect::>(); + if let Some(project) = project + && let Some((instance_name, _)) = &self.content_manifest + { + let manifest_path = crate::storage::InstancePaths::new( + self.instance_manager.instances_dir.join(instance_name), + ) + .content_manifest(); + let updated = + crate::instance::ContentManifest::update(&manifest_path, |manifest| { + if let Some(record) = manifest + .files + .iter_mut() + .find(|record| record.relative_path == relative_path) + { + record.resolution = + crate::instance::Resolution::Resolved { project }; + record.provider_aliases = aliases; + } + Ok(manifest.clone()) + })?; + self.content_manifest = Some((instance_name.clone(), updated)); + self.provider_conflict = None; + self.reconciliation_for = None; + } + } + KeyCode::Esc => { + self.dismissed_provider_conflicts + .insert(conflict.relative_path.clone()); + self.provider_conflict = None; + } + _ => {} + } + return Ok(()); + } + // log overlay eats all input when open, including its own search sub-mode if self.focused == FocusedArea::OverviewExpanded { if self.log_overlay_search.active { @@ -24,7 +141,7 @@ impl App { self.log_overlay_search.deactivate(); } KeyCode::Backspace => { - self.log_overlay_search.pop(); + self.log_overlay_search.backspace(key_event.modifiers); } KeyCode::Char(c) => { self.log_overlay_search.push(c); @@ -75,6 +192,7 @@ impl App { match self.instance_manager.delete(&name) { Ok(_) => { self.instances_state.remove_instance(&name); + self.forget_instance_content(&name); } Err(e) => { tracing::error!("Failed to delete instance '{}': {}", name, e); @@ -105,10 +223,16 @@ impl App { } FocusedArea::Settings } - Some(confirm_popup::ConfirmTarget::Content { name, path }) => { + Some(confirm_popup::ConfirmTarget::Content { name, path, .. }) => { + let orphaned = self.orphan_dependencies_after_removing(&path); match delete_content_path(&path) { Ok(()) => { self.remove_content_path_from_states(&path); + self.remove_content_path_from_manifest(&path); + if !orphaned.is_empty() { + confirm_popup::set_pending_orphan_dependencies(orphaned); + return Ok(()); + } } Err(e) => { tracing::error!("Failed to delete content '{}': {}", name, e); @@ -116,6 +240,22 @@ impl App { } FocusedArea::Content } + Some(confirm_popup::ConfirmTarget::OrphanDependencies { paths }) => { + for path in paths { + match delete_content_path(&path) { + Ok(()) => { + self.remove_content_path_from_states(&path); + self.remove_content_path_from_manifest(&path); + } + Err(error) => tracing::error!( + "Failed to remove unused dependency '{}': {}", + path.display(), + error + ), + } + } + FocusedArea::Content + } None => FocusedArea::Instances, }; confirm_popup::clear_pending(); @@ -125,6 +265,9 @@ impl App { KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => { let focus_after = match confirm_popup::pending_target() { Some(confirm_popup::ConfirmTarget::Content { .. }) => FocusedArea::Content, + Some(confirm_popup::ConfirmTarget::OrphanDependencies { .. }) => { + FocusedArea::Content + } Some(confirm_popup::ConfirmTarget::Account { .. }) => FocusedArea::Account, Some(confirm_popup::ConfirmTarget::ConfigProfile { .. }) => { FocusedArea::Settings @@ -143,15 +286,131 @@ impl App { // content area delegates to whichever tab is active. // worlds use the same list navigation without the toggle - if self.focused == FocusedArea::Content { + let discovery_popup_open = self + .active_discovery_state() + .is_some_and(|state| state.version_popup.is_some() || state.project_page_open()); + if self.focused == FocusedArea::Content + && (self.content_mode == widgets::content::ContentMode::Discover + || discovery_popup_open) + { + let (search_active, popup_open, project_page_open) = self + .active_discovery_state_mut() + .map(|state| { + ( + state.search.active, + state.version_popup.is_some(), + state.project_page_open(), + ) + }) + .unwrap_or_default(); + if !search_active + && !popup_open + && !project_page_open + && key_event.code == KeyCode::Enter + { + self.spawn_active_discovery_project_page(); + return Ok(()); + } + if !search_active && !popup_open && key_event.code == KeyCode::Char('v') { + self.spawn_active_discovery_versions(); + return Ok(()); + } + if !search_active + && !popup_open + && !project_page_open + && key_event.code == KeyCode::Char('d') + { + if let Some(pending) = self + .active_discovery_state_mut() + .and_then(|state| state.pending_installed_delete()) + { + self.confirm_content_delete(pending); + } + return Ok(()); + } + if popup_open && key_event.code == KeyCode::Enter { + if self + .active_discovery_state_mut() + .is_some_and(|state| state.select_minecraft_version()) + { + return Ok(()); + } + let selecting_world = self + .active_discovery_state_mut() + .and_then(|state| state.version_popup.as_ref()) + .is_some_and(|popup| popup.selecting_world); + if selecting_world { + let minecraft_dir = self.instances_state.selected_instance().map(|instance| { + crate::storage::InstancePaths::new( + self.instance_manager.instances_dir.join(&instance.name), + ) + .minecraft() + }); + let manifest = self + .content_manifest + .as_ref() + .map(|(_, manifest)| manifest.clone()); + if let Some(minecraft_dir) = minecraft_dir + && self.active_discovery_state_mut().is_some_and(|state| { + state.select_world(manifest.as_ref(), &minecraft_dir) + }) + { + self.spawn_active_discovery_dependencies(); + } + return Ok(()); + } + let confirming = self + .active_discovery_state_mut() + .and_then(|state| state.version_popup.as_ref()) + .is_some_and(|popup| popup.confirming); + if confirming { + self.spawn_active_discovery_install(); + } else { + let kind = self.active_discovery_state_mut().map(|state| state.kind); + if kind == Some(crate::instance::ContentKind::DataPack) { + let worlds = self.worlds_state.entries.clone(); + if let Some(state) = self.active_discovery_state_mut() { + state.begin_world_selection(worlds); + } + } else if matches!( + kind, + Some( + crate::instance::ContentKind::Mod + | crate::instance::ContentKind::DataPack + ) + ) { + self.spawn_active_discovery_dependencies(); + } else if let Some(state) = self.active_discovery_state_mut() { + state.begin_confirmation(); + } + } + return Ok(()); + } + if popup_open && key_event.code == KeyCode::Tab { + self.spawn_active_discovery_version_source(); + return Ok(()); + } + let handled = self + .active_discovery_state_mut() + .is_some_and(|state| widgets::content::discovery::handle_key(&key_event, state)); + if handled { + if matches!( + key_event.code, + KeyCode::Char('j') | KeyCode::Char('k') | KeyCode::Down | KeyCode::Up + ) || widgets::content::discovery::page_key_direction(&key_event).is_some() + { + self.spawn_active_discovery_page(); + } + return Ok(()); + } + } else if self.focused == FocusedArea::Content { if self.content_tab == widgets::content::ContentTab::Logs { if key_event.code == KeyCode::Char('d') && !self.logs_state.search.active && !self.logs_state.viewer_search.active { if let Some(pending) = self.logs_state.pending_delete() { - confirm_popup::set_pending_content_delete(pending.name, pending.path); - self.focused = FocusedArea::ConfirmDelete; + self.confirm_content_delete(pending); } return Ok(()); } @@ -161,8 +420,7 @@ impl App { } else if self.content_tab == widgets::content::ContentTab::Screenshots { if key_event.code == KeyCode::Char('d') && !self.screenshots_state.search.active { if let Some(pending) = self.screenshots_state.pending_delete() { - confirm_popup::set_pending_content_delete(pending.name, pending.path); - self.focused = FocusedArea::ConfirmDelete; + self.confirm_content_delete(pending); } return Ok(()); } @@ -170,10 +428,67 @@ impl App { return Ok(()); } } else if self.content_tab == widgets::content::ContentTab::Worlds { + if self.open_world_datapacks.is_some() { + if key_event.code == KeyCode::Char('u') + && !self.world_datapacks_state.search.active + { + self.spawn_bulk_content_updates(); + return Ok(()); + } + if key_event.code == KeyCode::Char('v') + && !self.world_datapacks_state.search.active + && self.world_datapacks_state.selected_has_provider_project() + { + self.spawn_installed_versions(); + return Ok(()); + } + if matches!(key_event.code, KeyCode::Esc | KeyCode::Char('h')) + && !self.world_datapacks_state.search.active + { + self.open_world_datapacks = None; + return Ok(()); + } + if key_event.code == KeyCode::Char('d') + && !self.world_datapacks_state.search.active + { + if let Some(pending) = self.world_datapacks_state.pending_delete() { + self.confirm_content_delete(pending); + } + return Ok(()); + } + if widgets::content::list::handle_key_no_toggle( + &key_event, + &mut self.world_datapacks_state, + ) { + return Ok(()); + } + return Ok(()); + } + if key_event.code == KeyCode::Enter && !self.worlds_state.search.active { + self.open_world_datapacks = self + .worlds_state + .selected_entry() + .map(|entry| (entry.name.clone(), entry.path.clone())); + return Ok(()); + } + if key_event.code == KeyCode::Char('q') && !self.worlds_state.search.active { + if self.selected_instance_supports_quick_play() { + let instance = self.instances_state.selected_instance().cloned(); + let world = self + .worlds_state + .selected_entry() + .and_then(|entry| entry.path.file_name()) + .and_then(|name| name.to_str()) + .map(str::to_owned); + if let (Some(instance), Some(world)) = (instance, world) { + self.spawn_launch(instance, Some(world)); + } + } + return Ok(()); + } if key_event.code == KeyCode::Char('d') && !self.worlds_state.search.active { if let Some(pending) = self.worlds_state.pending_delete() { - confirm_popup::set_pending_content_delete(pending.name, pending.path); - self.focused = FocusedArea::ConfirmDelete; + self.confirm_content_delete(pending); } return Ok(()); } @@ -191,10 +506,20 @@ impl App { _ => None, }; if let Some(state) = state { + if key_event.code == KeyCode::Char('u') && !state.search.active { + self.spawn_bulk_content_updates(); + return Ok(()); + } + if key_event.code == KeyCode::Char('v') + && !state.search.active + && state.selected_has_provider_project() + { + self.spawn_installed_versions(); + return Ok(()); + } if key_event.code == KeyCode::Char('d') && !state.search.active { if let Some(pending) = state.pending_delete() { - confirm_popup::set_pending_content_delete(pending.name, pending.path); - self.focused = FocusedArea::ConfirmDelete; + self.confirm_content_delete(pending); } return Ok(()); } @@ -225,6 +550,10 @@ impl App { } if self.focused == FocusedArea::Settings { + let editing_profile = matches!( + &self.settings_state.add_mode, + widgets::settings::AddMode::ProfileName(_) + ); match widgets::settings::handle_key( &key_event, &mut self.settings_state, @@ -312,6 +641,9 @@ impl App { } widgets::settings::SettingsAction::None => {} } + if editing_profile { + return Ok(()); + } } match self.focused { @@ -345,7 +677,7 @@ impl App { } KeyCode::Backspace => { if let Some(ref mut name) = self.instances_state.renaming { - name.pop(); + widgets::search::backspace(name, key_event.modifiers); } } KeyCode::Char(c) => { @@ -366,6 +698,17 @@ impl App { // global keybindings (uppercase = area switch, lowercase = action) match key_event.code { KeyCode::Char('q') => self.exit = true, + KeyCode::Esc + if matches!( + self.focused, + FocusedArea::Content + | FocusedArea::Account + | FocusedArea::Settings + | FocusedArea::Overview + ) => + { + self.focused = FocusedArea::Instances; + } KeyCode::Char('I') => self.focused = FocusedArea::Instances, KeyCode::Char('C') => self.focused = FocusedArea::Content, KeyCode::Char('A') => self.focused = FocusedArea::Account, @@ -374,15 +717,42 @@ impl App { self.pre_overlay_focused = self.focused; self.focused = FocusedArea::OverviewExpanded; } - KeyCode::Tab | KeyCode::Char('l') | KeyCode::Right - if self.focused == FocusedArea::Content => - { - self.content_tab = self.content_tab.next(); + KeyCode::Tab if self.focused == FocusedArea::Content => { + self.content_mode = self.content_mode.toggle(); + self.content_tab = match (self.content_mode, self.content_tab) { + ( + widgets::content::ContentMode::Installed, + widgets::content::ContentTab::DataPacks, + ) => widgets::content::ContentTab::Worlds, + ( + widgets::content::ContentMode::Discover, + widgets::content::ContentTab::Worlds, + ) => widgets::content::ContentTab::DataPacks, + (widgets::content::ContentMode::Discover, tab) + if !matches!( + tab, + widgets::content::ContentTab::Mods + | widgets::content::ContentTab::ResourcePacks + | widgets::content::ContentTab::Shaders + | widgets::content::ContentTab::DataPacks + ) => + { + widgets::content::ContentTab::Mods + } + (_, tab) => tab, + }; + if self.content_mode == widgets::content::ContentMode::Discover { + self.open_world_datapacks = None; + } + self.ensure_active_discovery_loaded(); } - KeyCode::BackTab | KeyCode::Char('h') | KeyCode::Left - if self.focused == FocusedArea::Content => - { - self.content_tab = self.content_tab.previous(); + KeyCode::Char('l') | KeyCode::Right if self.focused == FocusedArea::Content => { + self.content_tab = self.content_tab.next_for_mode(self.content_mode); + self.ensure_active_discovery_loaded(); + } + KeyCode::Char('h') | KeyCode::Left if self.focused == FocusedArea::Content => { + self.content_tab = self.content_tab.previous_for_mode(self.content_mode); + self.ensure_active_discovery_loaded(); } KeyCode::Char('d') if self.focused == FocusedArea::Instances @@ -405,7 +775,7 @@ impl App { .instance_manager .instances_dir .join(&instance.name) - .join(".minecraft"); + .join(crate::storage::MINECRAFT_DIR_NAME); if let Err(e) = open::that_detached(&dir) { tracing::error!("Failed to open instance directory: {}", e); } @@ -425,17 +795,21 @@ impl App { && !self.instances_state.search.active => { if let Some(instance) = self.instances_state.selected_instance().cloned() { - let can_launch = matches!( - crate::running::get(&instance.name), - None | Some(crate::running::RunState::Crashed(_)) - ); - if can_launch { - crate::running::remove(&instance.name); - crate::instance_logs::clear(&instance.name); - self.spawn_launch(instance); - } + self.spawn_launch(instance, None); } } + KeyCode::Char('u') + if self.focused == FocusedArea::Instances + && !self.instances_state.search.active => + { + self.spawn_modpack_update(); + } + KeyCode::Char('v') + if self.focused == FocusedArea::Instances + && !self.instances_state.search.active => + { + self.open_modpack_versions(); + } KeyCode::Char('r') if self.focused == FocusedArea::Instances && !self.instances_state.search.active => @@ -450,7 +824,7 @@ impl App { && !self.instances_state.search.active => { if let Some(instance) = self.instances_state.selected_instance() { - crate::running::send_kill(&instance.name); + crate::instance::runtime::send_kill(&instance.name); } } _ => {} @@ -469,6 +843,9 @@ impl App { } if self.instances_state.wants_import_popup() { + if self.focused != FocusedArea::ImportPopup { + import_modpack::open(); + } self.focused = FocusedArea::ImportPopup; } else if self.focused == FocusedArea::ImportPopup { self.focused = FocusedArea::Instances; @@ -477,6 +854,918 @@ impl App { Ok(()) } + fn spawn_modpack_update(&mut self) { + let Some(target) = self.instances_state.selected_modpack_update() else { + return; + }; + self.spawn_modpack_refresh(target, widgets::popups::modpack_update::Action::Update); + } + + fn open_modpack_versions(&mut self) { + let Some(instance) = self.instances_state.selected_instance() else { + return; + }; + let Some(source) = instance.modpack_source.clone() else { + return; + }; + let mut state = widgets::content::DiscoveryState::new_modpacks(); + let Some(request) = state.begin_managed_modpack_versions(&instance.name, source) else { + return; + }; + self.modpack_versions_state = Some(state); + self.spawn_modpack_versions_request(request); + } + + fn spawn_modpack_versions_request( + &self, + request: widgets::content::discovery::VersionsRequest, + ) { + tokio::spawn(async move { + let source = crate::instance::ProviderProject { + provider: request.provider.clone(), + project_id: request.project_id.clone(), + version_id: request.current_version_id.clone().unwrap_or_default(), + }; + let result = crate::instance::import::provider_versions(&source) + .await + .map_err(|error| error.to_string()); + widgets::content::DiscoveryState::push_action_result( + &request.pending, + widgets::content::discovery::DiscoveryActionResult::Versions { + request_id: request.request_id, + project_id: request.project_id, + result, + }, + ); + }); + } + + fn handle_modpack_versions_key(&mut self, key_event: KeyEvent) { + if key_event.code == KeyCode::Tab { + let request = self + .modpack_versions_state + .as_mut() + .and_then(widgets::content::DiscoveryState::switch_version_source); + if let Some(request) = request { + self.spawn_modpack_versions_request(request); + } + return; + } + if key_event.code == KeyCode::Enter { + let target = self + .modpack_versions_state + .as_ref() + .and_then(|state| state.version_popup.as_ref()) + .filter(|popup| !popup.loading) + .and_then(|popup| popup.selected_version().cloned()); + if let Some(target) = target { + let action = if self + .instances_state + .selected_instance() + .and_then(|instance| instance.modpack_source.as_ref()) + .is_some_and(|source| source.version_id == target.id) + { + widgets::popups::modpack_update::Action::Reinstall + } else { + widgets::popups::modpack_update::Action::Change + }; + self.modpack_versions_state = None; + self.spawn_modpack_refresh(target, action); + } + return; + } + let Some(state) = self.modpack_versions_state.as_mut() else { + return; + }; + widgets::content::discovery::handle_key(&key_event, state); + if state.version_popup.is_none() { + self.modpack_versions_state = None; + } + } + + fn spawn_modpack_refresh( + &mut self, + target: crate::net::modrinth::VersionInfo, + action: widgets::popups::modpack_update::Action, + ) { + let Some(instance) = self.instances_state.selected_instance().cloned() else { + return; + }; + let state = widgets::popups::modpack_update::State::preparing(action); + let pending = state.pending.clone(); + self.modpack_update_popup = Some(state); + let instances_dir = self.instance_manager.instances_dir.clone(); + let meta_dir = self.instance_manager.meta_dir.clone(); + tokio::spawn(async move { + let action = match action { + widgets::popups::modpack_update::Action::Update => "update", + widgets::popups::modpack_update::Action::Change => "change", + widgets::popups::modpack_update::Action::Reinstall => "reinstall", + }; + let progress = crate::feedback::progress::ProgressTask::start(format!( + "Preparing modpack {action} for {}", + instance.name + )); + let manager = crate::instance::InstanceManager::new(instances_dir, meta_dir); + let result = + crate::instance::import::refresh::prepare(&manager, &instance, target).await; + if let Err(error) = &result { + progress.fail(error); + } else { + progress.finish(); + } + if let Ok(mut pending) = pending.lock() { + pending.push(widgets::popups::modpack_update::PendingResult::Prepared( + Box::new(result), + )); + crate::feedback::request_redraw(); + } + }); + } + + fn handle_modpack_update_key(&mut self, key_event: KeyEvent) { + let Some(state) = self.modpack_update_popup.as_mut() else { + return; + }; + match (state.phase, key_event.code) { + (widgets::popups::modpack_update::Phase::Preparing, KeyCode::Esc) + | (widgets::popups::modpack_update::Phase::Conflicts, KeyCode::Esc) + | (widgets::popups::modpack_update::Phase::Review, KeyCode::Esc) => { + self.modpack_update_popup = None; + } + ( + widgets::popups::modpack_update::Phase::Conflicts, + KeyCode::Char('j') | KeyCode::Down, + ) => { + let count = state.plan.as_ref().map_or(0, |plan| plan.conflicts.len()); + state.selected = (state.selected + 1).min(count.saturating_sub(1)); + } + ( + widgets::popups::modpack_update::Phase::Conflicts, + KeyCode::Char('k') | KeyCode::Up, + ) => state.selected = state.selected.saturating_sub(1), + (widgets::popups::modpack_update::Phase::Conflicts, KeyCode::Char(' ')) => { + if let Some(replace) = state.replace.get_mut(state.selected) { + *replace = !*replace; + } + } + (widgets::popups::modpack_update::Phase::Conflicts, KeyCode::Enter) => { + state.phase = widgets::popups::modpack_update::Phase::Review; + } + (widgets::popups::modpack_update::Phase::Review, KeyCode::Enter) + if state.plan.is_some() => + { + self.spawn_modpack_update_apply(); + } + _ => {} + } + } + + fn spawn_modpack_update_apply(&mut self) { + let Some(state) = self.modpack_update_popup.as_mut() else { + return; + }; + let action = state.action; + let replacements = state.replacements(); + let Some(plan) = state.plan.take() else { + return; + }; + state.phase = widgets::popups::modpack_update::Phase::Applying; + let pending = state.pending.clone(); + tokio::spawn(async move { + let progress = crate::feedback::progress::ProgressTask::start(match action { + widgets::popups::modpack_update::Action::Update => "Updating modpack", + widgets::popups::modpack_update::Action::Change => "Changing modpack", + widgets::popups::modpack_update::Action::Reinstall => "Reinstalling modpack", + }); + let result = tokio::task::spawn_blocking(move || { + crate::instance::import::refresh::apply(plan, &replacements) + }) + .await + .map_err(|error| error.to_string()) + .and_then(|result| result); + if let Err(error) = &result { + progress.fail(error); + } else { + progress.finish(); + } + if let Ok(mut pending) = pending.lock() { + pending.push(widgets::popups::modpack_update::PendingResult::Applied( + Box::new(result), + )); + crate::feedback::request_redraw(); + } + }); + } + + pub(super) fn ensure_active_discovery_loaded(&mut self) { + if self.content_mode != widgets::content::ContentMode::Discover { + return; + } + let Some(instance) = self.instances_state.selected_instance().cloned() else { + return; + }; + let Some(state) = self.active_discovery_state_mut() else { + return; + }; + if state.unavailable_message(&instance).is_some() { + state.set_unavailable(&instance); + return; + } + let needs_search = state.needs_search(&instance); + let search_due = state.search_due(); + if needs_search || search_due { + self.spawn_active_discovery_search(); + } else { + self.spawn_active_discovery_page(); + } + } + + fn active_discovery_state_mut( + &mut self, + ) -> Option<&mut widgets::content::discovery::DiscoveryState> { + match self.content_tab { + widgets::content::ContentTab::Mods => Some(&mut self.mods_discovery_state), + widgets::content::ContentTab::ResourcePacks => { + Some(&mut self.resource_packs_discovery_state) + } + widgets::content::ContentTab::Shaders => Some(&mut self.shaders_discovery_state), + widgets::content::ContentTab::DataPacks => Some(&mut self.datapacks_discovery_state), + widgets::content::ContentTab::Worlds if self.open_world_datapacks.is_some() => { + Some(&mut self.datapacks_discovery_state) + } + _ => None, + } + } + + fn active_discovery_state(&self) -> Option<&widgets::content::discovery::DiscoveryState> { + match self.content_tab { + widgets::content::ContentTab::Mods => Some(&self.mods_discovery_state), + widgets::content::ContentTab::ResourcePacks => { + Some(&self.resource_packs_discovery_state) + } + widgets::content::ContentTab::Shaders => Some(&self.shaders_discovery_state), + widgets::content::ContentTab::DataPacks => Some(&self.datapacks_discovery_state), + widgets::content::ContentTab::Worlds if self.open_world_datapacks.is_some() => { + Some(&self.datapacks_discovery_state) + } + _ => None, + } + } + + fn spawn_bulk_content_updates(&mut self) { + if self.content_update_popup.is_some() { + return; + } + let Some(instance) = self.instances_state.selected_instance().cloned() else { + return; + }; + let (kind, target_world) = match self.content_tab { + widgets::content::ContentTab::Mods => (crate::instance::ContentKind::Mod, None), + widgets::content::ContentTab::ResourcePacks => { + (crate::instance::ContentKind::ResourcePack, None) + } + widgets::content::ContentTab::Shaders => (crate::instance::ContentKind::Shader, None), + widgets::content::ContentTab::Worlds => ( + crate::instance::ContentKind::DataPack, + self.open_world_datapacks.clone(), + ), + _ => return, + }; + if kind == crate::instance::ContentKind::DataPack && target_world.is_none() { + return; + } + let Some((_, manifest)) = self + .content_manifest + .as_ref() + .filter(|(name, _)| name == &instance.name) + .cloned() + else { + return; + }; + let paths = crate::storage::InstancePaths::new( + self.instance_manager.instances_dir.join(&instance.name), + ); + let source_entries = match self.content_tab { + widgets::content::ContentTab::Mods => self.mods_state.entries.clone(), + widgets::content::ContentTab::ResourcePacks => { + self.resource_packs_state.entries.clone() + } + widgets::content::ContentTab::Shaders => self.shaders_state.entries.clone(), + widgets::content::ContentTab::Worlds => self.world_datapacks_state.entries.clone(), + _ => return, + }; + let cached_snapshot = self + .content_update_snapshot + .as_ref() + .filter(|(name, snapshot)| { + name == &instance.name + && snapshot.applies_to(&instance) + && snapshot.matches_manifest(&manifest) + }) + .map(|(_, snapshot)| snapshot.clone()); + if cached_snapshot + .as_ref() + .is_none_or(|snapshot| !snapshot.updates.iter().any(|update| update.kind == kind)) + { + return; + } + let state = + widgets::content::update::State::checking(kind, target_world.clone(), source_entries); + let pending = state.pending.clone(); + self.content_update_popup = Some(state); + tokio::spawn(async move { + let progress = crate::feedback::progress::ProgressTask::start("Preparing updates"); + let snapshot = cached_snapshot.expect("checked above"); + let target_directory = target_world + .as_ref() + .map(|(_, world)| world.join("datapacks")); + let mut requests = Vec::new(); + let mut conflicts = Vec::new(); + for record in manifest.files.iter().filter(|record| { + record.kind == kind + && target_directory.as_ref().is_none_or(|directory| { + paths + .minecraft() + .join(&record.relative_path) + .starts_with(directory) + }) + }) { + let Some(installed) = record.resolved_project() else { + continue; + }; + let installed_path = paths.minecraft().join(&record.relative_path); + let title = record + .relative_path + .file_stem() + .and_then(|name| name.to_str()) + .unwrap_or("content") + .to_owned(); + if let Some(update) = snapshot.update_for(installed) { + requests.push(crate::instance::content::updates::UpdateRequest { + title, + installed_path, + target_world: target_world.as_ref().map(|(_, path)| path.clone()), + update: update.clone(), + }); + } else if let Some(failure) = snapshot + .failures + .iter() + .find(|failure| failure.installed == *installed && failure.kind == kind) + { + conflicts.push(crate::instance::content::updates::UpdateConflict { + title, + installed_path, + reason: failure.reason.clone(), + }); + } + } + let plan = crate::instance::content::updates::plan_bulk( + &instance, + &manifest, + &paths.minecraft(), + requests, + conflicts, + ) + .await; + progress.finish(); + if let Ok(mut pending) = pending.lock() { + pending.push(widgets::content::update::PendingResult::Prepared( + snapshot, plan, + )); + crate::feedback::request_redraw(); + } + }); + } + + fn handle_content_update_key(&mut self, key_event: KeyEvent) { + let Some(state) = self.content_update_popup.as_mut() else { + return; + }; + match (state.phase, key_event.code) { + (widgets::content::update::Phase::Checking, KeyCode::Esc) + | (widgets::content::update::Phase::Review, KeyCode::Esc) + | (widgets::content::update::Phase::Conflicts, KeyCode::Esc) => { + self.content_update_popup = None; + } + (widgets::content::update::Phase::Conflicts, KeyCode::Char('h')) => { + self.content_update_popup = None; + } + (widgets::content::update::Phase::Conflicts, KeyCode::Enter) => { + if state.has_updates() { + state.show_review(); + } + } + (widgets::content::update::Phase::Review, KeyCode::Char('h')) => { + state.show_conflicts(); + } + (widgets::content::update::Phase::Review, KeyCode::Enter) => { + self.spawn_bulk_content_install(); + } + (_, KeyCode::Char('j') | KeyCode::Down | KeyCode::Char('k') | KeyCode::Up) => { + widgets::content::list::handle_key_no_toggle(&key_event, &mut state.list); + } + _ => {} + } + } + + fn spawn_bulk_content_install(&mut self) { + let Some(instance) = self.instances_state.selected_instance().cloned() else { + return; + }; + let Some(state) = self.content_update_popup.as_mut() else { + return; + }; + let Some(plan) = state.plan.as_ref().map(|plan| plan.dependency_plan.clone()) else { + return; + }; + if plan.items.is_empty() { + self.content_update_popup = None; + return; + } + state.phase = widgets::content::update::Phase::Applying; + let pending = state.pending.clone(); + let instances_dir = self.instance_manager.instances_dir.clone(); + let paths = crate::storage::InstancePaths::new(instances_dir.join(&instance.name)); + tokio::spawn(async move { + let progress = crate::feedback::progress::ProgressTask::start("Updating content"); + let registry = crate::instance::content::provider::ProviderRegistry::configured( + crate::net::HttpClient::new(), + ); + let result = crate::instance::content::dependencies::install( + ®istry, + &paths.content_manifest(), + &paths.minecraft(), + &plan, + ) + .await + .map(|_| ()) + .map_err(|error| error.to_string()); + if let Err(error) = &result { + progress.fail(error); + } else { + progress.finish(); + crate::instance::content::reconcile::spawn_after_change( + instance, + instances_dir, + crate::net::HttpClient::new(), + ); + } + if let Ok(mut pending) = pending.lock() { + pending.push(widgets::content::update::PendingResult::Applied(result)); + crate::feedback::request_redraw(); + } + }); + } + + fn spawn_installed_versions(&mut self) { + let Some(instance) = self.instances_state.selected_instance().cloned() else { + return; + }; + let (entry, kind, target_world) = match self.content_tab { + widgets::content::ContentTab::Mods => ( + self.mods_state.selected_entry().cloned(), + crate::instance::ContentKind::Mod, + None, + ), + widgets::content::ContentTab::ResourcePacks => ( + self.resource_packs_state.selected_entry().cloned(), + crate::instance::ContentKind::ResourcePack, + None, + ), + widgets::content::ContentTab::Shaders => ( + self.shaders_state.selected_entry().cloned(), + crate::instance::ContentKind::Shader, + None, + ), + widgets::content::ContentTab::Worlds => ( + self.world_datapacks_state.selected_entry().cloned(), + crate::instance::ContentKind::DataPack, + self.open_world_datapacks.clone(), + ), + _ => return, + }; + let Some(entry) = entry else { + return; + }; + let paths = crate::storage::InstancePaths::new( + self.instance_manager.instances_dir.join(&instance.name), + ); + let Ok(relative_path) = entry.path.strip_prefix(paths.minecraft()) else { + return; + }; + let Some(record) = self + .content_manifest + .as_ref() + .filter(|(name, _)| name == &instance.name) + .and_then(|(_, manifest)| manifest.record(relative_path)) + .cloned() + else { + return; + }; + let Some(state) = self.active_discovery_state_mut() else { + return; + }; + let Some(request) = state.begin_installed_versions(&entry, &record, target_world) else { + return; + }; + self.spawn_discovery_versions_request(instance, kind, request); + } + + fn spawn_active_discovery_versions(&mut self) { + let Some(instance) = self.instances_state.selected_instance().cloned() else { + return; + }; + let Some(state) = self.active_discovery_state_mut() else { + return; + }; + let kind = state.kind; + let Some(request) = state.begin_versions() else { + return; + }; + self.spawn_discovery_versions_request(instance, kind, request); + } + + fn spawn_active_discovery_version_source(&mut self) { + let Some(instance) = self.instances_state.selected_instance().cloned() else { + return; + }; + let Some(state) = self.active_discovery_state_mut() else { + return; + }; + let kind = state.kind; + let Some(request) = state.switch_version_source() else { + return; + }; + self.spawn_discovery_versions_request(instance, kind, request); + } + + fn spawn_active_discovery_dependencies(&mut self) { + let Some(instance) = self.instances_state.selected_instance().cloned() else { + return; + }; + let Some(request) = self + .active_discovery_state_mut() + .and_then(|state| state.begin_dependency_resolution()) + else { + return; + }; + let paths = crate::storage::InstancePaths::new( + self.instance_manager.instances_dir.join(&instance.name), + ); + tokio::spawn(async move { + let result = async { + let manifest = crate::instance::ContentManifest::load(&paths.content_manifest()) + .map_err(|error| crate::net::NetError::Parse(error.to_string()))?; + let registry = crate::instance::content::provider::ProviderRegistry::configured( + crate::net::HttpClient::new(), + ); + crate::instance::content::dependencies::resolve( + ®istry, + &manifest, + &paths.minecraft(), + &instance, + request.root, + ) + .await + } + .await + .map_err(|error| error.to_string()); + widgets::content::DiscoveryState::push_action_result( + &request.pending, + widgets::content::discovery::DiscoveryActionResult::Dependencies { + request_id: request.request_id, + project_id: request.project_id, + result, + }, + ); + }); + } + + fn spawn_discovery_versions_request( + &self, + instance: crate::instance::InstanceConfig, + kind: crate::instance::ContentKind, + request: widgets::content::discovery::VersionsRequest, + ) { + let version_cache = crate::storage::MetadataPaths::new(&self.instance_manager.meta_dir) + .provider_versions(&request.provider) + .join(&request.project_id) + .join(format!( + "{}-{}.json", + instance.game_version, + instance.loader.to_string().to_lowercase() + )); + tokio::spawn(async move { + let registry = crate::instance::content::provider::ProviderRegistry::configured( + crate::net::HttpClient::new(), + ); + let result = match registry.get(&request.provider) { + Some(provider) => match provider + .compatible_versions( + &request.project_id, + kind, + &instance.game_version, + instance.loader, + ) + .await + { + Ok(mut versions) => { + if let Some(current) = request.current_version_id.as_deref() + && !versions.iter().any(|version| version.id == current) + && let Ok(version) = provider.version(current).await + { + versions.push(version); + } + if let Ok(bytes) = serde_json::to_vec_pretty(&versions) { + let _ = crate::storage::write_atomic(&version_cache, &bytes); + } + Ok(versions) + } + Err(error) => match std::fs::read(&version_cache) + .ok() + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + { + Some(versions) => Ok(versions), + None => Err(error.to_string()), + }, + }, + None => Err(format!( + "{} content provider is unavailable", + request.provider + )), + }; + widgets::content::DiscoveryState::push_action_result( + &request.pending, + widgets::content::discovery::DiscoveryActionResult::Versions { + request_id: request.request_id, + project_id: request.project_id, + result, + }, + ); + }); + } + + fn spawn_active_discovery_project_page(&mut self) { + let Some(request) = self + .active_discovery_state_mut() + .and_then(widgets::content::DiscoveryState::begin_project_page) + else { + return; + }; + widgets::content::discovery::spawn_project_page(request); + } + + fn spawn_active_discovery_install(&mut self) { + let Some(instance) = self.instances_state.selected_instance().cloned() else { + return; + }; + let Some(state) = self.active_discovery_state_mut() else { + return; + }; + let kind = state.kind; + let Some(request) = state.begin_install() else { + return; + }; + let instances_dir = self.instance_manager.instances_dir.clone(); + let instance_paths = crate::storage::InstancePaths::new(instances_dir.join(&instance.name)); + let manifest_path = instance_paths.content_manifest(); + let minecraft_dir = instance_paths.minecraft(); + let destination = match (&request.target_world, kind) { + (Some((_, world)), crate::instance::ContentKind::DataPack) => world.join("datapacks"), + _ => minecraft_dir.join(kind.directory()), + }; + tokio::spawn(async move { + let action = if request.installed_path.is_some() { + format!("Changing {} version", request.project_title) + } else { + format!("Installing {}", request.project_title) + }; + let progress = crate::feedback::progress::ProgressTask::start(action); + progress.set_sub_action(&request.version.version_number); + let client = crate::net::HttpClient::new(); + let result = async { + if kind == crate::instance::ContentKind::DataPack + && (request.target_world.is_none() + || !destination.starts_with(minecraft_dir.join("saves"))) + { + return Err(crate::net::NetError::Parse( + "Invalid datapack target world".to_owned(), + )); + } + tokio::fs::create_dir_all(&destination) + .await + .map_err(crate::net::NetError::from)?; + let registry = + crate::instance::content::provider::ProviderRegistry::configured(client); + if let Some(plan) = &request.dependency_plan { + let installed = crate::instance::content::dependencies::install( + ®istry, + &manifest_path, + &minecraft_dir, + plan, + ) + .await?; + return Ok::<_, crate::net::NetError>( + widgets::content::discovery::InstallCompletion { + path: installed.root_path, + replaced: installed.replaced, + skipped: installed.skipped, + orphaned_dependencies: installed.orphaned_dependencies, + }, + ); + } + let provider = registry.get(&request.provider).ok_or_else(|| { + crate::net::NetError::Parse(format!( + "{} content provider is unavailable", + request.provider + )) + })?; + let outcome = provider + .download_version( + &request.version, + &destination, + request.installed_path.as_deref(), + ) + .await?; + let (path, skipped) = match outcome { + crate::net::modrinth::DownloadOutcome::Downloaded(path) => (path, false), + crate::net::modrinth::DownloadOutcome::SkippedExisting(path) => (path, true), + }; + let replaced = request.installed_path.is_some() && !skipped; + let relative_path = path + .strip_prefix(&minecraft_dir) + .map_err(|error| crate::net::NetError::Parse(error.to_string()))? + .to_path_buf(); + let fingerprint = crate::instance::content::manifest::fingerprint(&path)?; + let record = crate::instance::ContentFileRecord { + relative_path, + kind, + enabled: !path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(".disabled")), + fingerprint, + resolution: crate::instance::Resolution::Resolved { + project: crate::instance::ProviderProject { + provider: request.provider.clone(), + project_id: request.project_id.clone(), + version_id: request.version.id.clone(), + }, + }, + provider_aliases: Vec::new(), + provider_checks: vec![request.provider.clone()], + required_dependencies: Vec::new(), + automatic_dependency: false, + cleanup_eligible: false, + }; + crate::instance::ContentManifest::update(&manifest_path, |manifest| { + if let Some(old_path) = request.installed_path.as_ref() + && let Ok(relative) = old_path.strip_prefix(&minecraft_dir) + && old_path != &path + { + manifest.remove(relative); + } + manifest.upsert(record); + Ok(()) + }) + .map_err(|error| crate::net::NetError::Parse(error.to_string()))?; + if !skipped + && let Some(old_path) = request + .installed_path + .as_ref() + .filter(|old_path| old_path.as_path() != path.as_path()) + { + delete_content_path(old_path).map_err(crate::net::NetError::from)?; + } + Ok::<_, crate::net::NetError>(widgets::content::discovery::InstallCompletion { + path, + replaced, + skipped, + orphaned_dependencies: Vec::new(), + }) + } + .await + .map_err(|error| error.to_string()); + if let Err(error) = &result { + progress.fail(error); + } else { + progress.finish(); + crate::instance::content::reconcile::spawn_after_change( + instance, + instances_dir, + crate::net::HttpClient::new(), + ); + } + widgets::content::DiscoveryState::push_action_result( + &request.pending, + widgets::content::discovery::DiscoveryActionResult::Install { + request_id: request.request_id, + generation: request.generation, + project_id: request.project_id, + project_title: request.project_title, + result, + }, + ); + }); + } + + fn spawn_active_discovery_search(&mut self) { + let Some(instance) = self.instances_state.selected_instance().cloned() else { + return; + }; + let manifest = self + .content_manifest + .as_ref() + .filter(|(name, _)| name == &instance.name) + .map(|(_, manifest)| manifest.clone()); + let minecraft_dir = crate::storage::InstancePaths::new( + self.instance_manager.instances_dir.join(&instance.name), + ) + .minecraft(); + let meta_dir = self.instance_manager.meta_dir.clone(); + let Some(state) = self.active_discovery_state_mut() else { + return; + }; + if state.unavailable_message(&instance).is_some() { + state.set_unavailable(&instance); + return; + } + let kind = state.kind; + let query = state.search.query.clone(); + let request = state.begin_search(&instance); + Self::spawn_discovery_request( + instance, + kind, + query, + manifest, + minecraft_dir, + meta_dir, + request, + ); + } + + fn spawn_active_discovery_page(&mut self) { + let Some(instance) = self.instances_state.selected_instance().cloned() else { + return; + }; + let manifest = self + .content_manifest + .as_ref() + .filter(|(name, _)| name == &instance.name) + .map(|(_, manifest)| manifest.clone()); + let minecraft_dir = crate::storage::InstancePaths::new( + self.instance_manager.instances_dir.join(&instance.name), + ) + .minecraft(); + let meta_dir = self.instance_manager.meta_dir.clone(); + let Some(state) = self.active_discovery_state_mut() else { + return; + }; + if state.unavailable_message(&instance).is_some() { + state.set_unavailable(&instance); + return; + } + let kind = state.kind; + let query = state.search.query.clone(); + let Some(request) = state.begin_next_page() else { + return; + }; + Self::spawn_discovery_request( + instance, + kind, + query, + manifest, + minecraft_dir, + meta_dir, + request, + ); + } + + fn spawn_discovery_request( + instance: crate::instance::InstanceConfig, + kind: crate::instance::ContentKind, + query: String, + manifest: Option, + minecraft_dir: std::path::PathBuf, + meta_dir: std::path::PathBuf, + request: widgets::content::discovery::DiscoveryRequest, + ) { + widgets::content::discovery::spawn_provider_search( + query, + widgets::content::discovery::DiscoveryTarget::Content(Box::new( + widgets::content::discovery::ContentDiscoveryTarget { + instance, + kind, + manifest, + minecraft_dir, + }, + )), + meta_dir, + request, + ); + } + fn delete_config_profile(&mut self, profile: &str) -> color_eyre::Result<()> { let instances = self.instance_manager.load_all(); for instance in instances @@ -502,13 +1791,117 @@ impl App { Ok(()) } + fn confirm_content_delete(&mut self, pending: widgets::content::list::PendingContentDelete) { + let dependents = self + .content_manifest_for_path(&pending.path) + .map(|(manifest, relative_path, _)| { + manifest + .dependent_paths(&relative_path) + .into_iter() + .map(|path| { + path.file_stem() + .and_then(|name| name.to_str()) + .unwrap_or("mod") + .to_owned() + }) + .collect() + }) + .unwrap_or_default(); + confirm_popup::set_pending_managed_content_delete(pending.name, pending.path, dependents); + self.focused = FocusedArea::ConfirmDelete; + } + + fn orphan_dependencies_after_removing( + &self, + path: &std::path::Path, + ) -> Vec { + self.content_manifest_for_path(path) + .map(|(manifest, relative_path, minecraft_dir)| { + manifest + .orphaned_dependencies_after_removing(&relative_path) + .into_iter() + .map(|relative| minecraft_dir.join(relative)) + .collect() + }) + .unwrap_or_default() + } + + fn content_manifest_for_path( + &self, + path: &std::path::Path, + ) -> Option<( + crate::instance::ContentManifest, + std::path::PathBuf, + std::path::PathBuf, + )> { + let instance = self.instances_state.selected_instance()?; + let paths = crate::storage::InstancePaths::new( + self.instance_manager.instances_dir.join(&instance.name), + ); + let minecraft_dir = paths.minecraft(); + let relative_path = path.strip_prefix(&minecraft_dir).ok()?.to_owned(); + let manifest = crate::instance::ContentManifest::load(&paths.content_manifest()).ok()?; + Some((manifest, relative_path, minecraft_dir)) + } + fn remove_content_path_from_states(&mut self, path: &std::path::Path) { self.mods_state.remove_path(path); self.resource_packs_state.remove_path(path); self.shaders_state.remove_path(path); + self.world_datapacks_state.remove_path(path); self.worlds_state.remove_path(path); self.screenshots_state.remove_path(path); self.logs_state.remove_path(path); + self.mods_discovery_state.clear_installed_path(path); + self.resource_packs_discovery_state + .clear_installed_path(path); + self.shaders_discovery_state.clear_installed_path(path); + self.datapacks_discovery_state.clear_installed_path(path); + if path + .parent() + .and_then(std::path::Path::file_name) + .is_some_and(|name| name == "datapacks") + && let Some(world_path) = path.parent().and_then(std::path::Path::parent) + && let Some(world) = self + .worlds_state + .entries + .iter_mut() + .find(|world| world.path == world_path) + && let Some(details) = world.world_details.as_mut() + { + details.datapacks = crate::instance::content::worlds::datapack_names(world_path); + } + } + + fn remove_content_path_from_manifest(&mut self, path: &std::path::Path) { + let Some(instance) = self.instances_state.selected_instance() else { + return; + }; + let instance_paths = crate::storage::InstancePaths::new( + self.instance_manager.instances_dir.join(&instance.name), + ); + let minecraft_dir = instance_paths.minecraft(); + let Ok(relative_path) = path.strip_prefix(&minecraft_dir) else { + return; + }; + if let Some((instance_name, manifest)) = self.content_manifest.as_mut() + && *instance_name == instance.name + { + manifest.remove(relative_path); + } + if let Err(error) = crate::instance::ContentManifest::update( + &instance_paths.content_manifest(), + |manifest| { + manifest.remove(relative_path); + Ok(()) + }, + ) { + tracing::warn!( + "Failed to remove '{}' from the content manifest: {}", + relative_path.display(), + error + ); + } } } diff --git a/src/tui/logging.rs b/src/tui/logging.rs index 6e0d29b..02d806b 100644 --- a/src/tui/logging.rs +++ b/src/tui/logging.rs @@ -3,11 +3,12 @@ // also keeps an in-memory ring buffer of log lines for the log overlay viewer. use std::fmt; +use std::path::Path; use std::sync::{Arc, Mutex}; use tracing::field::{Field, Visit}; use tracing::{Level, Subscriber}; -use tracing_appender::non_blocking::WorkerGuard; +use tracing_appender::non_blocking::{NonBlocking, WorkerGuard}; use tracing_subscriber::filter::filter_fn; use tracing_subscriber::layer::Context; use tracing_subscriber::prelude::*; @@ -15,7 +16,7 @@ use tracing_subscriber::{EnvFilter, Layer}; use std::sync::LazyLock; -use crate::tui::error_buffer::{self, ErrorEvent}; +use crate::feedback::errors::{self as error_buffer, ErrorEvent}; const MINECRAFT_LOG_TARGET: &str = "mc_instance"; const DEFAULT_FILE_FILTER: &str = "warn,rmcl=trace"; @@ -47,20 +48,8 @@ pub fn init() -> WorkerGuard { Some(d) => d.join("rmcl"), None => std::path::PathBuf::from("./cache"), }; - match std::fs::create_dir_all(&log_dir) { - Ok(_) => {} - Err(e) => { - eprintln!( - "Warning: failed to create log directory {}: {}", - log_dir.display(), - e - ); - } - } - let now = chrono::Local::now().format("%Y-%m-%d_%H-%M-%S"); - let file_appender = tracing_appender::rolling::never(&log_dir, format!("rmcl_{now}.log")); - let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); + let (non_blocking, guard) = open_log_writer(&log_dir); let file_filter = EnvFilter::try_from_env("RUST_LOG").unwrap_or_else(|_| EnvFilter::new(DEFAULT_FILE_FILTER)); @@ -104,6 +93,28 @@ pub fn init() -> WorkerGuard { guard } +fn open_log_writer(log_dir: &Path) -> (NonBlocking, WorkerGuard) { + let now = chrono::Local::now().format("%Y-%m-%d_%H-%M-%S"); + let file = std::fs::create_dir_all(log_dir).and_then(|_| { + std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(log_dir.join(format!("rmcl_{now}.log"))) + }); + + match file { + Ok(file) => tracing_appender::non_blocking(file), + Err(error) => { + eprintln!( + "Warning: failed to open log file in {}: {}", + log_dir.display(), + error + ); + tracing_appender::non_blocking(std::io::sink()) + } + } +} + // custom tracing layer that intercepts all log events: // - everything gets appended to the in-memory log buffer for the overlay viewer // - WARN and ERROR additionally get pushed as error toasts @@ -150,10 +161,19 @@ fn should_record_app_log(target: &str, level: Level) -> bool { if target == MINECRAFT_LOG_TARGET { return false; } + if is_svg_renderer_target(target) { + return false; + } target == "rmcl" || target.starts_with("rmcl::") || level <= Level::WARN } +fn is_svg_renderer_target(target: &str) -> bool { + ["usvg", "resvg", "tiny_skia", "fontdb"] + .iter() + .any(|renderer| target == *renderer || target.starts_with(&format!("{renderer}::"))) +} + #[derive(Default)] struct MessageVisitor { message: String, @@ -181,29 +201,5 @@ impl Visit for MessageVisitor { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn minecraft_events_are_not_general_app_logs() { - assert!(!should_record_app_log(MINECRAFT_LOG_TARGET, Level::ERROR)); - } - - #[test] - fn rmcl_events_are_general_app_logs() { - assert!(should_record_app_log( - "rmcl::instance::manager", - Level::TRACE - )); - } - - #[test] - fn dependency_debug_events_are_not_general_app_logs() { - assert!(!should_record_app_log("log", Level::DEBUG)); - } - - #[test] - fn dependency_warnings_are_general_app_logs() { - assert!(should_record_app_log("notify", Level::WARN)); - } -} +#[path = "tests/logging.rs"] +mod tests; diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 109f198..644a0a8 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1,25 +1,16 @@ // tui entrypoint: sets up the terminal, runs the app, cleans up on exit. pub mod app; -pub mod error_buffer; mod event; mod input; pub mod logging; -pub mod progress; mod render; pub mod widgets; -use std::sync::atomic::{AtomicBool, Ordering}; +use crate::feedback::request_redraw; -static REDRAW_REQUESTED: AtomicBool = AtomicBool::new(true); - -pub fn request_redraw() { - REDRAW_REQUESTED.store(true, Ordering::Release); -} - -pub(super) fn take_redraw_request() -> bool { - REDRAW_REQUESTED.swap(false, Ordering::AcqRel) -} +#[cfg(test)] +pub(crate) mod tests; pub type Tui = ratatui::DefaultTerminal; @@ -30,6 +21,7 @@ pub async fn show() -> color_eyre::Result<()> { std::panic::set_hook(Box::new(move |info| { let _ = crossterm::execute!( std::io::stdout(), + crossterm::event::DisableMouseCapture, crossterm::event::PopKeyboardEnhancementFlags ); ratatui::restore(); @@ -41,43 +33,402 @@ pub async fn show() -> color_eyre::Result<()> { // opt into enhanced keyboard protocol to distinguish key press vs release let _ = crossterm::execute!( std::io::stdout(), + crossterm::event::EnableMouseCapture, crossterm::event::PushKeyboardEnhancementFlags( crossterm::event::KeyboardEnhancementFlags::REPORT_EVENT_TYPES | crossterm::event::KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES ) ); - // figure out the terminal's font cell size for rendering images. - // falls back to halfblock characters if the terminal doesn't respond - let mut picker = ratatui_image::picker::Picker::from_query_stdio() - .unwrap_or_else(|_| ratatui_image::picker::Picker::halfblocks()); - let detected_protocol = picker.protocol_type(); - let requested_protocol = match crate::config::SETTINGS.ui.image_protocol { - crate::config::settings::ImageProtocol::Halfblocks - | crate::config::settings::ImageProtocol::Quadrants => { - ratatui_image::picker::ProtocolType::Halfblocks - } - crate::config::settings::ImageProtocol::Kitty - if detected_protocol == ratatui_image::picker::ProtocolType::Kitty => - { - ratatui_image::picker::ProtocolType::Kitty - } - crate::config::settings::ImageProtocol::Iterm2 - if detected_protocol == ratatui_image::picker::ProtocolType::Iterm2 => - { - ratatui_image::picker::ProtocolType::Iterm2 - } - _ => ratatui_image::picker::ProtocolType::Halfblocks, - }; - picker.set_protocol_type(requested_protocol); + let result = async { + // figure out the terminal's font cell size for rendering images. + // falls back to halfblock characters if the terminal doesn't respond + let mut picker = ratatui_image::picker::Picker::from_query_stdio() + .unwrap_or_else(|_| ratatui_image::picker::Picker::halfblocks()); + let detected_protocol = picker.protocol_type(); + let requested_protocol = match crate::config::SETTINGS.ui.image_protocol { + crate::config::settings::ImageProtocol::Halfblocks + | crate::config::settings::ImageProtocol::Quadrants => { + ratatui_image::picker::ProtocolType::Halfblocks + } + crate::config::settings::ImageProtocol::Kitty + if detected_protocol == ratatui_image::picker::ProtocolType::Kitty => + { + ratatui_image::picker::ProtocolType::Kitty + } + crate::config::settings::ImageProtocol::Iterm2 + if detected_protocol == ratatui_image::picker::ProtocolType::Iterm2 => + { + ratatui_image::picker::ProtocolType::Iterm2 + } + _ => ratatui_image::picker::ProtocolType::Halfblocks, + }; + picker.set_protocol_type(requested_protocol); - let result = app::App::new(picker).run(&mut terminal).await; + let mut app = app::App::new(picker); + match run_layout_migration_screen(&mut terminal, &mut app).await? { + MigrationScreenOutcome::NotNeeded => {} + MigrationScreenOutcome::Migrated => { + // the modal uses pre-migration state as its background. rebuild the + // app after confirmation so no instance or profile data stays stale + let picker = app.into_picker(); + app = app::App::new(picker); + } + MigrationScreenOutcome::Quit => return Ok(()), + } + app.run(&mut terminal).await + } + .await; let _ = crossterm::execute!( std::io::stdout(), + crossterm::event::DisableMouseCapture, crossterm::event::PopKeyboardEnhancementFlags ); ratatui::restore(); result } + +enum MigrationScreenOutcome { + NotNeeded, + Migrated, + Quit, +} + +async fn run_layout_migration_screen( + terminal: &mut Tui, + app: &mut app::App, +) -> color_eyre::Result { + use std::sync::{Arc, Mutex}; + use std::time::Duration; + + let instances_dir = crate::config::SETTINGS.paths.resolve_instances_dir(); + let meta_dir = crate::config::SETTINGS.paths.resolve_meta_dir(); + if !crate::layout_migration::is_needed(&instances_dir, &meta_dir) { + crate::layout_migration::initialize_new_layout(&meta_dir)?; + return Ok(MigrationScreenOutcome::NotNeeded); + } + let config = crate::config::get_config_path().join("config.toml"); + + loop { + let progress = Arc::new(Mutex::new(crate::layout_migration::MigrationProgress { + phase: "Preparing migration".to_owned(), + item: "Inventorying launcher data".to_owned(), + current: 0, + total: 1, + item_current: None, + item_total: None, + backup_dir: None, + })); + let task_progress = progress.clone(); + let task_instances = instances_dir.clone(); + let task_meta = meta_dir.clone(); + let task_config = config.clone(); + let task = tokio::task::spawn_blocking(move || { + crate::layout_migration::run(&task_instances, &task_meta, &task_config, |update| { + if let Ok(mut current) = task_progress.lock() { + *current = update; + } + request_redraw(); + }) + }); + + while !task.is_finished() { + let current = progress.lock().ok().map(|state| state.clone()); + terminal.draw(|frame| { + app.render_migration_frame(frame); + if let Some(current) = ¤t { + let item_fraction = match (current.item_current, current.item_total) { + (Some(value), Some(total)) if total > 0 => { + value.min(total) as f64 / total as f64 + } + _ => 0.0, + }; + let ratio = if current.total == 0 { + 0.0 + } else { + ((current.current as f64 + item_fraction) / current.total as f64) + .clamp(0.0, 1.0) + }; + render_migration_progress_popup( + frame, + ratio, + current.phase.clone(), + current.item.clone(), + ); + } + })?; + tokio::time::sleep(Duration::from_millis(50)).await; + } + + match task.await? { + Ok(backup) => { + tracing::info!("Layout migration complete; backup at {}", backup.display()); + if crate::layout_migration::cache_rebuild_pending(&meta_dir) { + if let Err(error) = + rebuild_runtime_cache_screen(terminal, app, &instances_dir, &meta_dir).await + { + if migration_retry_requested(terminal, app, &error).await? { + continue; + } + return Ok(MigrationScreenOutcome::Quit); + } + crate::layout_migration::finish_cache_rebuild(&meta_dir)?; + } + migration_completion_confirmation(terminal, app, &backup).await?; + return Ok(MigrationScreenOutcome::Migrated); + } + Err(error) => { + if !migration_retry_requested(terminal, app, &error.to_string()).await? { + return Ok(MigrationScreenOutcome::Quit); + } + } + } + } +} + +async fn rebuild_runtime_cache_screen( + terminal: &mut Tui, + app: &mut app::App, + instances_dir: &std::path::Path, + meta_dir: &std::path::Path, +) -> Result<(), String> { + use std::sync::{Arc, Mutex}; + use std::time::Duration; + + let manager = crate::instance::InstanceManager::new(instances_dir, meta_dir); + let instances = manager.load_all(); + let instance_total = instances.len() as u64; + let instance_progress = Arc::new(Mutex::new((0_u64, String::new()))); + let task_progress = instance_progress.clone(); + let task = tokio::spawn(async move { + for (index, instance) in instances.iter().enumerate() { + if let Ok(mut state) = task_progress.lock() { + state.0 = index as u64; + state.1 = instance.name.clone(); + } + request_redraw(); + manager + .repair_runtime_cache(instance) + .await + .map_err(|error| error.to_string())?; + } + if let Ok(mut state) = task_progress.lock() { + state.0 = instance_total; + state.1.clear(); + } + Ok::<(), String>(()) + }); + + while !task.is_finished() { + let progress = crate::feedback::progress::PROGRESS + .lock() + .ok() + .map(|progress| progress.clone()) + .unwrap_or_default(); + let (instance_current, instance_name) = instance_progress + .lock() + .ok() + .map(|state| state.clone()) + .unwrap_or_default(); + terminal + .draw(|frame| { + app.render_migration_frame(frame); + let action = progress + .current_action + .as_deref() + .unwrap_or("Checking cached runtime files"); + let detail = progress.sub_action.as_deref().unwrap_or(""); + let (current, total) = progress.progress.unwrap_or((0, 0)); + let instance_ratio = if instance_total == 0 { + 1.0 + } else { + instance_current.min(instance_total) as f64 / instance_total as f64 + }; + let ratio = if total == 0 { + instance_ratio + } else { + current.min(total) as f64 / total as f64 + }; + let action = if instance_name.is_empty() { + action.to_owned() + } else { + format!("{action} — {instance_name}") + }; + render_migration_progress_popup(frame, ratio, action, detail.to_owned()); + }) + .map_err(|error| error.to_string())?; + tokio::time::sleep(Duration::from_millis(50)).await; + } + task.await.map_err(|error| error.to_string())? +} + +async fn migration_retry_requested( + terminal: &mut Tui, + app: &mut app::App, + error: &str, +) -> color_eyre::Result { + use crate::config::theme::{BORDER_STYLE, THEME}; + use crossterm::event::{Event, KeyCode}; + use ratatui::layout::{Constraint, Direction, Layout}; + use ratatui::style::Style; + use ratatui::widgets::{Block, Paragraph, Wrap}; + use std::time::Duration; + + loop { + terminal.draw(|frame| { + app.render_migration_frame(frame); + let theme = THEME.as_ref(); + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Fill(1), + Constraint::Length(9), + Constraint::Fill(1), + ]) + .split(frame.area()); + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(12), + Constraint::Percentage(76), + Constraint::Percentage(12), + ]) + .split(rows[1]); + frame.render_widget( + Paragraph::new(format!( + "Migration stopped safely:\n\n{error}\n\n[r] retry [q] quit" + )) + .block( + Block::bordered() + .title(" Migration needs attention ") + .border_type(BORDER_STYLE.to_border_type()) + .border_style(Style::default().fg(theme.error())) + .style(Style::default().fg(theme.text()).bg(theme.surface())), + ) + .wrap(Wrap { trim: false }), + columns[1], + ); + })?; + if crossterm::event::poll(Duration::from_millis(100))? + && let Event::Key(key) = crossterm::event::read()? + { + match key.code { + KeyCode::Char('r') => return Ok(true), + KeyCode::Char('q') | KeyCode::Esc => return Ok(false), + _ => {} + } + } + } +} + +async fn migration_completion_confirmation( + terminal: &mut Tui, + app: &mut app::App, + backup: &std::path::Path, +) -> color_eyre::Result<()> { + use crate::config::theme::THEME; + use crate::tui::widgets::popups::{base::PopupFrame, keybind_line}; + use crossterm::event::{Event, KeyCode}; + use ratatui::style::{Modifier, Style}; + use ratatui::text::Line; + use ratatui::widgets::{Paragraph, Widget, Wrap}; + use std::time::Duration; + + loop { + terminal.draw(|frame| { + app.render_migration_frame(frame); + let theme = THEME.as_ref(); + let backup = backup.display().to_string(); + frame.render_widget( + PopupFrame { + title: Line::from(" Migration finished ").style( + Style::default() + .fg(theme.success()) + .add_modifier(Modifier::BOLD), + ), + border_color: theme.success(), + bg: Some(theme.surface()), + keybinds: Some(keybind_line(&[("Enter", " continue")])), + search_line: None, + content: Box::new(move |area, buffer| { + Paragraph::new(format!("Backup:\n{backup}")) + .style(Style::default().fg(theme.text())) + .wrap(Wrap { trim: false }) + .render(area, buffer); + }), + }, + migration_popup_area(frame.area(), 6), + ); + })?; + if crossterm::event::poll(Duration::from_millis(100))? + && let Event::Key(key) = crossterm::event::read()? + && key.code == KeyCode::Enter + { + return Ok(()); + } + } +} + +fn render_migration_progress_popup( + frame: &mut ratatui::Frame, + ratio: f64, + action: String, + detail: String, +) { + use crate::config::theme::THEME; + use crate::tui::widgets::popups::base::PopupFrame; + use ratatui::layout::{Constraint, Layout}; + use ratatui::style::{Modifier, Style}; + use ratatui::text::Line; + use ratatui::widgets::{Gauge, Paragraph, Widget}; + + let theme = THEME.as_ref(); + frame.render_widget( + PopupFrame { + title: Line::from(" Migration ").style( + Style::default() + .fg(theme.text_dim()) + .add_modifier(Modifier::BOLD), + ), + border_color: theme.accent(), + bg: Some(theme.surface()), + keybinds: None, + search_line: None, + content: Box::new(move |area, buffer| { + let rows = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(area); + Gauge::default() + .gauge_style( + Style::default() + .fg(theme.success()) + .bg(theme.surface()) + .add_modifier(Modifier::BOLD), + ) + .percent((ratio.clamp(0.0, 1.0) * 100.0) as u16) + .render(rows[0], buffer); + Paragraph::new(action.as_str()) + .style(Style::default().fg(theme.text())) + .render(rows[1], buffer); + Paragraph::new(detail.as_str()) + .style(Style::default().fg(theme.text_dim())) + .render(rows[2], buffer); + }), + }, + migration_popup_area(frame.area(), 5), + ); +} + +fn migration_popup_area(area: ratatui::layout::Rect, height: u16) -> ratatui::layout::Rect { + use ratatui::layout::Constraint; + + area.centered( + Constraint::Length(area.width.saturating_sub(4).min(72)), + Constraint::Length(area.height.saturating_sub(2).min(height)), + ) +} diff --git a/src/tui/progress.rs b/src/tui/progress.rs deleted file mode 100644 index 9679dfb..0000000 --- a/src/tui/progress.rs +++ /dev/null @@ -1,75 +0,0 @@ -// global progress state shared between background tasks and the status bar widget. -// background tasks set the action/progress, the render loop reads it every frame. - -use std::sync::LazyLock; -use std::sync::{Arc, Mutex}; - -#[derive(Debug, Default, Clone)] -pub struct ProgressState { - pub current_action: Option, - pub progress: Option<(u64, u64)>, - pub sub_action: Option, -} - -pub static PROGRESS: LazyLock>> = - LazyLock::new(|| Arc::new(Mutex::new(ProgressState::default()))); - -pub fn set_action(text: impl Into) { - let text = text.into(); - match PROGRESS.lock() { - Ok(mut state) => { - state.current_action = Some(text.clone()); - crate::tui::request_redraw(); - } - Err(e) => { - tracing::error!("Progress lock poisoned: {}", e); - } - } - tracing::info!("{}", text); -} - -pub fn set_progress(current: u64, total: u64) { - match PROGRESS.lock() { - Ok(mut state) => { - state.progress = Some((current, total)); - crate::tui::request_redraw(); - } - Err(e) => { - tracing::error!("Progress lock poisoned: {}", e); - } - } -} - -pub fn set_sub_action(text: impl Into) { - let text = text.into(); - match PROGRESS.lock() { - Ok(mut state) => { - state.sub_action = Some(text.clone()); - crate::tui::request_redraw(); - } - Err(e) => { - tracing::error!("Progress lock poisoned: {}", e); - } - } - tracing::debug!(" {}", text); -} - -pub fn clear() { - match PROGRESS.lock() { - Ok(mut state) => { - state.current_action = None; - state.progress = None; - state.sub_action = None; - crate::tui::request_redraw(); - } - Err(e) => { - tracing::error!("Progress lock poisoned: {}", e); - } - } -} - -pub fn is_active() -> bool { - PROGRESS - .lock() - .is_ok_and(|state| state.current_action.is_some()) -} diff --git a/src/tui/render.rs b/src/tui/render.rs index 0836172..e200a5b 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -13,12 +13,20 @@ use super::app::{App, ErrorEffectState, FocusedArea}; use super::widgets::{ self, popups::confirm as confirm_popup, popups::import_modpack, popups::new_instance, }; -use crate::tui::error_buffer; +use crate::feedback::errors as error_buffer; use crate::tui::widgets::popups::confirm::{ConfirmPopup, confirm_popup_area}; use crate::tui::widgets::popups::error::{ErrorPopup, popup_area}; impl App { pub(super) fn render_frame(&mut self, frame: &mut Frame) { + self.render_frame_with_content(frame, true); + } + + pub(super) fn render_migration_frame(&mut self, frame: &mut Frame) { + self.render_frame_with_content(frame, false); + } + + fn render_frame_with_content(&mut self, frame: &mut Frame, load_content: bool) { use crate::config::theme::THEME; use ratatui::style::Style; use ratatui::widgets::Block; @@ -52,21 +60,58 @@ impl App { self.instances_state.selected_instance(), &mut self.throbber_state, ); - widgets::content::render( - frame, - main_chunks[1], - self.focused, - self.content_tab, - self.instances_state.selected_instance(), - &mut self.mods_state, - &mut self.resource_packs_state, - &mut self.shaders_state, - &mut self.worlds_state, - &mut self.screenshots_state, - &mut self.logs_state, - &self.instance_manager.instances_dir, - &self.picker, - ); + let world_quick_play_supported = self.selected_instance_supports_quick_play(); + if let Some((_, world)) = self.open_world_datapacks.as_ref() { + let valid = self + .instances_state + .selected_instance() + .is_some_and(|instance| { + world.starts_with( + crate::storage::InstancePaths::new( + self.instance_manager.instances_dir.join(&instance.name), + ) + .minecraft() + .join("saves"), + ) + }); + if !valid { + self.open_world_datapacks = None; + } + } + if load_content { + let has_modpack_update = self.instances_state.selected_modpack_update().is_some(); + widgets::content::render( + frame, + main_chunks[1], + self.focused, + self.content_tab, + self.content_mode, + self.instances_state.selected_instance(), + has_modpack_update, + &mut self.mods_state, + &mut self.mods_discovery_state, + &mut self.resource_packs_state, + &mut self.resource_packs_discovery_state, + &mut self.shaders_state, + &mut self.shaders_discovery_state, + &mut self.datapacks_discovery_state, + &mut self.worlds_state, + &mut self.world_datapacks_state, + self.open_world_datapacks.as_ref(), + &mut self.screenshots_state, + &mut self.logs_state, + &self.instance_manager.instances_dir, + &self.picker, + world_quick_play_supported, + ); + } else { + frame.render_widget( + Block::bordered() + .border_type(crate::config::theme::BORDER_STYLE.to_border_type()) + .border_style(Style::default().fg(theme.border())), + main_chunks[1], + ); + } let bottom_chunks = Layout::default() .direction(Direction::Horizontal) @@ -122,7 +167,7 @@ impl App { if self.instances_state.show_import_popup { let area = import_modpack::popup_rect(frame.area()); - import_modpack::render(frame, area, self.focused); + import_modpack::render_with_picker(frame, area, self.focused, &self.picker); } if self.focused == FocusedArea::ConfirmDelete @@ -131,6 +176,27 @@ impl App { let area = confirm_popup_area(frame.area(), &target); frame.render_widget(ConfirmPopup::for_target(&target), area); } + + if let Some(conflict) = &self.provider_conflict { + render_provider_conflict(frame, conflict); + } + + if let Some(update) = self.content_update_popup.as_mut() { + widgets::content::update::render(frame, update, &self.picker); + } + + if let Some(state) = self.modpack_versions_state.as_mut() { + widgets::content::tabs::render_version_popup( + frame, + main_chunks[1], + state, + &self.picker, + ); + } + + if let Some(update) = &self.modpack_update_popup { + widgets::popups::modpack_update::render(frame, update); + } } // full-screen log viewer with search highlighting and auto-scroll. @@ -181,7 +247,8 @@ impl App { .alignment(Alignment::Right), ) .border_type(BORDER_STYLE.to_border_type()) - .border_style(Style::default().fg(theme.accent())); + .border_style(Style::default().fg(theme.accent())) + .style(Style::default().bg(theme.background())); if let Some(sl) = self.log_overlay_search.title_line() { block = block.title_top(sl); @@ -327,3 +394,55 @@ impl App { } } } + +fn render_provider_conflict(frame: &mut Frame, conflict: &super::app::ProviderConflictState) { + use crate::config::theme::{BORDER_STYLE, THEME}; + use ratatui::{ + layout::{Constraint, Flex, Layout}, + style::{Modifier, Style}, + text::Line, + widgets::{Block, Borders, Clear, List, ListItem, ListState}, + }; + + let theme = THEME.as_ref(); + let [area] = Layout::vertical([Constraint::Length( + (conflict.candidates.len() as u16 + 4).min(frame.area().height.saturating_sub(2)), + )]) + .flex(Flex::Center) + .areas(frame.area()); + let [area] = Layout::horizontal([Constraint::Percentage(58)]) + .flex(Flex::Center) + .areas(area); + frame.render_widget(Clear, area); + + let title = conflict + .relative_path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("content file"); + let items = conflict.candidates.iter().map(|candidate| { + ListItem::new(Line::from(format!( + "{} {} {}", + candidate.provider, candidate.project_id, candidate.version_id + ))) + }); + let mut state = ListState::default().with_selected(Some(conflict.selected)); + let list = List::new(items) + .block( + Block::default() + .title(Line::from(format!(" Choose provider for {title} "))) + .title_bottom(Line::from(" [j/k] select [Enter] use [Esc] later ")) + .borders(Borders::ALL) + .border_type(BORDER_STYLE.to_border_type()) + .border_style(Style::default().fg(theme.accent())), + ) + .style(Style::default().fg(theme.text()).bg(theme.surface())) + .highlight_style( + Style::default() + .fg(theme.accent()) + .bg(theme.stripe()) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("▌"); + frame.render_stateful_widget(list, area, &mut state); +} diff --git a/src/tui/tests/event.rs b/src/tui/tests/event.rs new file mode 100644 index 0000000..76988ef --- /dev/null +++ b/src/tui/tests/event.rs @@ -0,0 +1,117 @@ +use super::*; +use crate::tui::tests::harness::UiHarness; + +#[test] +fn edited_instance_config_reloads_into_the_ui() { + let mut ui = UiHarness::new(); + ui.add_instance("Edited"); + let mut config = ui.app.instances_state.selected_instance().unwrap().clone(); + config.memory_max = Some("8G".to_owned()); + ui.app.instance_manager.save(&config).unwrap(); + + ui.app + .reload_edited_config(&ui.instance_path("Edited").join("instance.json")); + + assert_eq!( + ui.app + .instances_state + .selected_instance() + .unwrap() + .memory_max + .as_deref(), + Some("8G") + ); +} + +#[test] +fn completed_background_instance_is_drained_into_the_ui() { + let mut ui = UiHarness::new(); + ui.add_instance("Existing"); + ui.add_instance("Pending"); + let mut config = ui.app.instances_state.instances.pop().unwrap(); + ui.app.mods_state.loaded_for = Some("Pending".to_owned()); + ui.app.reconciliation_for = Some(("Pending".to_owned(), config.created)); + ui.app.content_manifest = Some(( + "Pending".to_owned(), + crate::instance::ContentManifest::default(), + )); + config.created += chrono::TimeDelta::seconds(1); + ui.app.instances_state.list_state.selected = Some(0); + PENDING_INSTANCES.lock().unwrap().push(config); + + ui.app.drain_pending_instances(); + + assert_eq!( + ui.app.instances_state.selected_instance().unwrap().name, + "Pending" + ); + assert!(PENDING_INSTANCES.lock().unwrap().is_empty()); + assert!(ui.app.reconciliation_for.is_none()); + assert!(ui.app.content_manifest.is_none()); + assert!(ui.app.mods_state.loaded_for.is_none()); + + ui.draw(); + assert_eq!(ui.app.mods_state.loaded_for.as_deref(), Some("Pending")); +} + +#[test] +fn editor_kind_is_detected_from_the_executable_name() { + assert!(editor_runs_in_terminal("/usr/bin/nvim")); + assert!(editor_runs_in_terminal("nano")); + assert!(!editor_runs_in_terminal("/usr/bin/code")); +} + +#[test] +fn overlay_count_tracks_independent_popup_layers() { + let mut ui = UiHarness::new(); + assert_eq!(ui.app.overlay_count(), 0); + + ui.app.instances_state.show_import_popup = true; + ui.app.account_state.add_mode = widgets::account::AddMode::ChooseType; + assert_eq!(ui.app.overlay_count(), 2); + + ui.app.account_state.add_mode = widgets::account::AddMode::None; + assert_eq!(ui.app.overlay_count(), 1); +} + +#[test] +fn terminal_image_markers_exclude_normal_text_and_toggle() { + use std::num::NonZeroU16; + + let mut buffer = ratatui::buffer::Buffer::empty(ratatui::layout::Rect::new(4, 7, 3, 1)); + buffer[(4, 7)] + .set_symbol("\x1b_Gimage\x1b\\") + .set_diff_option(ratatui::buffer::CellDiffOption::ForcedWidth( + NonZeroU16::new(1).unwrap(), + )); + buffer[(5, 7)].set_symbol("text").set_diff_option( + ratatui::buffer::CellDiffOption::ForcedWidth(NonZeroU16::new(1).unwrap()), + ); + buffer[(6, 7)].set_symbol("\x1b[0m"); + + mark_terminal_images(&mut buffer, false); + assert_eq!(buffer[(4, 7)].symbol(), "\x1b_Gimage\x1b\\\u{200b}"); + assert_eq!(buffer[(5, 7)].symbol(), "text"); + assert_eq!(buffer[(6, 7)].symbol(), "\x1b[0m"); + + buffer[(4, 7)].set_symbol("\x1b_Gimage\x1b\\"); + mark_terminal_images(&mut buffer, true); + assert_eq!(buffer[(4, 7)].symbol(), "\x1b_Gimage\x1b\\\u{200b}\u{200b}"); +} + +#[test] +fn terminal_image_cells_change_when_an_overlay_opens_or_closes() { + assert!(terminal_image_cells_changed( + &[true, false, false], + &[true, true, false] + )); + assert!(terminal_image_cells_changed( + &[true, true, false], + &[true, false, false] + )); + assert!(!terminal_image_cells_changed(&[], &[true])); + assert!(!terminal_image_cells_changed( + &[true, false, true], + &[true, false, true], + )); +} diff --git a/src/tui/tests/flows.rs b/src/tui/tests/flows.rs new file mode 100644 index 0000000..0b99afb --- /dev/null +++ b/src/tui/tests/flows.rs @@ -0,0 +1,714 @@ +use crossterm::event::{KeyCode, MouseEventKind}; +use std::path::{Path, PathBuf}; + +use super::harness::UiHarness; +use crate::instance::content::entry::ContentEntry; +use crate::instance::{ + ContentFileRecord, ContentKind, ContentManifest, FileFingerprint, ProviderProject, Resolution, +}; +use crate::net::modrinth::DiscoveryProject; +use crate::tui::{ + app::{FocusedArea, ProviderConflictState}, + widgets::{ + content::{ContentMode, ContentTab}, + popups::confirm, + }, +}; + +#[test] +fn global_navigation_returns_from_log_overlay() { + let mut ui = UiHarness::new(); + + ui.key(KeyCode::Char('C')); + assert_eq!(ui.app.focused, FocusedArea::Content); + + ui.key(KeyCode::Char('O')); + assert_eq!(ui.app.focused, FocusedArea::OverviewExpanded); + + ui.key(KeyCode::Esc); + assert_eq!(ui.app.focused, FocusedArea::Content); + + ui.key(KeyCode::Char('q')); + assert!(ui.app.exit); +} + +#[test] +fn escape_returns_through_nested_sections() { + let mut ui = UiHarness::new(); + + ui.app.focused = FocusedArea::Content; + ui.app.mods_state.search.activate(); + ui.key(KeyCode::Esc); + assert_eq!(ui.app.focused, FocusedArea::Content); + ui.key(KeyCode::Esc); + assert_eq!(ui.app.focused, FocusedArea::Instances); + + ui.app.focused = FocusedArea::Account; + ui.key(KeyCode::Char('a')); + ui.key(KeyCode::Esc); + assert_eq!(ui.app.focused, FocusedArea::Account); + ui.key(KeyCode::Esc); + assert_eq!(ui.app.focused, FocusedArea::Instances); + + ui.app.focused = FocusedArea::Settings; + ui.key(KeyCode::Char('a')); + ui.key(KeyCode::Esc); + assert_eq!(ui.app.focused, FocusedArea::Settings); + ui.key(KeyCode::Esc); + assert_eq!(ui.app.focused, FocusedArea::Instances); + + ui.app.focused = FocusedArea::Content; + ui.app.content_tab = ContentTab::Worlds; + ui.app.open_world_datapacks = Some(("World".to_owned(), PathBuf::from("World"))); + ui.key(KeyCode::Esc); + + assert_eq!(ui.app.focused, FocusedArea::Content); + assert!(ui.app.open_world_datapacks.is_none()); +} + +#[test] +fn installed_version_action_requires_selected_provider_match() { + let mut ui = UiHarness::new(); + ui.add_instance("Unmatched"); + ui.app.focused = FocusedArea::Content; + ui.app.content_tab = ContentTab::Mods; + let path = ui + .instance_path("Unmatched") + .join(crate::storage::MINECRAFT_DIR_NAME) + .join("mods/unknown.jar"); + ui.app.mods_state.entries = vec![content_entry("Unknown mod", path)]; + ui.app.mods_state.list_state.selected = Some(0); + let record = managed_mod_record("mods/unknown.jar", "known", false, Vec::new()); + let project = record.resolved_project().unwrap().clone(); + ui.app.content_manifest = Some(( + "Unmatched".to_owned(), + ContentManifest { + files: vec![record], + ..Default::default() + }, + )); + + ui.key(KeyCode::Char('v')); + assert!(ui.app.mods_discovery_state.version_popup.is_none()); + + ui.app.mods_state.entries[0].provider_project = Some(project); + ui.key(KeyCode::Char('v')); + assert!(ui.app.mods_discovery_state.version_popup.is_some()); +} + +#[test] +fn installed_version_hint_requires_selected_provider_match() { + let mut ui = UiHarness::new(); + ui.app.focused = FocusedArea::Content; + ui.app.content_tab = ContentTab::Mods; + ui.app.mods_state.entries = vec![content_entry( + "Unknown mod", + PathBuf::from("mods/unknown.jar"), + )]; + ui.app.mods_state.list_state.selected = Some(0); + + ui.draw(); + assert!(!ui.screen().contains("[v] versions")); + + ui.app.mods_state.entries[0].provider_project = Some(ProviderProject { + provider: "modrinth".to_owned(), + project_id: "known".to_owned(), + version_id: "known-version".to_owned(), + }); + ui.draw(); + assert!(ui.screen().contains("[v] versions")); +} + +#[test] +fn managed_modpack_instance_exposes_direct_version_selector() { + let mut ui = UiHarness::new(); + ui.add_instance("Managed Pack"); + + ui.draw(); + assert!(!ui.screen().contains("[v] versions")); + ui.app.instances_state.instances[0].modpack_source = Some(ProviderProject { + provider: "unsupported".to_owned(), + project_id: "pack".to_owned(), + version_id: "current".to_owned(), + }); + + ui.draw(); + assert!(ui.screen().contains("[v] versions")); + ui.key(KeyCode::Char('v')); + let popup = ui + .app + .modpack_versions_state + .as_ref() + .and_then(|state| state.version_popup.as_ref()) + .unwrap(); + assert!(!popup.selecting_minecraft_version); + assert_eq!(popup.current_version_id.as_deref(), Some("current")); + + ui.key(KeyCode::Esc); + assert!(ui.app.modpack_versions_state.is_none()); + + ui.key(KeyCode::Char('v')); + let popup = ui + .app + .modpack_versions_state + .as_mut() + .and_then(|state| state.version_popup.as_mut()) + .unwrap(); + popup.loading = false; + popup.versions = vec![crate::net::modrinth::VersionInfo { + id: "current".to_owned(), + project_id: "pack".to_owned(), + name: "Current".to_owned(), + version_number: "1.0".to_owned(), + game_versions: vec!["1.21.1".to_owned()], + loaders: Vec::new(), + version_type: crate::net::modrinth::VersionType::Release, + dependencies: Vec::new(), + date_published: String::new(), + files: Vec::new(), + }]; + ui.key(KeyCode::Enter); + assert!(ui.app.modpack_versions_state.is_none()); + assert_eq!( + ui.app.modpack_update_popup.as_ref().unwrap().action, + crate::tui::widgets::popups::modpack_update::Action::Reinstall + ); + + ui.app.modpack_update_popup = None; + ui.key(KeyCode::Char('v')); + let popup = ui + .app + .modpack_versions_state + .as_mut() + .and_then(|state| state.version_popup.as_mut()) + .unwrap(); + popup.loading = false; + popup.versions = vec![crate::net::modrinth::VersionInfo { + id: "new".to_owned(), + project_id: "pack".to_owned(), + name: "New".to_owned(), + version_number: "2.0".to_owned(), + game_versions: vec!["1.21.1".to_owned()], + loaders: Vec::new(), + version_type: crate::net::modrinth::VersionType::Release, + dependencies: Vec::new(), + date_published: String::new(), + files: Vec::new(), + }]; + ui.key(KeyCode::Enter); + assert_eq!( + ui.app.modpack_update_popup.as_ref().unwrap().action, + crate::tui::widgets::popups::modpack_update::Action::Change + ); +} + +#[test] +fn world_datapack_version_action_requires_selected_provider_match() { + let mut ui = UiHarness::new(); + ui.add_instance("Datapacks"); + ui.app.focused = FocusedArea::Content; + ui.app.content_tab = ContentTab::Worlds; + let world = ui + .instance_path("Datapacks") + .join(crate::storage::MINECRAFT_DIR_NAME) + .join("saves/World"); + let path = world.join("datapacks/unknown.zip"); + ui.app.world_datapacks_state.entries = vec![content_entry("Unknown datapack", path)]; + ui.app.world_datapacks_state.list_state.selected = Some(0); + ui.app.open_world_datapacks = Some(("World".to_owned(), world)); + let mut record = managed_mod_record( + "saves/World/datapacks/unknown.zip", + "known", + false, + Vec::new(), + ); + record.kind = ContentKind::DataPack; + let project = record.resolved_project().unwrap().clone(); + ui.app.content_manifest = Some(( + "Datapacks".to_owned(), + ContentManifest { + files: vec![record], + ..Default::default() + }, + )); + + ui.key(KeyCode::Char('v')); + assert!(ui.app.datapacks_discovery_state.version_popup.is_none()); + + ui.app.world_datapacks_state.entries[0].provider_project = Some(project); + ui.key(KeyCode::Char('v')); + assert!(ui.app.datapacks_discovery_state.version_popup.is_some()); +} + +#[test] +fn worlds_reserves_q_for_available_quick_launch() { + let mut ui = UiHarness::new(); + ui.add_instance("Test Instance"); + ui.app.focused = FocusedArea::Content; + ui.app.content_tab = ContentTab::Worlds; + + ui.key(KeyCode::Char('q')); + assert!(!ui.app.exit, "unsupported Quick Play must not quit rmcl"); + + let meta = serde_json::json!({ + "id": "1.21.1", + "mainClass": "net.minecraft.client.main.Main", + "arguments": { + "game": [{ + "rules": [{ + "action": "allow", + "features": { "is_quick_play_singleplayer": true } + }], + "value": ["--quickPlaySingleplayer", "${quickPlaySingleplayer}"] + }], + "jvm": [] + } + }); + let meta_path = crate::storage::MetadataPaths::new(&ui.app.instance_manager.meta_dir) + .versions() + .join("1.21.1/meta.json"); + std::fs::create_dir_all(meta_path.parent().unwrap()).unwrap(); + std::fs::write(meta_path, serde_json::to_vec(&meta).unwrap()).unwrap(); + ui.app.world_quick_play_support = None; + ui.draw(); + + assert!(ui.screen().contains("quick launch")); +} + +#[test] +fn mouse_wheel_uses_the_focused_views_scroll_navigation() { + let mut ui = UiHarness::new(); + ui.app.focused = FocusedArea::OverviewExpanded; + ui.app.log_overlay_max_scroll = 1; + + ui.mouse(MouseEventKind::ScrollDown); + ui.mouse(MouseEventKind::ScrollDown); + assert_eq!(ui.app.log_overlay_scroll, 1); + + ui.mouse(MouseEventKind::ScrollUp); + assert_eq!(ui.app.log_overlay_scroll, 0); +} + +#[test] +fn discovery_mode_recovers_from_a_hidden_tab_and_cycles_visible_tabs() { + let mut ui = UiHarness::new(); + ui.app.focused = FocusedArea::Content; + ui.app.content_tab = ContentTab::Logs; + + ui.key(KeyCode::Tab); + assert_eq!(ui.app.content_mode, ContentMode::Discover); + assert_eq!(ui.app.content_tab, ContentTab::Mods); + + ui.key(KeyCode::Right); + assert_eq!(ui.app.content_tab, ContentTab::ResourcePacks); + + ui.key(KeyCode::Left); + assert_eq!(ui.app.content_tab, ContentTab::Mods); +} + +#[test] +fn versions_open_from_a_discovery_project_page() { + let mut ui = UiHarness::new(); + ui.add_instance("Test Instance"); + ui.app.focused = FocusedArea::Content; + ui.app.content_mode = ContentMode::Discover; + ui.app.content_tab = ContentTab::Mods; + ui.app.mods_discovery_state.list.entries.push( + crate::tui::widgets::content::discovery::provider_project_entry( + DiscoveryProject { + id: "project".to_owned(), + slug: "project".to_owned(), + title: "Project".to_owned(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }, + "modrinth", + "project".to_owned(), + None, + ), + ); + ui.app.mods_discovery_state.list.list_state.selected = Some(0); + ui.app.mods_discovery_state.begin_project_page(); + + ui.key(KeyCode::Char('v')); + + assert!(ui.app.mods_discovery_state.version_popup.is_some()); +} + +#[test] +fn instance_delete_can_be_cancelled_without_touching_disk() { + let mut ui = UiHarness::new(); + ui.add_instance("Test Instance"); + let instance_path = ui.instance_path("Test Instance"); + + ui.key(KeyCode::Char('d')); + assert_eq!(ui.app.focused, FocusedArea::ConfirmDelete); + assert!(matches!( + confirm::pending_target(), + Some(confirm::ConfirmTarget::Instance { name }) if name == "Test Instance" + )); + + ui.key(KeyCode::Esc); + assert_eq!(ui.app.focused, FocusedArea::Instances); + assert!(confirm::pending_target().is_none()); + assert_eq!(ui.app.instances_state.instances.len(), 1); + assert!(instance_path.exists()); +} + +#[test] +fn confirmed_instance_delete_removes_state_and_disk() { + let mut ui = UiHarness::new(); + let name = format!("rmcl-ui-test-{}", std::process::id()); + ui.add_instance(&name); + let instance_path = ui.instance_path(&name); + assert!(!crate::instance::desktop::exists(&name)); + + ui.key(KeyCode::Char('d')); + ui.draw(); + assert!(ui.screen().contains(&format!("Delete '{name}'"))); + assert!( + ui.screen() + .contains("This will permanently remove the instance") + ); + ui.key(KeyCode::Char('y')); + + assert_eq!(ui.app.focused, FocusedArea::Instances); + assert!(ui.app.instances_state.instances.is_empty()); + assert!(!instance_path.exists()); +} + +#[test] +fn confirmed_screenshot_delete_removes_state_and_file() { + let mut ui = UiHarness::new(); + ui.add_instance("Screenshots"); + let path = ui + .instance_path("Screenshots") + .join(crate::storage::MINECRAFT_DIR_NAME) + .join("screenshots") + .join("shot.png"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, b"image").unwrap(); + ui.app.screenshots_state.entries = vec![crate::instance::screenshots::ScreenshotEntry { + name: "shot.png".to_owned(), + path: path.clone(), + width: 1, + height: 1, + }]; + ui.app.focused = FocusedArea::Content; + ui.app.content_tab = ContentTab::Screenshots; + + ui.key(KeyCode::Char('d')); + ui.key(KeyCode::Enter); + + assert_eq!(ui.app.focused, FocusedArea::Content); + assert!(ui.app.screenshots_state.entries.is_empty()); + assert!(!path.exists()); +} + +fn managed_mod_record( + path: &str, + project_id: &str, + automatic_dependency: bool, + required_dependencies: Vec, +) -> ContentFileRecord { + ContentFileRecord { + relative_path: PathBuf::from(path), + kind: ContentKind::Mod, + enabled: true, + fingerprint: FileFingerprint { + size: 1, + modified_ns: 1, + hashes: Default::default(), + }, + resolution: Resolution::Resolved { + project: ProviderProject { + provider: "modrinth".to_owned(), + project_id: project_id.to_owned(), + version_id: format!("{project_id}-version"), + }, + }, + provider_aliases: Vec::new(), + provider_checks: Vec::new(), + required_dependencies, + automatic_dependency, + cleanup_eligible: automatic_dependency, + } +} + +fn content_entry(name: &str, path: PathBuf) -> ContentEntry { + ContentEntry { + file_stem: name.to_owned(), + name: name.to_owned(), + source_slug: None, + installed_path: Some(path.clone()), + provider_project: None, + world_details: None, + title_suffix: None, + footer_label: None, + footer_change: None, + description: String::new(), + enabled: true, + icon_bytes: None, + provider_icon: false, + provider_description: false, + path, + icon_lines: None, + } +} + +#[test] +fn deleting_a_mod_offers_its_unused_dependency_chain() { + let mut ui = UiHarness::new(); + ui.add_instance("Dependencies"); + let minecraft = ui + .instance_path("Dependencies") + .join(crate::storage::MINECRAFT_DIR_NAME); + let root_path = minecraft.join("mods/root.jar"); + let library_path = minecraft.join("mods/library.jar"); + std::fs::create_dir_all(root_path.parent().unwrap()).unwrap(); + std::fs::write(&root_path, b"r").unwrap(); + std::fs::write(&library_path, b"l").unwrap(); + let dependency = ProviderProject { + provider: "modrinth".to_owned(), + project_id: "library".to_owned(), + version_id: "library-version".to_owned(), + }; + ContentManifest { + version: 1, + files: vec![ + managed_mod_record("mods/root.jar", "root", false, vec![dependency]), + managed_mod_record("mods/library.jar", "library", true, Vec::new()), + ], + } + .save(&crate::storage::InstancePaths::new(ui.instance_path("Dependencies")).content_manifest()) + .unwrap(); + ui.app.mods_state.entries = vec![content_entry("Root", root_path.clone())]; + ui.app.mods_state.list_state.selected = Some(0); + ui.app.focused = FocusedArea::Content; + ui.app.content_tab = ContentTab::Mods; + + ui.key(KeyCode::Char('d')); + ui.key(KeyCode::Enter); + + assert!(!root_path.exists()); + assert!(library_path.exists()); + assert!(matches!( + confirm::pending_target(), + Some(confirm::ConfirmTarget::OrphanDependencies { paths }) + if paths == vec![library_path.clone()] + )); + assert_eq!(ui.app.focused, FocusedArea::ConfirmDelete); + + ui.key(KeyCode::Enter); + + assert!(!library_path.exists()); + assert_eq!(ui.app.focused, FocusedArea::Content); + let manifest = ContentManifest::load( + &crate::storage::InstancePaths::new(ui.instance_path("Dependencies")).content_manifest(), + ) + .unwrap(); + assert!(manifest.files.is_empty()); +} + +#[test] +fn deleting_a_required_library_warns_but_can_continue() { + let mut ui = UiHarness::new(); + ui.add_instance("Required"); + let minecraft = ui + .instance_path("Required") + .join(crate::storage::MINECRAFT_DIR_NAME); + let library_path = minecraft.join("mods/library.jar"); + std::fs::create_dir_all(library_path.parent().unwrap()).unwrap(); + std::fs::write(&library_path, b"l").unwrap(); + ContentManifest { + version: 1, + files: vec![ + managed_mod_record( + "mods/root.jar", + "root", + false, + vec![ProviderProject { + provider: "modrinth".to_owned(), + project_id: "library".to_owned(), + version_id: "library-version".to_owned(), + }], + ), + managed_mod_record("mods/library.jar", "library", true, Vec::new()), + ], + } + .save(&crate::storage::InstancePaths::new(ui.instance_path("Required")).content_manifest()) + .unwrap(); + ui.app.mods_state.entries = vec![content_entry("Library", library_path.clone())]; + ui.app.mods_state.list_state.selected = Some(0); + ui.app.focused = FocusedArea::Content; + ui.app.content_tab = ContentTab::Mods; + + ui.key(KeyCode::Char('d')); + + assert!(matches!( + confirm::pending_target(), + Some(confirm::ConfirmTarget::Content { dependents, .. }) + if dependents == vec!["root"] + )); + ui.key(KeyCode::Enter); + assert!(!Path::new(&library_path).exists()); +} + +#[test] +fn confirmed_account_delete_updates_the_account_panel() { + let mut ui = UiHarness::new(); + ui.add_account("Player"); + ui.app.focused = FocusedArea::Account; + + ui.key(KeyCode::Char('d')); + ui.key(KeyCode::Char('y')); + + assert_eq!(ui.app.focused, FocusedArea::Account); + assert!(ui.app.account_state.store.accounts.is_empty()); + assert_eq!(ui.app.account_state.list_state.selected, None); +} + +#[test] +fn settings_profile_can_be_created_from_key_events() { + let mut ui = UiHarness::new(); + ui.app.focused = FocusedArea::Settings; + + ui.key(KeyCode::Char('a')); + for character in "qConfig".chars() { + ui.key(KeyCode::Char(character)); + } + ui.key(KeyCode::Enter); + + assert!(!ui.app.exit); + assert_eq!(ui.app.focused, FocusedArea::Settings); + assert_eq!(ui.app.settings_state.profiles, ["qConfig"]); +} + +#[test] +fn instance_wizards_open_render_and_cancel_through_app_input() { + let mut ui = UiHarness::new(); + + ui.key(KeyCode::Char('a')); + assert_eq!(ui.app.focused, FocusedArea::Popup); + ui.draw(); + assert!(ui.screen().contains("New Instance")); + ui.key(KeyCode::Esc); + assert_eq!(ui.app.focused, FocusedArea::Instances); + + ui.key(KeyCode::Char('m')); + assert_eq!(ui.app.focused, FocusedArea::ImportPopup); + ui.draw(); + assert!(ui.screen().contains("Browse Modpacks")); + ui.key(KeyCode::Char('i')); + ui.draw(); + assert!(ui.screen().contains("Import Modpack")); + ui.key(KeyCode::Esc); + assert_eq!(ui.app.focused, FocusedArea::ImportPopup); + ui.key(KeyCode::Esc); + assert_eq!(ui.app.focused, FocusedArea::Instances); +} + +#[test] +fn provider_conflict_renders_and_can_be_deferred() { + let mut ui = UiHarness::new(); + ui.app.provider_conflict = Some(ProviderConflictState { + relative_path: "mods/example.jar".into(), + candidates: vec![ + ProviderProject { + provider: "modrinth".to_owned(), + project_id: "first".to_owned(), + version_id: "1".to_owned(), + }, + ProviderProject { + provider: "curseforge".to_owned(), + project_id: "second".to_owned(), + version_id: "2".to_owned(), + }, + ], + selected: 0, + }); + + ui.draw(); + assert!(ui.screen().contains("Choose provider for example.jar")); + + ui.key(KeyCode::Down); + assert_eq!(ui.app.provider_conflict.as_ref().unwrap().selected, 1); + ui.key(KeyCode::Esc); + + assert!(ui.app.provider_conflict.is_none()); + assert!( + ui.app + .dismissed_provider_conflicts + .contains(std::path::Path::new("mods/example.jar")) + ); +} + +#[test] +fn provider_conflict_selection_is_persisted() { + let mut ui = UiHarness::new(); + ui.add_instance("Conflict"); + let relative_path = std::path::PathBuf::from("mods/example.jar"); + let candidates = vec![ + ProviderProject { + provider: "modrinth".to_owned(), + project_id: "first".to_owned(), + version_id: "1".to_owned(), + }, + ProviderProject { + provider: "curseforge".to_owned(), + project_id: "second".to_owned(), + version_id: "2".to_owned(), + }, + ]; + let manifest = crate::instance::ContentManifest { + version: 1, + files: vec![crate::instance::ContentFileRecord { + relative_path: relative_path.clone(), + kind: crate::instance::ContentKind::Mod, + enabled: true, + fingerprint: crate::instance::FileFingerprint { + size: 1, + modified_ns: 0, + hashes: Default::default(), + }, + resolution: crate::instance::Resolution::Ambiguous { + candidates: candidates.clone(), + }, + provider_aliases: Vec::new(), + provider_checks: Vec::new(), + required_dependencies: Vec::new(), + automatic_dependency: false, + cleanup_eligible: false, + }], + }; + let manifest_path = + crate::storage::InstancePaths::new(ui.instance_path("Conflict")).content_manifest(); + manifest.save(&manifest_path).unwrap(); + ui.app.content_manifest = Some(("Conflict".to_owned(), manifest)); + ui.app.provider_conflict = Some(ProviderConflictState { + relative_path: relative_path.clone(), + candidates, + selected: 0, + }); + + ui.key(KeyCode::Down); + ui.key(KeyCode::Enter); + + assert!(ui.app.provider_conflict.is_none()); + let saved = crate::instance::ContentManifest::load(&manifest_path).unwrap(); + assert!(matches!( + &saved.record(&relative_path).unwrap().resolution, + crate::instance::Resolution::Resolved { project } + if project.provider == "curseforge" && project.project_id == "second" + )); + assert_eq!( + saved.record(&relative_path).unwrap().provider_aliases, + vec![ProviderProject { + provider: "modrinth".to_owned(), + project_id: "first".to_owned(), + version_id: "1".to_owned(), + }] + ); +} diff --git a/src/tui/tests/harness.rs b/src/tui/tests/harness.rs new file mode 100644 index 0000000..5ce2979 --- /dev/null +++ b/src/tui/tests/harness.rs @@ -0,0 +1,188 @@ +use std::collections::{HashMap, HashSet}; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind}; +use ratatui::{Terminal, backend::TestBackend}; + +use crate::auth::{Account, AccountStore, AccountType}; +use crate::instance::{InstanceConfig, InstanceManager, ModLoader}; +use crate::tests::TEST_LOCK; +use crate::tui::{ + app::{App, FocusedArea}, + widgets, +}; + +pub(in crate::tui) struct UiHarness { + pub app: App, + terminal: Terminal, + runtime: tokio::runtime::Runtime, + _temp: tempfile::TempDir, + _guard: std::sync::MutexGuard<'static, ()>, +} + +impl UiHarness { + pub fn new() -> Self { + let guard = TEST_LOCK.lock().unwrap_or_else(|error| error.into_inner()); + widgets::popups::confirm::clear_pending(); + widgets::popups::new_instance::reset_for_test(); + widgets::popups::import_modpack::reset_for_test(); + crate::feedback::errors::ERROR_EVENTS + .lock() + .expect("error buffer") + .clear(); + crate::feedback::progress::clear(); + + let temp = tempfile::tempdir().expect("temporary UI data"); + let instances_dir = temp.path().join("instances"); + let meta_dir = temp.path().join("meta"); + std::fs::create_dir_all(&instances_dir).expect("instances directory"); + std::fs::create_dir_all(&meta_dir).expect("metadata directory"); + + let picker = ratatui_image::picker::Picker::halfblocks(); + let font_size = picker.font_size(); + let instance_manager = InstanceManager::new(instances_dir, meta_dir.clone()); + let account_state = widgets::account::AccountState { + store: AccountStore::empty_for_test(temp.path().join("accounts.json")), + list_state: Default::default(), + add_mode: widgets::account::AddMode::None, + }; + + let app = App { + exit: false, + focused: FocusedArea::default(), + pre_overlay_focused: FocusedArea::default(), + content_tab: widgets::content::ContentTab::default(), + content_mode: widgets::content::ContentMode::default(), + instances_state: widgets::instances::State::default(), + mods_state: widgets::content::ContentListState::default(), + mods_discovery_state: widgets::content::DiscoveryState::new( + crate::instance::ContentKind::Mod, + ), + resource_packs_state: widgets::content::ContentListState::default(), + resource_packs_discovery_state: widgets::content::DiscoveryState::new( + crate::instance::ContentKind::ResourcePack, + ), + shaders_state: widgets::content::ContentListState::default(), + shaders_discovery_state: widgets::content::DiscoveryState::new( + crate::instance::ContentKind::Shader, + ), + datapacks_discovery_state: widgets::content::DiscoveryState::new( + crate::instance::ContentKind::DataPack, + ), + worlds_state: widgets::content::ContentListState::default(), + world_datapacks_state: widgets::content::ContentListState::default(), + open_world_datapacks: None, + world_quick_play_support: None, + screenshots_state: { + let mut state = widgets::screenshots_grid::ScreenshotsState::default(); + state.font_size = (font_size.width, font_size.height); + state + }, + logs_state: widgets::logs_viewer::LogsState::default(), + account_state, + settings_state: widgets::settings::SettingsState::new(meta_dir), + picker, + instance_manager, + log_overlay_scroll: 0, + log_overlay_max_scroll: 0, + log_overlay_search: widgets::search::SearchState::default(), + log_overlay_scrollbar: ratatui::widgets::ScrollbarState::default(), + throbber_state: throbber_widgets_tui::ThrobberState::default(), + throbber_tick: 0, + error_effects: HashMap::new(), + pending_editor: None, + reconciliation_for: None, + content_manifest: None, + content_update_snapshot: None, + content_update_popup: None, + modpack_versions_state: None, + modpack_update_popup: None, + provider_conflict: None, + dismissed_provider_conflicts: HashSet::new(), + }; + + Self { + app, + terminal: Terminal::new(TestBackend::new(100, 30)).expect("test terminal"), + runtime: tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"), + _temp: temp, + _guard: guard, + } + } + + pub fn add_instance(&mut self, name: &str) { + let config = InstanceConfig { + name: name.to_owned(), + game_version: "1.21.1".to_owned(), + loader: ModLoader::Fabric, + loader_version: Some("0.16.14".to_owned()), + created: chrono::Utc::now(), + last_played: None, + java_path: None, + memory_max: None, + memory_min: None, + jvm_args: Vec::new(), + resolution: None, + config_sync_profile: None, + modpack_source: None, + }; + std::fs::create_dir_all(self.instance_path(name)).expect("instance directory"); + self.app + .instance_manager + .save(&config) + .expect("instance config"); + self.app.instances_state.add_instance(config); + } + + pub fn add_account(&mut self, username: &str) { + self.app.account_state.store.accounts.push(Account { + uuid: username.to_owned(), + username: username.to_owned(), + account_type: AccountType::Microsoft, + active: true, + refresh_token: Some("refresh".to_owned()), + cached_mc_token: None, + cached_mc_token_expires_at: None, + }); + self.app.account_state.list_state.selected = Some(0); + } + + pub fn instance_path(&self, name: &str) -> std::path::PathBuf { + self.app.instance_manager.instances_dir.join(name) + } + + pub fn key(&mut self, code: KeyCode) { + self.key_with(code, KeyModifiers::NONE); + } + + pub fn key_with(&mut self, code: KeyCode, modifiers: KeyModifiers) { + let _runtime = self.runtime.enter(); + self.app + .handle_key_event(KeyEvent::new(code, modifiers)) + .expect("handle key"); + } + + pub fn mouse(&mut self, kind: MouseEventKind) { + let _runtime = self.runtime.enter(); + self.app.handle_mouse_event(MouseEvent { + kind, + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + }); + } + + pub fn draw(&mut self) { + let _runtime = self.runtime.enter(); + let app = &mut self.app; + self.terminal + .draw(|frame| app.render_frame(frame)) + .expect("draw UI"); + } + + pub fn screen(&self) -> String { + self.terminal.backend().to_string() + } +} diff --git a/src/tui/tests/logging.rs b/src/tui/tests/logging.rs new file mode 100644 index 0000000..3fd1856 --- /dev/null +++ b/src/tui/tests/logging.rs @@ -0,0 +1,39 @@ +use super::*; + +#[test] +fn minecraft_events_are_not_general_app_logs() { + assert!(!should_record_app_log(MINECRAFT_LOG_TARGET, Level::ERROR)); +} + +#[test] +fn rmcl_events_are_general_app_logs() { + assert!(should_record_app_log( + "rmcl::instance::manager", + Level::TRACE + )); +} + +#[test] +fn dependency_debug_events_are_not_general_app_logs() { + assert!(!should_record_app_log("log", Level::DEBUG)); +} + +#[test] +fn dependency_warnings_are_general_app_logs() { + assert!(should_record_app_log("notify", Level::WARN)); +} + +#[test] +fn svg_renderer_warnings_do_not_interrupt_the_ui() { + assert!(!should_record_app_log("usvg::text", Level::WARN)); + assert!(!should_record_app_log("usvg::parser::filter", Level::WARN)); +} + +#[test] +fn unwritable_log_path_falls_back_without_panicking() { + let temp = tempfile::tempdir().unwrap(); + let file = temp.path().join("not-a-directory"); + std::fs::write(&file, b"block nested directory creation").unwrap(); + + let (_writer, _guard) = open_log_writer(&file.join("rmcl")); +} diff --git a/src/tui/tests/mod.rs b/src/tui/tests/mod.rs new file mode 100644 index 0000000..9f53416 --- /dev/null +++ b/src/tui/tests/mod.rs @@ -0,0 +1,3 @@ +mod flows; +pub(super) mod harness; +mod snapshots; diff --git a/src/tui/tests/snapshots.rs b/src/tui/tests/snapshots.rs new file mode 100644 index 0000000..3220624 --- /dev/null +++ b/src/tui/tests/snapshots.rs @@ -0,0 +1,86 @@ +use super::harness::UiHarness; +use crate::instance::ProviderProject; +use crate::tui::app::ProviderConflictState; +use crossterm::event::KeyCode; + +#[test] +fn empty_app_renders_the_complete_frame() { + let mut ui = UiHarness::new(); + ui.draw(); + insta::assert_snapshot!(ui.screen()); +} + +#[test] +fn active_progress_is_visible_in_the_complete_frame() { + let mut ui = UiHarness::new(); + crate::feedback::progress::set_action("Downloading test data"); + crate::feedback::progress::set_sub_action("one.jar"); + crate::feedback::progress::set_progress(1, 2); + + ui.draw(); + + let screen = ui.screen(); + assert!(screen.contains("Downloading test data")); + assert!(screen.contains("one.jar")); + crate::feedback::progress::clear(); +} + +#[test] +fn instance_delete_confirmation_renders_the_complete_frame() { + let mut ui = UiHarness::new(); + ui.add_instance("Snapshot Instance"); + ui.key(KeyCode::Char('d')); + + ui.draw(); + + insta::assert_snapshot!(ui.screen()); +} + +#[test] +fn orphan_dependency_confirmation_renders_simple_list() { + let mut ui = UiHarness::new(); + crate::tui::widgets::popups::confirm::set_pending_orphan_dependencies(vec![ + "mods/sodium-fabric-0.9.1+mc26.2.jar".into(), + ]); + ui.app.focused = crate::tui::app::FocusedArea::ConfirmDelete; + + ui.draw(); + + let screen = ui.screen(); + assert!( + !screen.contains("needs these libraries anymore"), + "{screen}" + ); + assert!(screen.contains("Remove unused dependencies"), "{screen}"); + assert!(screen.contains('•'), "{screen}"); + assert!(screen.contains("[Esc] cancel"), "{screen}"); + assert!( + screen.contains("sodium-fabric-0.9.1+mc26.2.jar"), + "{screen}" + ); +} + +#[test] +fn provider_conflict_renders_the_complete_frame() { + let mut ui = UiHarness::new(); + ui.app.provider_conflict = Some(ProviderConflictState { + relative_path: "mods/example.jar".into(), + candidates: vec![ + ProviderProject { + provider: "modrinth".to_owned(), + project_id: "first".to_owned(), + version_id: "1".to_owned(), + }, + ProviderProject { + provider: "curseforge".to_owned(), + project_id: "second".to_owned(), + version_id: "2".to_owned(), + }, + ], + selected: 0, + }); + + ui.draw(); + + insta::assert_snapshot!(ui.screen()); +} diff --git a/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__empty_app_renders_the_complete_frame.snap b/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__empty_app_renders_the_complete_frame.snap new file mode 100644 index 0000000..cc90885 --- /dev/null +++ b/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__empty_app_renders_the_complete_frame.snap @@ -0,0 +1,34 @@ +--- +source: src/tui/tests/snapshots.rs +expression: ui.screen() +--- +"╭Instances─────────╮╭Content───────────────────────────────────────────────────────────────────────╮" +"│ ││No instance selected │" +"│ │╰──────────────────────────────────────────────────────────────────────────────╯" +"│ │╭ Installed Mods • Resource Packs • Shaders • Screenshots • Worlds • Logs ───╮" +"│ ││No instance selected. │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ │╰[l] launch [⏎] content [Shift+⏎] open dir [Esc] kill [a] add [m] modpacks╯" +"│ │╭Accounts─────────╮╭Settings───────────────────────────╮╭Overview──────────────╮" +"│ ││No accounts. ││▸ instance default ││Ready │" +"│ ││ ││ ││ │" +"│ ││ ││ ││ │" +"╰──────────────────╯╰─────────────────╯╰───────────────────────────────────╯╰──────────────────────╯" diff --git a/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__instance_delete_confirmation_renders_the_complete_frame.snap b/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__instance_delete_confirmation_renders_the_complete_frame.snap new file mode 100644 index 0000000..bf31793 --- /dev/null +++ b/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__instance_delete_confirmation_renders_the_complete_frame.snap @@ -0,0 +1,34 @@ +--- +source: src/tui/tests/snapshots.rs +expression: ui.screen() +--- +"╭Instances─────────╮╭Content───────────────────────────────────────────────────────────────────────╮" +"│▌ Snapshot Instanc││Snapshot Instance 1.21.1 · Fabric 0.16.14│" +"│▌ Never played │╰──────────────────────────────────────────────────────────────────────────────╯" +"│ │╭ Installed Mods • Resource Packs • Shaders • Screenshots • Worlds • Logs ───╮" +"│ ││Loading mods... │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ ╭ Delete 'Snapshot Instance' ─────────────╮ │" +"│ ││ │This will permanently remove the instance│ │" +"│ ││ ╰────────────[Esc] cancel [Enter] confirm╯ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ │╰──────────────────────────────────────────────────────────────────────────────╯" +"│ │╭Accounts─────────╮╭Settings───────────────────────────╮╭Overview──────────────╮" +"│ ││No accounts. ││▸ instance default ││Ready │" +"│ ││ ││ ││ │" +"│ ││ ││ ││ │" +"╰──────────────────╯╰─────────────────╯╰───────────────────────────────────╯╰──────────────────────╯" diff --git a/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__provider_conflict_renders_the_complete_frame.snap b/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__provider_conflict_renders_the_complete_frame.snap new file mode 100644 index 0000000..02a964c --- /dev/null +++ b/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__provider_conflict_renders_the_complete_frame.snap @@ -0,0 +1,34 @@ +--- +source: src/tui/tests/snapshots.rs +expression: ui.screen() +--- +"╭Instances─────────╮╭Content───────────────────────────────────────────────────────────────────────╮" +"│ ││No instance selected │" +"│ │╰──────────────────────────────────────────────────────────────────────────────╯" +"│ │╭ Installed Mods • Resource Packs • Shaders • Screenshots • Worlds • Logs ───╮" +"│ ││No instance selected. │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││╭ Choose provider for example.jar ───────────────────────╮ │" +"│ │││▌modrinth first 1 │ │" +"│ │││ curseforge second 2 │ │" +"│ │││ │ │" +"│ │││ │ │" +"│ ││╰ [j/k] select [Enter] use [Esc] later ────────────────╯ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ │╰[l] launch [⏎] content [Shift+⏎] open dir [Esc] kill [a] add [m] modpacks╯" +"│ │╭Accounts─────────╮╭Settings───────────────────────────╮╭Overview──────────────╮" +"│ ││No accounts. ││▸ instance default ││Ready │" +"│ ││ ││ ││ │" +"│ ││ ││ ││ │" +"╰──────────────────╯╰─────────────────╯╰───────────────────────────────────╯╰──────────────────────╯" diff --git a/src/tui/tests/widgets/account.rs b/src/tui/tests/widgets/account.rs new file mode 100644 index 0000000..5518ad3 --- /dev/null +++ b/src/tui/tests/widgets/account.rs @@ -0,0 +1,47 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::{Terminal, backend::TestBackend}; + +use super::*; + +fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) +} + +#[test] +fn offline_account_requires_a_microsoft_account_and_can_be_dismissed() { + let temp = tempfile::tempdir().unwrap(); + let mut state = AccountState { + store: AccountStore::empty_for_test(temp.path().join("accounts.json")), + list_state: Default::default(), + add_mode: AddMode::None, + }; + + assert!(handle_key(&key(KeyCode::Char('a')), &mut state)); + assert!(matches!(state.add_mode, AddMode::ChooseType)); + + assert!(handle_key(&key(KeyCode::Char('o')), &mut state)); + assert!(matches!(state.add_mode, AddMode::OfflineBlocked)); + + assert!(handle_key(&key(KeyCode::Esc), &mut state)); + assert!(matches!(state.add_mode, AddMode::None)); +} + +#[test] +fn account_type_popup_uses_the_theme_surface() { + let temp = tempfile::tempdir().unwrap(); + let mut state = AccountState { + store: AccountStore::empty_for_test(temp.path().join("accounts.json")), + list_state: Default::default(), + add_mode: AddMode::ChooseType, + }; + let mut terminal = Terminal::new(TestBackend::new(60, 12)).unwrap(); + + terminal + .draw(|frame| render(frame, frame.area(), FocusedArea::Account, &mut state)) + .unwrap(); + + assert_eq!( + terminal.backend().buffer().cell((11, 3)).unwrap().bg, + THEME.as_ref().surface() + ); +} diff --git a/src/tui/tests/widgets/content/discovery.rs b/src/tui/tests/widgets/content/discovery.rs new file mode 100644 index 0000000..3c3955d --- /dev/null +++ b/src/tui/tests/widgets/content/discovery.rs @@ -0,0 +1,1332 @@ +use super::*; +use crate::tests::TEST_LOCK; +use chrono::Utc; +use crossterm::event::KeyModifiers; +use std::collections::HashMap; + +#[test] +fn bracket_keys_change_pages() { + assert_eq!( + page_key_direction(&KeyEvent::new(KeyCode::Char('['), KeyModifiers::NONE)), + Some(false) + ); + assert_eq!( + page_key_direction(&KeyEvent::new(KeyCode::Char(']'), KeyModifiers::NONE)), + Some(true) + ); +} + +#[test] +fn discovery_requests_four_viewport_pages() { + let mut state = DiscoveryState::new_modpacks(); + assert_eq!(state.begin_modpack_search().limit, PAGE_SIZE); + + state.set_viewport_rows(30); + assert_eq!(state.begin_modpack_search().limit, 40); + + state.set_viewport_rows(300); + assert_eq!(state.begin_modpack_search().limit, PAGE_SIZE); +} + +fn instance(name: &str, version: &str) -> InstanceConfig { + InstanceConfig { + name: name.to_string(), + game_version: version.to_string(), + loader: ModLoader::Fabric, + loader_version: None, + created: Utc::now(), + last_played: None, + java_path: None, + memory_max: None, + memory_min: None, + jvm_args: Vec::new(), + resolution: None, + config_sync_profile: None, + modpack_source: None, + } +} + +fn version(id: &str) -> VersionInfo { + VersionInfo { + id: id.to_owned(), + project_id: "project".to_owned(), + name: format!("Version {id}"), + version_number: id.to_owned(), + game_versions: vec!["1.21.1".to_owned()], + loaders: vec!["fabric".to_owned()], + version_type: crate::net::modrinth::VersionType::Release, + dependencies: Vec::new(), + date_published: "2026-01-02T12:00:00Z".to_owned(), + files: Vec::new(), + } +} + +fn project(id: &str) -> DiscoveryProject { + DiscoveryProject { + id: id.to_owned(), + slug: id.to_owned(), + title: id.to_owned(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + } +} + +#[test] +fn content_mode_toggles_both_ways() { + assert_eq!(ContentMode::Installed.toggle(), ContentMode::Discover); + assert_eq!(ContentMode::Discover.toggle(), ContentMode::Installed); +} + +#[test] +fn duplicate_provider_titles_share_one_identity() { + let mut modrinth = project("sodium"); + modrinth.title = "Sodium".to_owned(); + let mut curseforge = project("sodium-reforged"); + curseforge.title = "SODIUM!".to_owned(); + curseforge.slug = "sodium".to_owned(); + assert_eq!(project_identity(&modrinth), project_identity(&curseforge)); +} + +#[test] +fn matching_titles_with_different_slugs_remain_distinct() { + let mut original = project("sodium"); + original.title = "Sodium".to_owned(); + let mut fork = project("sodium-reforged"); + fork.title = "Sodium".to_owned(); + assert_ne!(project_identity(&original), project_identity(&fork)); +} + +#[test] +fn provider_merge_preserves_ranking_and_appends_fallbacks() { + let mut modrinth_first = project("mr-first"); + modrinth_first.title = "Shared".to_owned(); + modrinth_first.slug = "shared".to_owned(); + let modrinth_second = project("mr-second"); + let mut curseforge_duplicate = project("cf-duplicate"); + curseforge_duplicate.title = "Shared".to_owned(); + curseforge_duplicate.slug = "shared".to_owned(); + let curseforge_fallback = project("cf-fallback"); + + let merged = merge_provider_results( + vec![ + ( + "modrinth", + DiscoveryResults { + projects: vec![modrinth_first, modrinth_second], + total_hits: 2, + }, + ), + ( + "curseforge", + DiscoveryResults { + projects: vec![curseforge_duplicate, curseforge_fallback], + total_hits: 2, + }, + ), + ], + "modrinth", + HashMap::new(), + ); + + assert_eq!( + merged + .projects + .iter() + .map(|project| project.project.id.as_str()) + .collect::>(), + ["mr-first", "mr-second", "cf-fallback"] + ); + assert_eq!(merged.sources.len(), 4); + assert_eq!(merged.sources[0].0, merged.sources[2].0); +} + +#[test] +fn provider_merge_keeps_same_provider_title_collisions() { + let mut first = project("first"); + first.title = "Same".to_owned(); + let mut second = project("second"); + second.title = "Same".to_owned(); + + let merged = merge_provider_results( + vec![( + "modrinth", + DiscoveryResults { + projects: vec![first, second], + total_hits: 2, + }, + )], + "modrinth", + HashMap::new(), + ); + + assert_eq!(merged.projects.len(), 2); + assert_ne!(merged.projects[0].stem, merged.projects[1].stem); +} + +#[test] +fn provider_merge_keeps_the_preferred_project_across_pages() { + let mut fallback = project("shared"); + fallback.title = "Shared".to_owned(); + let identity = project_identity(&fallback); + let known = HashMap::from([(identity, ("shared".to_owned(), "modrinth".to_owned()))]); + + let merged = merge_provider_results( + vec![( + "curseforge", + DiscoveryResults { + projects: vec![fallback], + total_hits: 200, + }, + )], + "modrinth", + known, + ); + + assert!(merged.projects.is_empty()); + assert_eq!(merged.sources[0].0, "shared"); + assert_eq!(merged.total_hits, 200); +} + +#[test] +fn provider_merge_uses_the_longest_provider_result_range() { + let merged = merge_provider_results( + vec![ + ( + "modrinth", + DiscoveryResults { + projects: vec![], + total_hits: 20, + }, + ), + ( + "curseforge", + DiscoveryResults { + projects: vec![], + total_hits: 200, + }, + ), + ], + "modrinth", + HashMap::new(), + ); + + assert_eq!(merged.total_hits, 200); +} + +#[test] +fn version_popup_switches_to_the_other_provider() { + let mut state = DiscoveryState::new(ContentKind::Mod); + state + .list + .entries + .push(project_entry(project("sodium"), None)); + state.list.list_state.selected = Some(0); + let request = state.begin_search(&instance("test", "1.21.1")); + DiscoveryState::push_provider_result( + &request.pending, + request.generation, + request.offset, + Ok(DiscoveryPageResult { + received: 1, + total_hits: 1, + }), + vec![ + ( + "sodium".to_owned(), + crate::instance::ProviderProject { + provider: "modrinth".to_owned(), + project_id: "mr".to_owned(), + version_id: String::new(), + }, + ), + ( + "sodium".to_owned(), + crate::instance::ProviderProject { + provider: "curseforge".to_owned(), + project_id: "cf".to_owned(), + version_id: String::new(), + }, + ), + ], + ); + state.drain_pending(); + state + .list + .entries + .push(project_entry(project("sodium"), None)); + state.list.list_state.selected = Some(0); + let first = state.begin_versions().unwrap(); + state.version_popup.as_mut().unwrap().loading = false; + let second = state.switch_version_source().unwrap(); + assert_ne!(second.provider, first.provider); + assert_eq!( + state.version_popup.as_ref().unwrap().provider, + second.provider + ); +} + +#[test] +fn datapack_versions_select_a_world_before_dependency_resolution() { + let temp = tempfile::tempdir().unwrap(); + let minecraft = temp.path().join("minecraft"); + let world_path = minecraft.join("saves/world-folder"); + std::fs::create_dir_all(world_path.join("datapacks")).unwrap(); + let installed = world_path.join("datapacks/project.zip"); + std::fs::write(&installed, b"zip").unwrap(); + + let mut state = DiscoveryState::new(ContentKind::DataPack); + state + .list + .entries + .push(project_entry(project("project"), Some(installed.clone()))); + state.list.list_state.selected = Some(0); + let request = state.begin_versions().unwrap(); + let mut datapack_version = version("1.0.0"); + datapack_version.loaders = vec!["datapack".to_owned()]; + DiscoveryState::push_action_result( + &request.pending, + DiscoveryActionResult::Versions { + request_id: request.request_id, + project_id: request.project_id, + result: Ok(vec![datapack_version]), + }, + ); + state.drain_pending(); + + let world = crate::instance::scan_one_world(&world_path, "world-folder", true); + assert!(state.begin_world_selection(vec![world])); + assert_eq!( + state.version_popup.as_ref().unwrap().worlds.entries[0] + .icon_lines + .as_ref() + .unwrap() + .len(), + 3 + ); + let mut manifest = crate::instance::ContentManifest::default(); + manifest.upsert(crate::instance::ContentFileRecord { + relative_path: PathBuf::from("saves/world-folder/datapacks/project.zip"), + kind: ContentKind::DataPack, + enabled: true, + fingerprint: crate::instance::FileFingerprint { + size: 3, + modified_ns: 1, + hashes: Default::default(), + }, + resolution: crate::instance::Resolution::Resolved { + project: crate::instance::ProviderProject { + provider: "modrinth".to_owned(), + project_id: "project".to_owned(), + version_id: "old".to_owned(), + }, + }, + provider_aliases: Vec::new(), + provider_checks: Vec::new(), + required_dependencies: Vec::new(), + automatic_dependency: false, + cleanup_eligible: false, + }); + + assert!(state.select_world(Some(&manifest), &minecraft)); + let dependency = state.begin_dependency_resolution().unwrap(); + assert_eq!(dependency.root.kind, ContentKind::DataPack); + assert_eq!( + dependency.root.target_world.as_deref(), + Some(world_path.as_path()) + ); + assert_eq!( + dependency.root.installed_path.as_deref(), + Some(installed.as_path()) + ); +} + +#[test] +fn project_metadata_is_split_between_title_and_footer_badges() { + let entry = project_entry( + DiscoveryProject { + id: "example".to_owned(), + slug: "example".to_owned(), + title: "Example".to_owned(), + description: "Project description".to_owned(), + downloads: 1_234, + icon_url: None, + icon_bytes: None, + }, + Some(PathBuf::from("example.jar")), + ); + + assert_eq!(entry.title_suffix.as_deref(), Some("Installed")); + assert_eq!(entry.footer_label.as_deref(), Some("1.2K downloads")); + assert_eq!(entry.description, "Project description"); + assert_eq!(entry.path, PathBuf::from("example")); + assert_eq!(entry.installed_path, Some(PathBuf::from("example.jar"))); +} + +#[test] +fn install_and_change_version_popups_only_differ_in_title() { + let project = DiscoveryProject { + id: "project".to_owned(), + slug: "project".to_owned(), + title: "Project".to_owned(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }; + let mut state = DiscoveryState::new(ContentKind::Mod); + state + .list + .entries + .push(project_entry(project.clone(), None)); + state.list.list_state.selected = Some(0); + state.begin_versions().unwrap(); + assert_eq!( + state.version_popup.as_ref().unwrap().title(), + "Install Project" + ); + + state.version_popup = None; + state.list.entries[0] = project_entry(project, Some(PathBuf::from("mods/project.jar"))); + state.begin_versions().unwrap(); + assert_eq!( + state.version_popup.as_ref().unwrap().title(), + "Change Project version" + ); +} + +#[test] +fn installed_version_popup_tracks_current_version_and_reinstalls_it() { + let mut state = DiscoveryState::new(ContentKind::Mod); + let entry = crate::instance::content::entry::ContentEntry { + file_stem: "project".to_owned(), + name: "Project".to_owned(), + source_slug: None, + installed_path: None, + provider_project: None, + world_details: None, + title_suffix: None, + footer_label: None, + footer_change: None, + description: String::new(), + enabled: true, + icon_bytes: None, + provider_icon: false, + provider_description: false, + path: PathBuf::from("mods/project.jar"), + icon_lines: None, + }; + let record = crate::instance::ContentFileRecord { + relative_path: entry.path.clone(), + kind: ContentKind::Mod, + enabled: true, + fingerprint: crate::instance::FileFingerprint { + size: 1, + modified_ns: 1, + hashes: Default::default(), + }, + resolution: crate::instance::Resolution::Resolved { + project: crate::instance::ProviderProject { + provider: "modrinth".to_owned(), + project_id: "project".to_owned(), + version_id: "current".to_owned(), + }, + }, + provider_aliases: vec![crate::instance::ProviderProject { + provider: "curseforge".to_owned(), + project_id: "42".to_owned(), + version_id: "84".to_owned(), + }], + provider_checks: vec!["modrinth".to_owned(), "curseforge".to_owned()], + required_dependencies: Vec::new(), + automatic_dependency: false, + cleanup_eligible: false, + }; + + let request = state + .begin_installed_versions(&entry, &record, None) + .unwrap(); + let first_provider = request.provider.clone(); + let first_version = request.current_version_id.clone().unwrap(); + assert_eq!(state.version_popup.as_ref().unwrap().sources.len(), 2); + state.version_popup.as_mut().unwrap().loading = false; + state.version_popup.as_mut().unwrap().versions = vec![version(&first_version)]; + assert_eq!( + state.version_popup.as_ref().unwrap().title(), + "Reinstall Project" + ); + + let switched = state.switch_version_source().unwrap(); + assert_ne!(switched.provider, first_provider); + let switched_version = switched.current_version_id.clone().unwrap(); + + state.version_popup.as_mut().unwrap().loading = false; + state.version_popup.as_mut().unwrap().versions = vec![version(&switched_version)]; + let request = state.begin_dependency_resolution().unwrap(); + assert!(request.root.force_reinstall); +} + +#[test] +fn compatible_versions_populate_the_open_popup() { + let project = DiscoveryProject { + id: "project".to_owned(), + slug: "project".to_owned(), + title: "Project".to_owned(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }; + let mut state = DiscoveryState::new(ContentKind::Mod); + state.list.entries.push(project_entry(project, None)); + state.list.list_state.selected = Some(0); + let request = state.begin_versions().unwrap(); + DiscoveryState::push_action_result( + &request.pending, + DiscoveryActionResult::Versions { + request_id: request.request_id, + project_id: request.project_id, + result: Ok(vec![version("1.0.0"), version("1.1.0")]), + }, + ); + + state.drain_pending(); + + let popup = state.version_popup.as_ref().unwrap(); + assert!(!popup.loading); + assert_eq!(popup.versions.len(), 2); + assert_eq!(popup.selected, 0); +} + +#[test] +fn modpacks_choose_minecraft_before_filtering_pack_versions() { + let mut state = DiscoveryState::new_modpacks(); + state + .list + .entries + .push(project_entry(project("pack"), None)); + state.list.list_state.selected = Some(0); + let request = state.begin_versions().unwrap(); + let mut older = version("1.0.0"); + older.game_versions = vec!["1.20.1".to_owned(), "fabric".to_owned()]; + DiscoveryState::push_action_result( + &request.pending, + DiscoveryActionResult::Versions { + request_id: request.request_id, + project_id: request.project_id, + result: Ok(vec![older, version("2.0.0")]), + }, + ); + state.drain_pending(); + + let popup = state.version_popup.as_mut().unwrap(); + assert!(popup.selecting_minecraft_version); + assert_eq!(popup.minecraft_versions, ["1.21.1", "1.20.1"]); + popup.selected = 1; + + assert!(state.select_minecraft_version()); + let popup = state.version_popup.as_ref().unwrap(); + assert_eq!(popup.selected_minecraft_version.as_deref(), Some("1.20.1")); + assert_eq!( + popup + .visible_versions() + .map(|version| version.version_number.as_str()) + .collect::>(), + ["1.0.0"] + ); +} + +#[test] +fn managed_modpack_versions_open_directly_and_mark_reinstall() { + let mut state = DiscoveryState::new_modpacks(); + let source = crate::instance::ProviderProject { + provider: "modrinth".to_owned(), + project_id: "pack".to_owned(), + version_id: "current".to_owned(), + }; + + let request = state + .begin_managed_modpack_versions("Managed Pack", source) + .unwrap(); + assert_eq!(request.current_version_id.as_deref(), Some("current")); + let popup = state.version_popup.as_mut().unwrap(); + assert!(!popup.selecting_minecraft_version); + popup.loading = false; + popup.versions = vec![version("current"), version("older")]; + + assert_eq!(popup.title(), "Reinstall Managed Pack"); + popup.selected = 1; + assert_eq!(popup.title(), "Change Managed Pack version"); +} + +#[test] +fn project_page_loads_for_the_selected_discovery_entry() { + let project = DiscoveryProject { + id: "project".to_owned(), + slug: "project".to_owned(), + title: "Project".to_owned(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }; + let mut state = DiscoveryState::new(ContentKind::Mod); + state.list.entries.push(project_entry(project, None)); + state.list.list_state.selected = Some(0); + + let request = state.begin_project_page().unwrap(); + assert!(state.project_page_open()); + DiscoveryState::push_action_result( + &request.pending, + DiscoveryActionResult::ProjectPage { + request_id: request.request_id, + project_id: request.project_id, + result: Ok(crate::net::modrinth::ProjectInfo { + id: "project".to_owned(), + slug: "project".to_owned(), + title: "Project page".to_owned(), + description: "Short description".to_owned(), + body: "Long **Markdown** description.".to_owned(), + icon_url: None, + categories: Vec::new(), + additional_categories: Vec::new(), + project_type: "mod".to_owned(), + loaders: Vec::new(), + }), + }, + ); + + state.drain_pending(); + let page = state.project_page.as_ref().unwrap(); + assert_eq!(page.title, "Project page"); + assert!(page.document.is_some()); + state.project_page = None; + assert!(state.begin_project_page().is_none()); + assert!( + state + .project_page + .as_ref() + .is_some_and(|page| page.document.is_some()) + ); +} + +#[test] +fn project_page_navigation_is_bounded_and_can_go_back() { + let mut state = DiscoveryState::new(ContentKind::Mod); + state.project_page = Some(ProjectPageState { + request_id: 1, + project_id: "project".to_owned(), + title: "Project".to_owned(), + document: Some(crate::tui::widgets::markdown::Document::new( + "Project", "Body", + )), + error: None, + scroll: 0, + max_scroll: 20, + }); + + handle_key( + &KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE), + &mut state, + ); + assert_eq!(state.project_page.as_ref().unwrap().scroll, 10); + handle_key( + &KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE), + &mut state, + ); + assert_eq!(state.project_page.as_ref().unwrap().scroll, 20); + handle_key( + &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE), + &mut state, + ); + assert!(!state.project_page_open()); +} + +#[test] +fn version_popup_owns_navigation_over_a_project_page() { + let project = DiscoveryProject { + id: "project".to_owned(), + slug: "project".to_owned(), + title: "Project".to_owned(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }; + let mut state = DiscoveryState::new(ContentKind::Mod); + state.list.entries.push(project_entry(project, None)); + state.list.list_state.selected = Some(0); + state.project_page = Some(ProjectPageState { + request_id: 1, + project_id: "project".to_owned(), + title: "Project".to_owned(), + document: None, + error: None, + scroll: 0, + max_scroll: 20, + }); + let request = state.begin_versions().unwrap(); + DiscoveryState::push_action_result( + &request.pending, + DiscoveryActionResult::Versions { + request_id: request.request_id, + project_id: request.project_id, + result: Ok(vec![version("1.0.0"), version("2.0.0")]), + }, + ); + state.drain_pending(); + + handle_key( + &KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), + &mut state, + ); + + assert_eq!(state.version_popup.as_ref().unwrap().selected, 1); + assert_eq!(state.project_page.as_ref().unwrap().scroll, 0); +} + +#[test] +fn confirmation_can_return_to_version_selection() { + let project = DiscoveryProject { + id: "project".to_owned(), + slug: "project".to_owned(), + title: "Project".to_owned(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }; + let mut state = DiscoveryState::new(ContentKind::Mod); + state.list.entries.push(project_entry(project, None)); + state.list.list_state.selected = Some(0); + let request = state.begin_versions().unwrap(); + DiscoveryState::push_action_result( + &request.pending, + DiscoveryActionResult::Versions { + request_id: request.request_id, + project_id: request.project_id, + result: Ok(vec![version("1.0.0")]), + }, + ); + state.drain_pending(); + assert!(state.begin_confirmation()); + + handle_key( + &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE), + &mut state, + ); + + assert!(!state.version_popup.as_ref().unwrap().confirming); + assert!(state.version_popup.is_some()); +} + +#[test] +fn dependency_resolution_opens_the_existing_confirmation() { + let project = DiscoveryProject { + id: "project".to_owned(), + slug: "project".to_owned(), + title: "Project".to_owned(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }; + let mut state = DiscoveryState::new(ContentKind::Mod); + state.list.entries.push(project_entry(project, None)); + state.list.list_state.selected = Some(0); + let versions = state.begin_versions().unwrap(); + DiscoveryState::push_action_result( + &versions.pending, + DiscoveryActionResult::Versions { + request_id: versions.request_id, + project_id: versions.project_id, + result: Ok(vec![version("1.0.0")]), + }, + ); + state.drain_pending(); + let request = state.begin_dependency_resolution().unwrap(); + assert!(state.version_popup.as_ref().unwrap().loading); + let root_version = request.root.version.clone(); + DiscoveryState::push_action_result( + &request.pending, + DiscoveryActionResult::Dependencies { + request_id: request.request_id, + project_id: request.project_id, + result: Ok(crate::instance::content::dependencies::DependencyPlan { + items: vec![crate::instance::content::dependencies::PlannedInstall { + provider: "modrinth".to_owned(), + project_id: "project".to_owned(), + title: "Project".to_owned(), + version: root_version, + installed_path: None, + kind: crate::instance::ContentKind::Mod, + destination: std::path::PathBuf::from("mods"), + provider_aliases: Vec::new(), + required_dependencies: Vec::new(), + automatic_dependency: false, + cleanup_eligible: false, + replacement: false, + }], + root_count: 1, + optional_dependencies: 0, + }), + }, + ); + + state.drain_pending(); + + let popup = state.version_popup.as_ref().unwrap(); + assert!(popup.confirming); + assert!(!popup.loading); + assert!(popup.dependency_plan.is_some()); + assert!(state.begin_install().unwrap().dependency_plan.is_some()); +} + +#[test] +fn discovery_delete_only_clears_the_matching_installed_badge() { + let first_path = PathBuf::from("mods/first.jar"); + let second_path = PathBuf::from("mods/second.jar"); + let project = |id: &str| DiscoveryProject { + id: id.to_owned(), + slug: id.to_owned(), + title: id.to_owned(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }; + let mut state = DiscoveryState::new(ContentKind::Mod); + state + .list + .entries + .push(project_entry(project("first"), Some(first_path.clone()))); + state + .list + .entries + .push(project_entry(project("second"), Some(second_path.clone()))); + state.list.list_state.selected = Some(0); + + let pending = state.pending_installed_delete().unwrap(); + assert_eq!(pending.path, first_path); + assert!(state.clear_installed_path(&pending.path)); + + assert_eq!(state.list.entries.len(), 2); + assert!(state.list.entries[0].installed_path.is_none()); + assert!(state.list.entries[0].title_suffix.is_none()); + assert_eq!( + state.list.entries[1].installed_path.as_deref(), + Some(second_path.as_path()) + ); + assert_eq!( + state.list.entries[1].title_suffix.as_deref(), + Some("Installed") + ); + assert!(state.pending_installed_delete().is_none()); +} + +#[test] +fn successful_install_marks_the_project_and_closes_the_popup() { + let _guard = TEST_LOCK.lock().unwrap_or_else(|error| error.into_inner()); + let project = DiscoveryProject { + id: "project".to_owned(), + slug: "project".to_owned(), + title: "Project".to_owned(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }; + let mut state = DiscoveryState::new(ContentKind::Mod); + state.list.entries.push(project_entry(project, None)); + state.list.list_state.selected = Some(0); + let versions_request = state.begin_versions().unwrap(); + DiscoveryState::push_action_result( + &versions_request.pending, + DiscoveryActionResult::Versions { + request_id: versions_request.request_id, + project_id: versions_request.project_id, + result: Ok(vec![version("1.0.0")]), + }, + ); + state.drain_pending(); + assert!(state.begin_confirmation()); + let install = state.begin_install().unwrap(); + assert!(state.version_popup.is_none()); + DiscoveryState::push_action_result( + &install.pending, + DiscoveryActionResult::Install { + request_id: install.request_id, + generation: install.generation, + project_id: install.project_id, + project_title: install.project_title, + result: Ok(InstallCompletion { + path: PathBuf::from("mods/project.jar"), + replaced: false, + skipped: false, + orphaned_dependencies: Vec::new(), + }), + }, + ); + + state.drain_pending(); + assert!(state.version_popup.is_none()); + assert_eq!( + state.list.entries[0].title_suffix.as_deref(), + Some("Installed") + ); + assert_eq!( + state.list.entries[0].installed_path, + Some(PathBuf::from("mods/project.jar")) + ); + assert_eq!(state.list.entries[0].path, PathBuf::from("project")); +} + +#[test] +fn installed_labels_follow_exact_manifest_projects() { + let project = DiscoveryProject { + id: "project-id".to_owned(), + slug: "example-project".to_owned(), + title: "Example Project".to_owned(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }; + let mut state = DiscoveryState::new(ContentKind::Mod); + state.list.entries.push(project_entry(project, None)); + + let mut manifest = crate::instance::ContentManifest::default(); + manifest.upsert(crate::instance::ContentFileRecord { + relative_path: PathBuf::from("mods/example-project-1.0.0.jar"), + kind: crate::instance::ContentKind::Mod, + enabled: true, + fingerprint: crate::instance::FileFingerprint { + size: 1, + modified_ns: 1, + hashes: Default::default(), + }, + resolution: crate::instance::Resolution::Resolved { + project: crate::instance::ProviderProject { + provider: "modrinth".to_owned(), + project_id: "project-id".to_owned(), + version_id: "version".to_owned(), + }, + }, + provider_aliases: Vec::new(), + provider_checks: Vec::new(), + required_dependencies: Vec::new(), + automatic_dependency: false, + cleanup_eligible: false, + }); + let mut sources = vec![( + "example-project".to_owned(), + crate::instance::ProviderProject { + provider: "modrinth".to_owned(), + project_id: "project-id".to_owned(), + version_id: String::new(), + }, + )]; + refresh_source_installed_versions( + &mut sources, + &DiscoveryTarget::Content(Box::new(ContentDiscoveryTarget { + instance: instance("test", "1.21.1"), + kind: ContentKind::Mod, + manifest: Some(manifest.clone()), + minecraft_dir: PathBuf::from("first"), + })), + ); + assert_eq!(sources[0].1.version_id, "version"); + + state.refresh_installed_manifest(&manifest, std::path::Path::new("first")); + assert_eq!( + state.list.entries[0].title_suffix.as_deref(), + Some("Installed") + ); + assert_eq!( + state.list.entries[0].installed_path, + Some(PathBuf::from("first/mods/example-project-1.0.0.jar")) + ); + state.list.list_state.selected = Some(0); + let request = state.begin_versions().unwrap(); + assert_eq!(request.current_version_id.as_deref(), Some("version")); + assert_eq!( + state + .version_popup + .as_ref() + .unwrap() + .current_version_id + .as_deref(), + Some("version") + ); + state.version_popup = None; + + state.refresh_installed_manifest( + &crate::instance::ContentManifest::default(), + std::path::Path::new("first"), + ); + assert_eq!(state.list.entries[0].title_suffix, None); + assert_eq!(state.list.entries[0].installed_path, None); + assert_eq!(state.list.entries[0].path, PathBuf::from("project-id")); +} + +#[test] +fn changing_instance_invalidates_results() { + let mut state = DiscoveryState::new(ContentKind::Mod); + let first = instance("one", "1.21.1"); + let second = instance("two", "1.21.1"); + let _request = state.begin_search(&first); + + assert!(!state.needs_search(&first)); + assert!(state.needs_search(&second)); +} + +#[test] +fn unavailable_vanilla_discovery_clears_cached_results() { + let mut state = DiscoveryState::new(ContentKind::Mod); + state.list.entries.push(project_entry( + DiscoveryProject { + id: "cached".to_owned(), + slug: "cached".to_owned(), + title: "Cached".to_owned(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }, + None, + )); + let mut vanilla = instance("vanilla", "1.21.1"); + vanilla.loader = ModLoader::Vanilla; + assert_eq!( + state.unavailable_message(&vanilla), + Some("Vanilla does not support mods.") + ); + + state.set_unavailable(&vanilla); + + assert!(state.list.entries.is_empty()); + assert!(!state.page_loading); + assert!(state.exhausted); +} + +#[test] +fn changing_instance_compatibility_invalidates_results() { + let mut state = DiscoveryState::new(ContentKind::Mod); + let original = instance("one", "1.21.1"); + let mut other_version = original.clone(); + other_version.game_version = "1.20.1".to_owned(); + let mut other_loader = original.clone(); + other_loader.loader = ModLoader::NeoForge; + let _request = state.begin_search(&original); + + assert!(state.needs_search(&other_version)); + assert!(state.needs_search(&other_loader)); +} + +#[test] +fn stale_search_result_is_ignored() { + let mut state = DiscoveryState::new(ContentKind::Mod); + let instance = instance("one", "1.21.1"); + let old = state.begin_search(&instance); + let _new = state.begin_search(&instance); + DiscoveryState::push_result( + &old.pending, + old.generation, + old.offset, + Ok(DiscoveryPageResult { + received: 20, + total_hits: 99, + }), + ); + + state.drain_pending(); + + assert_eq!(state.total_hits, 0); + assert!(state.list.loading); +} + +#[test] +fn next_page_prefetches_before_selection_reaches_the_end() { + let mut state = DiscoveryState::new(ContentKind::Mod); + state.set_viewport_rows(30); + let instance = instance("one", "1.21.1"); + let first = state.begin_search(&instance); + for index in 0..PAGE_SIZE { + assert!(first.stream.send(project_entry( + DiscoveryProject { + id: index.to_string(), + slug: index.to_string(), + title: index.to_string(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }, + None + ))); + } + state.list.drain_pending(); + DiscoveryState::push_result( + &first.pending, + first.generation, + first.offset, + Ok(DiscoveryPageResult { + received: PAGE_SIZE, + total_hits: 300, + }), + ); + state.drain_pending(); + state.list.list_state.selected = Some(80); + + let next = state.begin_next_page().expect("next page should prefetch"); + assert_eq!(next.offset, PAGE_SIZE); + assert!(state.begin_next_page().is_none()); +} + +#[test] +fn large_page_fills_a_tall_viewport_without_another_request() { + let mut state = DiscoveryState::new(ContentKind::Mod); + state.set_viewport_rows(90); + let first = state.begin_search(&instance("one", "1.21.1")); + for index in 0..PAGE_SIZE { + assert!(first.stream.send(project_entry( + DiscoveryProject { + id: index.to_string(), + slug: index.to_string(), + title: index.to_string(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }, + None + ))); + } + state.list.drain_pending(); + DiscoveryState::push_result( + &first.pending, + first.generation, + first.offset, + Ok(DiscoveryPageResult { + received: PAGE_SIZE, + total_hits: 300, + }), + ); + state.drain_pending(); + + assert!(state.begin_next_page().is_none()); +} + +#[test] +fn typing_keeps_loaded_results_until_remote_search_is_due() { + let mut state = DiscoveryState::new(ContentKind::Mod); + let request = state.begin_search(&instance("one", "1.21.1")); + for title in ["Sodium", "Lithium"] { + assert!(request.stream.send(project_entry( + DiscoveryProject { + id: title.to_lowercase(), + slug: title.to_lowercase(), + title: title.to_owned(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }, + None + ))); + } + state.list.drain_pending(); + + handle_key( + &KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE), + &mut state, + ); + for character in "sod".chars() { + handle_key( + &KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE), + &mut state, + ); + } + + assert_eq!(state.list.filtered_indices(), vec![0, 1]); + assert_eq!(state.list.search.query, "sod"); + assert!(!state.search_due()); + state.search_changed_at = Some(std::time::Instant::now() - SEARCH_DEBOUNCE); + assert!(state.search_due()); +} + +#[test] +fn search_refresh_keeps_rows_until_the_diff_arrives() { + let mut state = DiscoveryState::new(ContentKind::Mod); + let instance = instance("one", "1.21.1"); + let initial = state.begin_search(&instance); + for title in ["Sodium", "Lithium"] { + assert!(initial.stream.upsert(project_entry( + DiscoveryProject { + id: title.to_lowercase(), + slug: title.to_lowercase(), + title: title.to_owned(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: (title == "Sodium").then(|| vec![1, 2, 3]), + }, + None + ))); + } + state.list.drain_pending(); + state.search.query = "sodium".to_owned(); + state.search_changed(); + + let refresh = state.begin_search(&instance); + + assert!(refresh.reconcile); + assert!(refresh.loaded_icon_stems.contains("sodium")); + assert!(!refresh.loaded_icon_stems.contains("lithium")); + assert_eq!(state.list.entries.len(), 2); + assert!(!state.list.loading); +} + +#[test] +fn pagination_continues_across_multiple_pages() { + let mut state = DiscoveryState::new(ContentKind::Mod); + let instance = instance("one", "1.21.1"); + let first = state.begin_search(&instance); + for index in 0..PAGE_SIZE { + assert!(first.stream.upsert(project_entry( + DiscoveryProject { + id: index.to_string(), + slug: index.to_string(), + title: index.to_string(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }, + None + ))); + } + state.list.drain_pending(); + DiscoveryState::push_result( + &first.pending, + first.generation, + first.offset, + Ok(DiscoveryPageResult { + received: PAGE_SIZE, + total_hits: 300, + }), + ); + state.drain_pending(); + state.list.list_state.selected = Some(PAGE_SIZE - MIN_PREFETCH_ITEMS); + + let second = state.begin_next_page().unwrap(); + for index in PAGE_SIZE..PAGE_SIZE * 2 { + assert!(second.stream.upsert(project_entry( + DiscoveryProject { + id: index.to_string(), + slug: index.to_string(), + title: index.to_string(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }, + None + ))); + } + state.list.drain_pending(); + DiscoveryState::push_result( + &second.pending, + second.generation, + second.offset, + Ok(DiscoveryPageResult { + received: PAGE_SIZE, + total_hits: 300, + }), + ); + state.drain_pending(); + state.list.list_state.selected = Some(PAGE_SIZE * 2 - MIN_PREFETCH_ITEMS); + + let third = state.begin_next_page().unwrap(); + assert_eq!(third.offset, PAGE_SIZE * 2); +} + +#[test] +fn permanent_pagination_failure_stops_without_discarding_loaded_entries() { + let mut state = DiscoveryState::new(ContentKind::Mod); + let first = state.begin_search(&instance("one", "1.21.1")); + for index in 0..PAGE_SIZE { + assert!(first.stream.upsert(project_entry( + DiscoveryProject { + id: index.to_string(), + slug: index.to_string(), + title: index.to_string(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }, + None + ))); + } + state.list.drain_pending(); + DiscoveryState::push_result( + &first.pending, + first.generation, + first.offset, + Ok(DiscoveryPageResult { + received: PAGE_SIZE, + total_hits: 300, + }), + ); + state.drain_pending(); + state.list.list_state.selected = Some(PAGE_SIZE - MIN_PREFETCH_ITEMS); + let second = state.begin_next_page().unwrap(); + DiscoveryState::push_result( + &second.pending, + second.generation, + second.offset, + Err(DiscoveryPageError { + message: "invalid response".to_owned(), + retryable: false, + }), + ); + state.drain_pending(); + + assert!(state.begin_next_page().is_none()); + assert_eq!(state.list.entries.len(), PAGE_SIZE); + assert!(state.exhausted); +} + +#[test] +fn transient_pagination_failure_retries_the_same_offset_after_a_delay() { + let mut state = DiscoveryState::new(ContentKind::Mod); + let first = state.begin_search(&instance("one", "1.21.1")); + DiscoveryState::push_result( + &first.pending, + first.generation, + first.offset, + Ok(DiscoveryPageResult { + received: PAGE_SIZE, + total_hits: 300, + }), + ); + state.drain_pending(); + + let second = state.begin_next_page().unwrap(); + DiscoveryState::push_result( + &second.pending, + second.generation, + second.offset, + Err(DiscoveryPageError { + message: "connection reset".to_owned(), + retryable: true, + }), + ); + state.drain_pending(); + + assert!(state.begin_next_page().is_none()); + state.retry_page_at = Some(std::time::Instant::now() - PAGE_RETRY_BASE_DELAY); + assert_eq!(state.begin_next_page().unwrap().offset, PAGE_SIZE); +} diff --git a/src/tui/tests/widgets/content/list.rs b/src/tui/tests/widgets/content/list.rs new file mode 100644 index 0000000..21c3098 --- /dev/null +++ b/src/tui/tests/widgets/content/list.rs @@ -0,0 +1,772 @@ +use std::{ + collections::{HashMap, HashSet}, + path::PathBuf, + sync::{Arc, Mutex}, +}; + +use crate::instance::content::entry::{ContentEntry, WorldDetails, WorldGameMode}; +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span, Text}, + widgets::Widget, +}; + +use super::{ + ContentListState, WatcherEventHandling, available_description_width, description_text_width, + diff_directory, diff_event_paths, ellipsize, load_provider_metadata, read_dir_stems, + right_aligned_footer_spans, square_icon_columns, title_suffix_spans, version_change_spans, + watcher_event_handling, world_descriptions, world_game_mode_color, +}; + +fn entry(name: &str) -> ContentEntry { + ContentEntry { + file_stem: name.to_lowercase(), + name: name.to_owned(), + source_slug: None, + installed_path: None, + provider_project: None, + world_details: None, + title_suffix: None, + footer_label: None, + footer_change: None, + description: String::new(), + enabled: true, + icon_bytes: None, + provider_icon: false, + provider_description: false, + path: PathBuf::from(name.to_lowercase()), + icon_lines: None, + } +} + +#[test] +fn selected_provider_project_tracks_the_filtered_selection() { + let mut state = ContentListState { + entries: vec![entry("Local only"), entry("Provider match")], + ..Default::default() + }; + state.entries[1].provider_project = Some(crate::instance::ProviderProject { + provider: "modrinth".to_owned(), + project_id: "matched".to_owned(), + version_id: "version".to_owned(), + }); + state.search.query = "local".to_owned(); + state.list_state.selected = Some(0); + + assert!(!state.selected_has_provider_project()); + state.search.query = "provider".to_owned(); + assert!(state.selected_has_provider_project()); +} + +#[test] +fn world_cards_preview_up_to_three_datapacks() { + let lines = world_descriptions(&WorldDetails { + game_mode: None, + last_played: None, + minecraft_version: Some("1.21.1".to_owned()), + size: Some("4.0 MB".to_owned()), + datapacks: ["A", "B", "C", "D"] + .into_iter() + .map(str::to_owned) + .collect(), + }); + + assert_eq!( + lines, + ["1.21.1 • 4.0 MB", " • A", " • B", " • C", " +1 more"] + ); +} + +#[test] +fn toggling_a_selected_entry_renames_and_updates_it() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("example.jar"); + std::fs::write(&path, b"mod").unwrap(); + let mut content = entry("Example"); + content.path = path.clone(); + let mut state = ContentListState::default(); + state.entries.push(content); + state.list_state.selected = Some(0); + + state.toggle_selected(); + + assert!(!path.exists()); + assert_eq!( + state.entries[0].path, + temp.path().join("example.jar.disabled") + ); + assert!(!state.entries[0].enabled); +} + +#[test] +fn content_watcher_ignores_file_access_events() { + assert_eq!( + watcher_event_handling(¬ify::EventKind::Access(notify::event::AccessKind::Any)), + WatcherEventHandling::Ignore + ); +} + +#[test] +fn content_watcher_handles_mutations_without_full_rescan() { + assert_eq!( + watcher_event_handling(¬ify::EventKind::Modify(notify::event::ModifyKind::Any)), + WatcherEventHandling::Paths + ); + assert_eq!( + watcher_event_handling(¬ify::EventKind::Modify(notify::event::ModifyKind::Name( + notify::event::RenameMode::Both + ))), + WatcherEventHandling::Rescan + ); + assert_eq!( + watcher_event_handling(¬ify::EventKind::Any), + WatcherEventHandling::Rescan + ); +} + +#[test] +fn content_watcher_keeps_a_renamed_disabled_entry() { + let temp = tempfile::tempdir().unwrap(); + let enabled = temp.path().join("example.jar"); + let disabled = temp.path().join("example.jar.disabled"); + std::fs::write(&enabled, b"mod").unwrap(); + let known = Arc::new(Mutex::new(read_dir_stems(temp.path(), ".jar"))); + + std::fs::rename(enabled, &disabled).unwrap(); + let diff = diff_directory(temp.path(), ".jar", None, &known).unwrap(); + + assert_eq!(diff.toggled, vec![("example".to_owned(), false, disabled)]); + assert!(diff.removed.is_empty()); + assert!(diff.added.is_empty()); +} + +#[test] +fn pure_toggle_does_not_request_reconciliation() { + let mut state = ContentListState::default(); + let mut content = entry("Example"); + content.path = PathBuf::from("mods/example.jar.disabled"); + content.enabled = false; + state.entries.push(content); + *state.watcher_diff.lock().unwrap() = Some(super::WatcherDiff { + toggled: vec![( + "example".to_owned(), + false, + PathBuf::from("mods/example.jar.disabled"), + )], + removed: Vec::new(), + added: Vec::new(), + }); + + let update = state.drain_watcher(); + + assert!(!update.requires_reconcile); + assert_eq!(update.toggles.len(), 1); + assert_eq!( + update.toggles[0].old_path, + PathBuf::from("mods/example.jar") + ); +} + +#[test] +fn content_watcher_replaces_a_version_without_removing_its_row() { + let mut state = ContentListState::default(); + let mut installed = entry("Example Mod"); + installed.file_stem = "example-1.0".to_owned(); + installed.path = PathBuf::from("mods/example-1.0.jar"); + installed.description = "Cached description".to_owned(); + installed.provider_description = true; + installed.icon_bytes = Some(vec![1, 2, 3]); + state.entries.push(installed); + *state.watcher_diff.lock().unwrap() = Some(super::WatcherDiff { + toggled: Vec::new(), + removed: vec!["example-1.0".to_owned()], + added: Vec::new(), + }); + + state.drain_watcher(); + assert_eq!(state.entries.len(), 1); + + let mut replacement = entry("Example Mod"); + replacement.file_stem = "example-2.0".to_owned(); + replacement.path = PathBuf::from("mods/example-2.0.jar"); + *state.watcher_diff.lock().unwrap() = Some(super::WatcherDiff { + toggled: Vec::new(), + removed: Vec::new(), + added: vec![replacement], + }); + + state.drain_watcher(); + + assert_eq!(state.entries.len(), 1); + assert_eq!(state.entries[0].path, PathBuf::from("mods/example-2.0.jar")); + assert_eq!(state.entries[0].description, "Cached description"); + assert_eq!(state.entries[0].icon_bytes, Some(vec![1, 2, 3])); +} + +#[test] +fn content_watcher_removes_a_file_after_the_replacement_grace_period() { + let mut state = ContentListState::default(); + let mut installed = entry("Removed Mod"); + installed.file_stem = "removed".to_owned(); + installed.path = PathBuf::from("mods/removed.jar"); + state.entries.push(installed); + state.pending_removals.insert( + "removed".to_owned(), + std::time::Instant::now() - super::REMOVAL_GRACE, + ); + + let update = state.drain_watcher(); + + assert!(state.entries.is_empty()); + assert!(update.requires_reconcile); +} + +#[test] +fn irrelevant_watcher_paths_do_not_emit_an_empty_diff() { + let temp = tempfile::tempdir().unwrap(); + let known = Arc::new(Mutex::new(HashMap::new())); + let paths = vec![temp.path().join("notes.txt")]; + assert!(diff_event_paths(temp.path(), &paths, ".jar", None, &known).is_none()); +} + +#[test] +fn square_columns_follow_terminal_cell_ratio() { + assert_eq!(square_icon_columns(3, (8, 16)), 6); + assert_eq!(square_icon_columns(3, (8, 18)), 7); + assert_eq!(square_icon_columns(6, (8, 18)), 14); +} + +#[test] +fn square_columns_handle_missing_cell_size() { + assert_eq!(square_icon_columns(3, (0, 0)), 3); +} + +#[test] +fn title_badge_is_rendered_after_a_small_gap() { + let label_style = Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD); + let spans = title_suffix_spans(Some("Installed"), Style::default(), label_style); + let text = spans + .iter() + .map(|span| span.content.as_ref()) + .collect::>() + .concat(); + + assert_eq!(text, " Installed "); + assert_eq!(spans[1].style, label_style); + assert!(title_suffix_spans(None, Style::default(), label_style).is_empty()); +} + +#[test] +fn title_suffix_keeps_label_style_after_the_row_background_is_applied() { + let label_style = Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD); + let text = Text::from(Line::from(title_suffix_spans( + Some("downloads"), + Style::default(), + label_style, + ))) + .style(Style::default().bg(Color::Black)); + let area = Rect::new(0, 0, 20, 1); + let mut buffer = Buffer::empty(area); + + text.render(area, &mut buffer); + + let label_cell = buffer.cell((5, 0)).unwrap(); + assert_eq!(label_cell.fg, Color::Black); + assert_eq!(label_cell.bg, Color::Cyan); + assert!(label_cell.modifier.contains(Modifier::BOLD)); +} + +#[test] +fn world_modes_use_distinct_theme_roles() { + let theme = crate::config::theme::THEME.as_ref(); + assert_eq!( + world_game_mode_color(WorldGameMode::Survival), + theme.success() + ); + assert_eq!(world_game_mode_color(WorldGameMode::Creative), theme.info()); + assert_eq!( + world_game_mode_color(WorldGameMode::Adventure), + theme.warning() + ); + assert_eq!( + world_game_mode_color(WorldGameMode::Spectator), + theme.text_dim() + ); + assert_eq!( + world_game_mode_color(WorldGameMode::Hardcore), + theme.error() + ); +} + +#[test] +fn descriptions_are_ellipsized_to_the_available_cell_width() { + assert_eq!(ellipsize("short", 5), "short"); + assert_eq!(ellipsize("a longer description", 10), "a longe..."); + assert_eq!(ellipsize("narrow", 3), "..."); + assert_eq!(ellipsize("narrow", 2), ".."); + assert_eq!(ellipsize("界界界", 5), "界..."); +} + +#[test] +fn description_width_reserves_the_row_chrome() { + assert_eq!(available_description_width(100, 6, true), 91); + assert_eq!(available_description_width(100, 0, false), 98); + assert_eq!(available_description_width(4, 6, true), 0); +} + +#[test] +fn description_width_reserves_the_download_metadata() { + assert_eq!(description_text_width(40, 14, true), 25); + assert_eq!(description_text_width(10, 14, true), 0); + assert_eq!(description_text_width(40, 0, true), 40); +} + +#[test] +fn footer_metadata_is_right_aligned_without_a_separator() { + let mut spans = vec![Span::raw("Description")]; + spans.extend(right_aligned_footer_spans( + 30, + "Description", + true, + vec![Span::raw("1.2K downloads")], + )); + let line = Line::from(spans); + + assert_eq!(line.width(), 30); + assert_eq!(line.to_string(), "Description 1.2K downloads"); +} + +#[test] +fn version_changes_render_as_two_labels_with_a_directional_arrow() { + let spans = version_change_spans("1.0", "2.0"); + + assert_eq!(Line::from(spans).to_string(), " 1.0 ➜ 2.0 "); +} + +#[test] +fn content_stream_inserts_entries_and_icons_incrementally() { + let mut state = ContentListState::default(); + let stream = state.start_stream("remote"); + + assert!(stream.send(entry("Zulu"))); + state.drain_pending(); + assert_eq!(state.entries[0].name, "Zulu"); + assert!(!state.loading); + + assert!(stream.send(entry("Alpha"))); + assert!(stream.send_icon("alpha".to_owned(), PathBuf::from("alpha"), vec![1, 2, 3],)); + state.drain_pending(); + + assert_eq!( + state + .entries + .iter() + .map(|entry| entry.name.as_str()) + .collect::>(), + ["Alpha", "Zulu"] + ); + assert_eq!( + state.entries[0].icon_bytes.as_deref(), + Some([1, 2, 3].as_slice()) + ); +} + +#[test] +fn source_stream_preserves_remote_result_order() { + let mut state = ContentListState::default(); + let stream = state.start_source_stream("remote"); + assert!(stream.send(entry("Zulu"))); + assert!(stream.send(entry("Alpha"))); + + state.drain_pending(); + + assert_eq!( + state + .entries + .iter() + .map(|entry| entry.name.as_str()) + .collect::>(), + ["Zulu", "Alpha"] + ); +} + +#[test] +fn source_refresh_reconciles_without_rebuilding_unchanged_entries() { + let mut state = ContentListState::default(); + let initial = state.start_source_stream("remote"); + let mut alpha = entry("Alpha"); + alpha.icon_bytes = Some(vec![1, 2, 3]); + assert!(initial.upsert(alpha)); + assert!(initial.upsert(entry("Beta"))); + assert!(initial.upsert(entry("Gamma"))); + state.drain_pending(); + state.list_state.selected = Some(0); + + let refresh = state.refresh_source_stream("remote"); + let mut alpha_update = entry("Alpha"); + alpha_update.description = "Updated".to_owned(); + assert!(refresh.upsert(alpha_update)); + assert!(refresh.upsert(entry("Delta"))); + assert!(refresh.retain(HashSet::from(["alpha".to_owned(), "delta".to_owned(),]))); + state.drain_pending(); + + assert_eq!( + state + .entries + .iter() + .map(|entry| entry.name.as_str()) + .collect::>(), + ["Alpha", "Delta"] + ); + assert_eq!(state.entries[0].description, "Updated"); + assert_eq!( + state.entries[0].icon_bytes.as_deref(), + Some([1, 2, 3].as_slice()) + ); + assert_eq!(state.list_state.selected, Some(0)); + assert!(!state.loading); +} + +#[test] +fn provider_icons_are_requested_only_for_visible_missing_icons() { + let mut state = ContentListState::default(); + let mut visible = entry("Visible"); + visible.icon_lines = Some(crate::instance::content::fallback_icon()); + visible.provider_project = Some(crate::instance::ProviderProject { + provider: "modrinth".to_owned(), + project_id: "visible-project".to_owned(), + version_id: "version".to_owned(), + }); + let mut offscreen = entry("Offscreen"); + offscreen.icon_lines = Some(crate::instance::content::fallback_icon()); + offscreen.provider_project = Some(crate::instance::ProviderProject { + provider: "modrinth".to_owned(), + project_id: "offscreen-project".to_owned(), + version_id: "version".to_owned(), + }); + state.entries = vec![visible, offscreen]; + state.rebuild_display_metadata(); + + let projects = state.visible_provider_projects(&[0, 1], 3); + + assert_eq!(projects.len(), 1); + assert_eq!(projects[0].project_id, "visible-project"); +} + +#[test] +fn complete_local_pack_metadata_does_not_request_provider_fallbacks() { + let mut state = ContentListState::default(); + let mut visible = entry("Visible"); + visible.description = "Local description".to_owned(); + visible.icon_bytes = Some(vec![1, 2, 3]); + visible.icon_lines = Some(crate::instance::content::fallback_icon()); + visible.provider_project = Some(crate::instance::ProviderProject { + provider: "modrinth".to_owned(), + project_id: "visible-project".to_owned(), + version_id: "version".to_owned(), + }); + state.entries = vec![visible]; + state.rebuild_display_metadata(); + + assert!(state.visible_provider_projects(&[0], 3).is_empty()); +} + +#[tokio::test] +async fn streamed_entries_wait_for_their_rendered_icon() { + let mut state = ContentListState::default(); + let mut png = std::io::Cursor::new(Vec::new()); + image::DynamicImage::new_rgba8(1, 1) + .write_to(&mut png, image::ImageFormat::Png) + .unwrap(); + let mut with_icon = entry("With icon"); + with_icon.icon_bytes = Some(png.into_inner()); + let stream = state.start_stream("local"); + + assert!(stream.send(with_icon)); + state.drain_pending(); + assert!(state.filtered_indices().is_empty()); + + let picker = ratatui_image::picker::Picker::halfblocks(); + state.request_image_loads(&picker); + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + state.drain_image_loads(&picker); + if !state.filtered_indices().is_empty() { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("icon render completed"); + + assert_eq!(state.filtered_indices(), vec![0]); +} + +#[test] +fn streamed_entries_without_icons_are_visible_immediately() { + let mut state = ContentListState::default(); + let stream = state.start_stream("local"); + + assert!(stream.send(entry("Without icon"))); + state.drain_pending(); + + assert_eq!(state.filtered_indices(), vec![0]); +} + +#[test] +fn rendering_visible_entries_restores_the_first_selection() { + let mut state = ContentListState::default(); + state.entries.push(entry("First")); + state.rebuild_display_metadata(); + let picker = ratatui_image::picker::Picker::halfblocks(); + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(40, 5)).unwrap(); + + terminal + .draw(|frame| { + super::render( + frame, + frame.area(), + &mut state, + true, + "Loading...", + "Empty", + &picker, + false, + false, + ); + }) + .unwrap(); + + assert_eq!(state.list_state.selected, Some(0)); +} + +#[test] +fn multiline_rendering_uses_the_space_beside_large_icons() { + let mut world = entry("World"); + world.world_details = Some(WorldDetails { + game_mode: Some(WorldGameMode::Survival), + last_played: None, + minecraft_version: Some("1.21.1".to_owned()), + size: Some("2.0 MB".to_owned()), + datapacks: Vec::new(), + }); + world.icon_lines = Some(crate::instance::content::fallback_icon_large()); + let mut state = ContentListState::default(); + state.entries.push(world); + state.rebuild_display_metadata(); + let picker = ratatui_image::picker::Picker::halfblocks(); + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(70, 6)).unwrap(); + + terminal + .draw(|frame| { + super::render( + frame, + frame.area(), + &mut state, + true, + "Loading...", + "Empty", + &picker, + false, + true, + ); + }) + .unwrap(); + + insta::assert_snapshot!(terminal.backend().to_string()); +} + +#[test] +fn pager_tracks_viewport_pages_and_jumps_to_their_first_item() { + assert_eq!( + super::pager_pages(0, 12), + vec![Some(0), Some(1), Some(2), Some(3)] + ); + assert_eq!( + super::pager_pages(4, 12), + vec![Some(0), None, Some(3), Some(4), Some(5)] + ); + assert_eq!( + super::pager_pages(11, 12), + vec![Some(0), None, Some(9), Some(10), Some(11)] + ); + + let area = Rect::new(0, 0, 40, 13); + let (list_area, pager) = super::pagination_layout(area, 5); + assert_eq!(list_area, area); + assert_eq!(pager.map(|(_, page_size)| page_size), Some(4)); + assert!(super::pagination_layout(area, 4).1.is_none()); + + let mut state = ContentListState { + entries: (1..=10) + .map(|number| { + let mut item = entry(&format!("Project {number}")); + item.icon_lines = Some(crate::instance::content::fallback_icon()); + item + }) + .collect(), + ..ContentListState::default() + }; + state.rebuild_display_metadata(); + let picker = ratatui_image::picker::Picker::halfblocks(); + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(40, 13)).unwrap(); + + terminal + .draw(|frame| { + super::render( + frame, + frame.area(), + &mut state, + true, + "Loading...", + "Empty", + &picker, + true, + false, + ); + }) + .unwrap(); + + let pagination = state.pagination.as_ref().expect("pager"); + assert_eq!(pagination.page_size, 4); + assert_eq!(pagination.page_count, 3); + let page_two = pagination + .hits + .iter() + .find(|(_, page)| *page == 1) + .map(|(area, _)| (area.x, area.y)) + .expect("page two hit target"); + assert!(state.click_page(page_two.0, page_two.1)); + assert_eq!(state.list_state.selected, Some(4)); + assert!(state.next_page()); + assert_eq!(state.list_state.selected, Some(8)); + assert!(state.previous_page()); + assert_eq!(state.list_state.selected, Some(4)); +} + +#[test] +fn manifest_metadata_keeps_an_embedded_icon_renderer() { + let minecraft_dir = PathBuf::from("instance/minecraft"); + let mut state = ContentListState::default(); + let mut installed = entry("Installed"); + installed.path = minecraft_dir.join("mods/installed.jar"); + installed.icon_bytes = Some(vec![1, 2, 3]); + state.entries.push(installed); + let picker = ratatui_image::picker::Picker::halfblocks(); + state.image_protocols.insert( + "installed".to_owned(), + picker.new_resize_protocol(image::DynamicImage::new_rgba8(1, 1)), + ); + let mut manifest = crate::instance::ContentManifest::default(); + manifest.upsert(crate::instance::ContentFileRecord { + relative_path: PathBuf::from("mods/installed.jar"), + kind: crate::instance::ContentKind::Mod, + enabled: true, + fingerprint: crate::instance::FileFingerprint { + size: 3, + modified_ns: 1, + hashes: Default::default(), + }, + resolution: crate::instance::Resolution::Resolved { + project: crate::instance::ProviderProject { + provider: "modrinth".to_owned(), + project_id: "project".to_owned(), + version_id: "version".to_owned(), + }, + }, + provider_aliases: Vec::new(), + provider_checks: Vec::new(), + required_dependencies: Vec::new(), + automatic_dependency: false, + cleanup_eligible: false, + }); + + state.apply_manifest(&manifest, &minecraft_dir, crate::instance::ContentKind::Mod); + + assert!(state.image_protocols.contains_key("installed")); +} + +#[test] +fn provider_metadata_fills_a_missing_installed_description() { + let mut state = ContentListState::default(); + let mut installed = entry("Shader"); + installed.provider_project = Some(crate::instance::ProviderProject { + provider: "modrinth".to_owned(), + project_id: "shader-project".to_owned(), + version_id: "version".to_owned(), + }); + state.entries.push(installed); + state + .pending_provider_icons + .lock() + .unwrap() + .push(super::PendingProviderIcon { + provider: "modrinth".to_owned(), + project_id: "shader-project".to_owned(), + bytes: Vec::new(), + description: "A cached shader description".to_owned(), + }); + + assert!(state.drain_provider_icons()); + assert_eq!(state.entries[0].description, "A cached shader description"); + assert!(state.entries[0].provider_description); + assert_eq!(state.entries[0].title_suffix, None); +} + +#[tokio::test] +async fn provider_metadata_loads_from_cache_without_network() { + let temp = tempfile::tempdir().unwrap(); + let mut png = std::io::Cursor::new(Vec::new()); + image::DynamicImage::new_rgba8(1, 1) + .write_to(&mut png, image::ImageFormat::Png) + .unwrap(); + let png = png.into_inner(); + let icon_path = crate::storage::MetadataPaths::new(temp.path()) + .provider_icons("modrinth") + .join("cached-project.img"); + std::fs::create_dir_all(icon_path.parent().unwrap()).unwrap(); + std::fs::write(&icon_path, &png).unwrap(); + let project_path = crate::storage::MetadataPaths::new(temp.path()) + .provider_projects("modrinth") + .join("cached-project.json"); + std::fs::create_dir_all(project_path.parent().unwrap()).unwrap(); + std::fs::write( + project_path, + serde_json::to_vec(&crate::net::modrinth::ProjectInfo { + id: "cached-project".to_owned(), + slug: "cached-project".to_owned(), + title: "Cached project".to_owned(), + description: "Cached description".to_owned(), + body: String::new(), + icon_url: None, + categories: Vec::new(), + additional_categories: Vec::new(), + project_type: "mod".to_owned(), + loaders: Vec::new(), + }) + .unwrap(), + ) + .unwrap(); + + let installed = crate::instance::ProviderProject { + provider: "modrinth".to_owned(), + project_id: "cached-project".to_owned(), + version_id: "cached-version".to_owned(), + }; + let (bytes, description) = + load_provider_metadata(&crate::net::HttpClient::new(), temp.path(), &installed) + .await + .unwrap(); + + assert_eq!(bytes, png); + assert_eq!(description, "Cached description"); +} diff --git a/src/tui/tests/widgets/content/snapshots/rmcl__tui__widgets__content__list__tests__multiline_rendering_uses_the_space_beside_large_icons.snap b/src/tui/tests/widgets/content/snapshots/rmcl__tui__widgets__content__list__tests__multiline_rendering_uses_the_space_beside_large_icons.snap new file mode 100644 index 0000000..5f2409c --- /dev/null +++ b/src/tui/tests/widgets/content/snapshots/rmcl__tui__widgets__content__list__tests__multiline_rendering_uses_the_space_beside_large_icons.snap @@ -0,0 +1,10 @@ +--- +source: src/tui/tests/widgets/content/list.rs +expression: terminal.backend().to_string() +--- +"▌▄▄▄▄▄▄▄▄▄▄▄▄ World Survival " +"▌▄▄▄▄▄▄▄▄▄▄▄▄ 1.21.1 • 2.0 MB Never played " +"▌▄▄▄▄▄▄▄▄▄▄▄▄ " +"▌▄▄▄▄▄▄▄▄▄▄▄▄ " +"▌▄▄▄▄▄▄▄▄▄▄▄▄ " +"▌▄▄▄▄▄▄▄▄▄▄▄▄ " diff --git a/src/tui/tests/widgets/content/snapshots/rmcl__tui__widgets__content__update__tests__update_review_reuses_content_rows.snap b/src/tui/tests/widgets/content/snapshots/rmcl__tui__widgets__content__update__tests__update_review_reuses_content_rows.snap new file mode 100644 index 0000000..f0f3bab --- /dev/null +++ b/src/tui/tests/widgets/content/snapshots/rmcl__tui__widgets__content__update__tests__update_review_reuses_content_rows.snap @@ -0,0 +1,28 @@ +--- +source: src/tui/tests/widgets/content/update.rs +expression: terminal.backend() +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" ╭Update installed content──────────────────────────────────╮ " +" │▌▄▄▄▄▄▄ Example Mod Update │ " +" │▌▄▄▄▄▄▄ Existing project description 1.0 ➜ 2.0 │ " +" │▌▄▄▄▄▄▄ │ " +" ╰───────────[j/k] navigate [Enter] update all [Esc] close╯ " +" " +" " +" " +" " +" " +" " +" " +" " +" " diff --git a/src/tui/tests/widgets/content/tabs.rs b/src/tui/tests/widgets/content/tabs.rs new file mode 100644 index 0000000..dc2ed33 --- /dev/null +++ b/src/tui/tests/widgets/content/tabs.rs @@ -0,0 +1,115 @@ +use super::*; + +#[test] +fn mode_labels_have_the_same_rendered_width() { + assert_eq!( + mode_label(ContentMode::Installed).chars().count(), + mode_label(ContentMode::Discover).chars().count() + ); +} + +#[test] +fn discovery_navigation_includes_datapacks() { + assert_eq!( + ContentTab::Shaders.next_for_mode(ContentMode::Discover), + ContentTab::DataPacks + ); + assert_eq!( + ContentTab::DataPacks.next_for_mode(ContentMode::Discover), + ContentTab::Mods + ); + assert_eq!( + ContentTab::Mods.previous_for_mode(ContentMode::Discover), + ContentTab::DataPacks + ); +} + +#[test] +fn discovery_navigation_recovers_from_hidden_local_tab() { + assert_eq!( + ContentTab::Logs.next_for_mode(ContentMode::Discover), + ContentTab::ResourcePacks + ); +} + +#[test] +fn discovery_version_rows_only_show_the_version_number() { + let version = crate::net::modrinth::VersionInfo { + id: "version-id".to_owned(), + project_id: "project-id".to_owned(), + name: "A descriptive release title".to_owned(), + version_number: "3.2.4-fabric-26.1".to_owned(), + game_versions: vec![], + loaders: vec![], + version_type: crate::net::modrinth::VersionType::Release, + dependencies: Vec::new(), + date_published: String::new(), + files: vec![], + }; + + assert_eq!(discovery_version_label(&version), "3.2.4-fabric-26.1"); +} + +#[test] +fn discovery_confirmation_popup_fits_its_summary() { + assert_eq!( + version_popup_height(false, None, false), + VERSION_POPUP_HEIGHT + ); + assert_eq!(version_popup_height(true, None, false), 6); +} + +#[test] +fn discovery_version_popup_renders_over_a_project_page() { + use crate::instance::ContentKind; + use crate::net::modrinth::DiscoveryProject; + use ratatui::{Terminal, backend::TestBackend}; + + let project = DiscoveryProject { + id: "project".to_owned(), + slug: "project".to_owned(), + title: "Project".to_owned(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }; + let mut state = DiscoveryState::new(ContentKind::Mod); + state + .list + .entries + .push(crate::tui::widgets::content::discovery::project_entry( + project, None, + )); + state.list.list_state.selected = Some(0); + state.begin_project_page(); + state.begin_versions(); + + let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap(); + let picker = ratatui_image::picker::Picker::halfblocks(); + terminal + .draw(|frame| render_discovery_popup(frame, frame.area(), &mut state, &picker)) + .unwrap(); + + assert!(format!("{}", terminal.backend()).contains("Install Project")); +} + +#[test] +fn confirmation_metadata_is_human_readable() { + assert_eq!( + confirmation_loaders(&["fabric".to_owned(), "neoforge".to_owned()]), + "Fabric, NeoForge" + ); + assert_eq!( + confirmation_release_date("2026-07-26T14:30:00Z"), + "2026-07-26" + ); + assert_eq!(confirmation_values(&[]), "Unknown"); +} + +#[test] +fn empty_world_picker_only_offers_exit_actions() { + let keybinds = world_picker_keybinds(false); + assert!(!keybinds.iter().any(|(key, _)| *key == "Enter")); + assert!(!keybinds.iter().any(|(key, _)| *key == "j/k")); +} diff --git a/src/tui/tests/widgets/content/update.rs b/src/tui/tests/widgets/content/update.rs new file mode 100644 index 0000000..9dc6007 --- /dev/null +++ b/src/tui/tests/widgets/content/update.rs @@ -0,0 +1,115 @@ +use std::path::PathBuf; + +use ratatui::{Terminal, backend::TestBackend}; + +use super::*; + +fn entry(name: &str, path: &str) -> ContentEntry { + ContentEntry { + file_stem: name.to_lowercase(), + name: name.to_owned(), + source_slug: None, + installed_path: Some(PathBuf::from(path)), + provider_project: None, + world_details: None, + title_suffix: None, + footer_label: None, + footer_change: None, + description: "Existing project description".to_owned(), + enabled: true, + icon_bytes: None, + provider_icon: false, + provider_description: false, + path: PathBuf::from(path), + icon_lines: Some(crate::instance::content::fallback_icon()), + } +} + +fn version(number: &str) -> crate::net::modrinth::VersionInfo { + crate::net::modrinth::VersionInfo { + id: number.to_owned(), + project_id: "project".to_owned(), + name: number.to_owned(), + version_number: number.to_owned(), + game_versions: Vec::new(), + loaders: Vec::new(), + version_type: crate::net::modrinth::VersionType::Release, + dependencies: Vec::new(), + date_published: String::new(), + files: Vec::new(), + } +} + +fn plan() -> BulkUpdatePlan { + BulkUpdatePlan { + dependency_plan: crate::instance::content::dependencies::DependencyPlan { + items: Vec::new(), + root_count: 0, + optional_dependencies: 0, + }, + roots: vec![crate::instance::content::updates::PlannedRootUpdate { + title: "Example Mod".to_owned(), + installed_path: PathBuf::from("mods/example.jar"), + current_version: "1.0".to_owned(), + target: version("2.0"), + }], + conflicts: Vec::new(), + } +} + +fn snapshot() -> UpdateSnapshot { + UpdateSnapshot { + game_version: "1.21.1".to_owned(), + loader: crate::instance::ModLoader::Fabric, + inventory: Vec::new(), + updates: Vec::new(), + failures: Vec::new(), + } +} + +#[test] +fn update_review_reuses_content_rows() { + let mut state = State::checking( + ContentKind::Mod, + None, + vec![entry("Example Mod", "mods/example.jar")], + ); + state.push(PendingResult::Prepared(snapshot(), plan())); + state.drain(); + let picker = ratatui_image::picker::Picker::halfblocks(); + let mut terminal = Terminal::new(TestBackend::new(100, 24)).unwrap(); + + terminal + .draw(|frame| render(frame, &mut state, &picker)) + .unwrap(); + + insta::assert_snapshot!(terminal.backend()); +} + +#[test] +fn update_conflicts_explain_why_the_item_was_not_updated() { + let mut plan = plan(); + plan.conflicts + .push(crate::instance::content::updates::UpdateConflict { + title: "Example Mod".to_owned(), + installed_path: PathBuf::from("mods/example.jar"), + reason: "Parse error: Conflicting selected versions for 'Library': '1.0' and '2.0'" + .to_owned(), + }); + let mut state = State::checking( + ContentKind::Mod, + None, + vec![entry("Example Mod", "mods/example.jar")], + ); + state.push(PendingResult::Prepared(snapshot(), plan)); + state.drain(); + + assert_eq!( + state.list.entries[0].title_suffix.as_deref(), + Some("Skipped") + ); + assert_eq!( + state.list.entries[0].description, + "Other selected updates require different versions of Library.\nThis mod was left unchanged; update it separately with v." + ); +} diff --git a/src/tui/tests/widgets/instances.rs b/src/tui/tests/widgets/instances.rs new file mode 100644 index 0000000..a37d304 --- /dev/null +++ b/src/tui/tests/widgets/instances.rs @@ -0,0 +1,81 @@ +use super::*; + +#[test] +fn format_relative_time_none_returns_never_played() { + assert_eq!(format_relative_time(None), "Never played"); +} + +// each #[case] picks a "seconds ago" value that lands in exactly one +// bucket of the match. mutating any bucket boundary (e.g. 3600 to 3601, +// or "minutes" to "seconds") makes one of these cases fail. +#[rstest::rstest] +#[case::just_now(0, "Just now")] +#[case::just_now_upper(59, "Just now")] +#[case::minutes(60, "1 minute ago")] +#[case::minutes_upper(3599, "59 minutes ago")] +#[case::hours(3600, "1 hour ago")] +#[case::hours_upper(86_399, "23 hours ago")] +#[case::days(86_400, "1 day ago")] +#[case::days_upper(2_591_999, "29 days ago")] +#[case::months(2_592_000, "1 month ago")] +#[case::months_upper(31_535_999, "12 months ago")] +#[case::over_a_year(31_536_000, "Over a year ago")] +fn format_relative_time_buckets(#[case] seconds_ago: i64, #[case] expected: &str) { + let dt = chrono::Utc::now() - chrono::Duration::seconds(seconds_ago); + assert_eq!(format_relative_time(Some(dt)), expected); +} + +use crate::instance::models::{InstanceConfig, ModLoader}; +use ratatui::Terminal; +use ratatui::backend::TestBackend; + +fn synthetic_instance(name: &str) -> InstanceConfig { + // last_played intentionally None so the rendered text is the + // deterministic "Never played" string. anything else would make the + // snapshot drift relative to chrono::Utc::now(). + InstanceConfig { + name: name.to_string(), + game_version: "1.20.1".to_string(), + loader: ModLoader::Vanilla, + loader_version: None, + created: chrono::Utc::now(), + last_played: None, + java_path: None, + memory_max: None, + memory_min: None, + jvm_args: vec![], + resolution: None, + config_sync_profile: None, + modpack_source: None, + } +} + +#[test] +fn instances_list_renders_three_instances() { + let mut state = State::with_instances(vec![ + synthetic_instance("Vanilla 1.20.1"), + synthetic_instance("Forge Pack"), + synthetic_instance("Fabric Test"), + ]); + + let backend = TestBackend::new(40, 12); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| render(f, f.area(), FocusedArea::Instances, &mut state)) + .unwrap(); + + insta::assert_snapshot!(terminal.backend()); +} + +#[test] +fn instances_list_renders_empty() { + let mut state = State::with_instances(vec![]); + + let backend = TestBackend::new(40, 8); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| render(f, f.area(), FocusedArea::Instances, &mut state)) + .unwrap(); + + insta::assert_snapshot!(terminal.backend()); +} diff --git a/src/tui/tests/widgets/logs_viewer.rs b/src/tui/tests/widgets/logs_viewer.rs new file mode 100644 index 0000000..e8b2412 --- /dev/null +++ b/src/tui/tests/widgets/logs_viewer.rs @@ -0,0 +1,47 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use super::*; + +fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) +} + +#[test] +fn list_and_viewer_navigation_loads_and_scrolls_the_selected_log() { + let temp = tempfile::tempdir().unwrap(); + let first = temp.path().join("first.log"); + let second = temp.path().join("second.log"); + std::fs::write(&first, "first").unwrap(); + std::fs::write(&second, "second\nline").unwrap(); + + let mut state = LogsState { + entries: vec![ + LogFileEntry { + name: "first.log".to_owned(), + path: first, + }, + LogFileEntry { + name: "second.log".to_owned(), + path: second, + }, + ], + ..Default::default() + }; + state.list_state.selected = Some(0); + + assert!(handle_key(&key(KeyCode::Down), &mut state)); + assert_eq!(state.list_state.selected, Some(1)); + assert_eq!(state.viewer_lines, ["second", "line"]); + + assert!(handle_key(&key(KeyCode::Enter), &mut state)); + assert!(state.viewer_focused); + state.viewer_max_scroll = 4; + + assert!(handle_key(&key(KeyCode::Char('G')), &mut state)); + assert_eq!(state.viewer_scroll, 4); + assert!(handle_key(&key(KeyCode::Char('g')), &mut state)); + assert_eq!(state.viewer_scroll, 0); + + assert!(handle_key(&key(KeyCode::Esc), &mut state)); + assert!(!state.viewer_focused); +} diff --git a/src/tui/tests/widgets/markdown.rs b/src/tui/tests/widgets/markdown.rs new file mode 100644 index 0000000..b7d40ff --- /dev/null +++ b/src/tui/tests/widgets/markdown.rs @@ -0,0 +1,642 @@ +use ratatui::{Terminal, backend::TestBackend}; + +fn formatted_text(source: &str, width: u16) -> ratatui::text::Text<'static> { + super::external_markdown_text(source, width).0 +} + +#[test] +fn html_images_are_extracted_and_raw_html_is_removed() { + let document = super::Document::new( + "Sodium", + "
\"Comparison\"
\n\n

Installation

", + ); + assert_eq!( + document.image_urls(), + vec!["https://cdn.example/comparison.png"] + ); + assert!(matches!( + &document.blocks[0], + super::DocumentBlock::ImageRow { alignment, .. } + if *alignment == super::ImageAlignment::Center + )); + assert!(document.blocks.iter().any( + |block| matches!(block, super::DocumentBlock::Text(text) if text.source.contains("Installation") && !text.source.contains("

")) + )); +} + +#[test] +fn inline_html_images_use_the_document_image_pipeline() { + let document = super::Document::new( + "Euphoria Patches", + r#"Euphoria logo A shader add-on"#, + ); + assert_eq!(document.image_urls(), vec!["https://cdn.example/logo.png"]); + assert!(matches!( + &document.blocks[0], + super::DocumentBlock::ImageRow { images, .. } + if images[0].alt == "Euphoria logo" + )); + assert!(document.blocks.iter().any( + |block| matches!(block, super::DocumentBlock::Text(text) if text.source.contains("A shader add-on") && !text.source.contains(""#, + ); + assert!(matches!( + &document.blocks[0], + super::DocumentBlock::ImageRow { images, .. } + if images[0].size == super::ImageSizeHint { + width: Some(30), + height: None, + } + )); +} + +#[test] +fn html_width_limits_the_rendered_terminal_image() { + let image = super::DocumentImage { + load: super::ImageLoad::Ready(image::DynamicImage::new_rgba8(300, 300)), + prepared: None, + pending: None, + }; + let picker = ratatui_image::picker::Picker::halfblocks(); + assert_eq!( + super::image_dimensions( + &image, + super::ImageSizeHint { + width: Some(30), + height: None, + }, + 120, + &picker, + ), + (3, 2) + ); +} + +#[test] +fn inserted_html_keeps_its_content_and_hides_tag_shells() { + let document = super::Document::new( + "Project", + "Support Reimagined and Unbound.", + ); + let source = document + .blocks + .iter() + .find_map(|block| match block { + super::DocumentBlock::Text(text) => Some(text.source.as_str()), + _ => None, + }) + .unwrap(); + let text = formatted_text(source, 80); + let rendered = text + .lines + .iter() + .flat_map(|line| &line.spans) + .collect::>(); + assert!(rendered.iter().all(|span| !span.content.contains('<'))); + assert!( + ["Reimagined", "Unbound"] + .iter() + .all(|expected| rendered.iter().any(|span| span.content.contains(expected))) + ); +} + +#[test] +fn nested_inline_html_uses_dom_conversion_for_links_and_emphasis() { + let document = super::Document::new( + "Project", + concat!( + r#""#, + "Supported" + ), + ); + let source = document + .blocks + .iter() + .find_map(|block| match block { + super::DocumentBlock::Text(text) => Some(text.source.as_str()), + _ => None, + }) + .unwrap(); + assert!(source.contains("[**Supported**](https://example.com)")); + assert!(!source.contains('<')); + + let text = formatted_text(source, 80); + let supported = text + .lines + .iter() + .flat_map(|line| &line.spans) + .find(|span| span.content.contains("Supported")) + .unwrap(); + assert!( + supported + .style + .add_modifier + .contains(ratatui::style::Modifier::BOLD) + ); + assert!( + supported + .style + .add_modifier + .contains(ratatui::style::Modifier::UNDERLINED) + ); +} + +#[test] +fn markdown_images_inside_links_do_not_leave_the_link_destination() { + let document = super::Document::new( + "Project", + "[![Support](https://cdn.example/button.png)](https://example.com/donate)", + ); + assert_eq!(document.blocks.len(), 1); + assert!(matches!( + &document.blocks[0], + super::DocumentBlock::ImageRow { images, alignment } + if images.len() == 1 && images[0].url == "https://cdn.example/button.png" + && images[0].link.as_deref() == Some("https://example.com/donate") + && *alignment == super::ImageAlignment::Left + )); +} + +#[test] +fn visible_link_text_does_not_include_the_destination() { + let text = formatted_text("[buying me a coffee](https://example.com/donate)", 80); + let rendered = text + .lines + .iter() + .flat_map(|line| &line.spans) + .map(|span| span.content.as_ref()) + .collect::(); + assert_eq!(rendered, "buying me a coffee"); +} + +#[test] +fn adjacent_images_share_a_row() { + let document = super::Document::new( + "Project", + "[![Support](https://cdn.example/support.svg)](https://example.com) [![Chat](https://cdn.example/chat.svg)](https://example.com/chat)", + ); + assert!(matches!( + &document.blocks[0], + super::DocumentBlock::ImageRow { images, .. } if images.len() == 2 + )); +} + +#[test] +fn explicitly_centered_html_images_share_a_centered_row() { + let document = super::Document::new( + "Project", + r#"

"#, + ); + assert!(matches!( + &document.blocks[0], + super::DocumentBlock::ImageRow { images, alignment } + if images.len() == 2 && *alignment == super::ImageAlignment::Center + )); +} + +#[test] +fn only_explicitly_centered_project_images_are_centered() { + let document = super::Document::new( + "Project", + concat!( + "![Badge](https://cdn.example/badge.png)\n\n", + "
\n\n", + "![Loader](https://cdn.example/loader.png)" + ), + ); + let alignments = document + .blocks + .iter() + .filter_map(|block| match block { + super::DocumentBlock::ImageRow { alignment, .. } => Some(*alignment), + super::DocumentBlock::Text(_) => None, + }) + .collect::>(); + assert_eq!( + alignments, + [ + super::ImageAlignment::Left, + super::ImageAlignment::Center, + super::ImageAlignment::Left + ] + ); +} + +#[test] +fn headings_hide_markers_and_rules_use_terminal_lines() { + let text = formatted_text("## Known Issues\n\n---", 12); + let rendered = text + .lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>(); + assert!(rendered.iter().any(|line| line == "Known Issues")); + assert!(rendered.iter().any(|line| line == "────────────")); + assert!(!rendered.iter().any(|line| line == "---")); + assert!(!rendered.iter().any(|line| line.starts_with('#'))); +} + +#[test] +fn all_heading_levels_are_bold() { + let text = formatted_text("### Performance", 20); + let heading = text + .lines + .iter() + .find(|line| { + line.spans + .iter() + .any(|span| span.content.contains("Performance")) + }) + .unwrap(); + assert!(heading.spans.iter().any(|span| { + span.style + .add_modifier + .contains(ratatui::style::Modifier::BOLD) + })); +} + +#[test] +fn matching_leading_project_title_is_removed_from_the_description() { + let document = super::Document::new( + "ImmediatelyFast", + "# ImmediatelyFast\n\nImmediatelyFast is an open source Minecraft mod.", + ); + let rendered = document + .blocks + .iter() + .filter_map(|block| match block { + super::DocumentBlock::Text(text) => Some(text.source.as_str()), + _ => None, + }) + .collect::(); + assert_eq!(rendered.matches("ImmediatelyFast").count(), 1); + assert!(!rendered.contains("# ImmediatelyFast")); +} + +#[test] +fn unrelated_and_later_headings_are_kept() { + let document = super::Document::new( + "ImmediatelyFast", + "# Performance\n\nText\n\n## ImmediatelyFast", + ); + let rendered = document + .blocks + .iter() + .filter_map(|block| match block { + super::DocumentBlock::Text(text) => Some(text.source.as_str()), + _ => None, + }) + .collect::(); + assert!(rendered.contains("# Performance")); + assert!(rendered.contains("## ImmediatelyFast")); +} + +#[test] +fn lists_use_terminal_bullets() { + let text = formatted_text("- first\n - nested\n- second", 20); + let rendered = text + .lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>(); + assert_eq!(rendered, [" • first", " • nested", " • second"]); +} + +#[test] +fn wrapped_list_items_keep_a_hanging_indent() { + let text = formatted_text("- one two three four", 16); + let rendered = text + .lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>(); + assert_eq!(rendered, [" • one two", " three four"]); + assert!(text.lines.iter().all(|line| line.width() <= 16)); +} + +#[test] +fn markdown_headings_with_inline_html_are_converted_as_one_block() { + let document = super::Document::new( + "Project", + r#"### Follow me? example.com"#, + ); + let source = document + .blocks + .iter() + .find_map(|block| match block { + super::DocumentBlock::Text(text) => Some(text.source.as_str()), + _ => None, + }) + .unwrap(); + assert!(!source.contains('<'), "{source}"); + let rendered = formatted_text(source, 80) + .lines + .iter() + .flat_map(|line| &line.spans) + .map(|span| span.content.as_ref()) + .collect::(); + assert_eq!(rendered, "Follow me? example.com"); +} + +#[test] +fn html_lists_are_converted_as_one_document() { + let document = super::Document::new( + "Project", + "### Features\n
  • First feature.
  • Second feature.
", + ); + let source = document + .blocks + .iter() + .filter_map(|block| match block { + super::DocumentBlock::Text(text) => Some(text.source.as_str()), + _ => None, + }) + .collect::(); + assert!(source.contains("- **First feature**.")); + assert!(source.contains("- Second feature.")); +} + +#[test] +fn emphasis_survives_html_list_conversion_without_literal_markers() { + let document = super::Document::new( + "Project", + "
  • The effects are important and optional.
", + ); + let source = document + .blocks + .iter() + .find_map(|block| match block { + super::DocumentBlock::Text(text) => Some(text.source.as_str()), + _ => None, + }) + .unwrap(); + let text = formatted_text(source, 80); + let rendered = text + .lines + .iter() + .flat_map(|line| &line.spans) + .map(|span| span.content.as_ref()) + .collect::(); + assert!(!rendered.contains('*')); + assert!(rendered.contains("important")); + assert!(rendered.contains("optional")); +} + +#[test] +fn emphasis_next_to_html_tag_boundaries_does_not_leak_markers() { + let document = super::Document::new( + "Project", + concat!( + "
  • The effects are xaerominimap:no_cave_maps. ", + "The effects are neutral.
  • ", + "
  • Hostile and friendly mobs can be colored ", + "differently.Can also be displayed as icons.
" + ), + ); + let source = document + .blocks + .iter() + .find_map(|block| match block { + super::DocumentBlock::Text(text) => Some(text.source.as_str()), + _ => None, + }) + .unwrap(); + let rendered = formatted_text(source, 100) + .lines + .iter() + .flat_map(|line| &line.spans) + .map(|span| span.content.as_ref()) + .collect::(); + assert!(!rendered.contains('*'), "{rendered}"); +} + +#[test] +fn fenced_code_blocks_are_rendered_without_fences() { + let text = formatted_text("```json5\n{\n \"enabled\": true\n}\n```", 24); + let rendered = text + .lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>(); + assert!( + rendered + .iter() + .any(|line| line.contains("\"enabled\": true")) + ); + assert!(rendered.iter().all(|line| !line.contains("```"))); + let code_lines = text + .lines + .iter() + .filter(|line| line.width() == 24) + .collect::>(); + assert_eq!(code_lines.len(), 4); + assert!( + code_lines + .iter() + .all(|line| { line.style.bg == Some(crate::config::theme::THEME.as_ref().surface()) }) + ); +} + +#[test] +fn tables_wrap_cells_without_breaking_the_columns() { + let text = formatted_text( + "| Mod | Status | Note |\n| --- | --- | --- |\n| Player Animator | Supported | This means mods that use it like Better Combat and Emotecraft |", + 48, + ); + assert!(text.lines.len() > 3); + assert!(text.lines.iter().all(|line| line.width() <= 48)); + assert!( + text.lines + .iter() + .flat_map(|line| &line.spans) + .any(|span| span.content.contains("Emotecraft")) + ); + let column_offsets = text + .lines + .iter() + .filter_map(|line| { + let mut offset = 0; + let separators = line + .spans + .iter() + .filter_map(|span| { + let start = offset; + offset += span.width(); + span.content.contains('│').then_some(start) + }) + .collect::>(); + (!separators.is_empty()).then_some(separators) + }) + .collect::>(); + assert!( + column_offsets.windows(2).all(|pair| pair[0] == pair[1]), + "{column_offsets:?}\n{:#?}", + text.lines + .iter() + .map(|line| line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::()) + .collect::>() + ); +} + +#[test] +fn rendered_links_keep_click_targets_after_wrapping() { + let backend = TestBackend::new(24, 5); + let mut terminal = Terminal::new(backend).unwrap(); + let picker = ratatui_image::picker::Picker::halfblocks(); + let mut document = super::Document::new( + "Project", + "Open the [project documentation](https://example.com/docs) here.", + ); + let mut scroll = 0; + terminal + .draw(|frame| { + super::render(frame, frame.area(), &mut document, &mut scroll, &picker); + }) + .unwrap(); + let hit = document.link_hits.first().unwrap(); + assert_eq!( + document.link_at(hit.area.x, hit.area.y), + Some("https://example.com/docs") + ); +} + +#[test] +fn svg_images_are_rasterized_for_terminal_protocols() { + let image = super::decode_image( + br##" + + "##, + ) + .unwrap(); + assert_eq!(image.width(), 32); + assert_eq!(image.height(), 16); +} + +#[test] +fn gif_images_decode_their_first_frame() { + let image = super::decode_image(&[ + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, + 0x00, 0xff, 0xff, 0xff, 0x2c, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, + 0x02, 0x44, 0x01, 0x00, 0x3b, + ]) + .unwrap(); + assert_eq!(image.width(), 1); + assert_eq!(image.height(), 1); +} + +#[test] +fn oversized_images_are_rejected_before_decoding() { + let image = image::DynamicImage::new_rgba8(super::MAX_PROJECT_IMAGE_DIMENSION + 1, 1); + let mut bytes = std::io::Cursor::new(Vec::new()); + image.write_to(&mut bytes, image::ImageFormat::Png).unwrap(); + assert!(super::decode_image(bytes.get_ref()).is_err()); +} + +#[test] +fn image_rows_align_fitted_images_to_the_bottom() { + let picker = ratatui_image::picker::Picker::halfblocks(); + let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 1, + 3, + image::Rgba([255, 255, 255, 255]), + )); + let aligned = super::bottom_align_to_cells( + image, + super::ImageRenderKey { + width: 1, + height: 2, + protocol: picker.protocol_type(), + mode: crate::config::settings::ImageProtocol::Halfblocks, + }, + &picker, + ) + .to_rgba8(); + + assert_eq!(aligned.get_pixel(0, 0).0[3], 0); + assert_eq!(aligned.get_pixel(0, aligned.height() - 1).0[3], 255); +} + +#[test] +fn markdown_rendering_clamps_scroll_to_the_document() { + let backend = TestBackend::new(40, 8); + let mut terminal = Terminal::new(backend).unwrap(); + let mut scroll = 0; + let mut document = super::Document::new( + "Project", + "**Bold** text\n\n- one\n- two\n- three\n- four\n- five\n- six\n- seven\n- eight\n- nine\n- ten", + ); + let picker = ratatui_image::picker::Picker::halfblocks(); + let mut max_scroll = 0; + + terminal + .draw(|frame| { + max_scroll = super::render(frame, frame.area(), &mut document, &mut scroll, &picker); + }) + .unwrap(); + + let rendered = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect::(); + assert!(rendered.contains("Bold text")); + + scroll = usize::MAX; + terminal + .draw(|frame| { + max_scroll = super::render(frame, frame.area(), &mut document, &mut scroll, &picker); + }) + .unwrap(); + assert_eq!(scroll, max_scroll); + assert_eq!(terminal.backend().buffer()[(39, 7)].symbol(), "█"); +} + +#[test] +fn multiple_images_inside_one_link_do_not_overlap() { + let document = super::Document::new( + "Project", + "[![First](https://cdn.example/first.png) ![Second](https://cdn.example/second.png)](https://example.com)", + ); + let mut urls = document.image_urls(); + urls.sort(); + assert_eq!( + urls, + [ + "https://cdn.example/first.png", + "https://cdn.example/second.png" + ] + ); +} diff --git a/src/tui/tests/widgets/popups/base.rs b/src/tui/tests/widgets/popups/base.rs new file mode 100644 index 0000000..860e942 --- /dev/null +++ b/src/tui/tests/widgets/popups/base.rs @@ -0,0 +1,27 @@ +use std::num::NonZeroU16; + +use super::*; + +#[test] +fn popup_cells_covering_terminal_images_are_always_redrawn() { + let area = Rect::new(0, 0, 4, 3); + let mut buffer = Buffer::empty(area); + buffer[(1, 1)].set_diff_option(CellDiffOption::Skip); + buffer[(2, 1)] + .set_symbol("\x1b_Gimage\x1b\\") + .set_diff_option(CellDiffOption::ForcedWidth(NonZeroU16::new(1).unwrap())); + let popup = PopupFrame { + title: Line::from("Popup"), + border_color: Color::White, + bg: Some(Color::Black), + keybinds: None, + search_line: None, + content: Box::new(|_, _| {}), + }; + + popup.render(area, &mut buffer); + + assert_eq!(buffer[(1, 1)].diff_option, CellDiffOption::AlwaysUpdate); + assert_eq!(buffer[(2, 1)].diff_option, CellDiffOption::AlwaysUpdate); + assert_eq!(buffer[(0, 1)].diff_option, CellDiffOption::None); +} diff --git a/src/tui/tests/widgets/popups/error/area.rs b/src/tui/tests/widgets/popups/error/area.rs new file mode 100644 index 0000000..ed4a365 --- /dev/null +++ b/src/tui/tests/widgets/popups/error/area.rs @@ -0,0 +1,41 @@ +use super::popup_area; +use crate::config::SETTINGS; +use ratatui::layout::Rect; + +fn frame() -> Rect { + Rect::new(0, 0, 80, 24) +} + +#[test] +fn returns_none_after_dismiss_timeout() { + let past_dismiss = SETTINGS.ui.error_auto_dismiss_ms as u128 + 1; + assert!(popup_area(frame(), "msg", 0, past_dismiss).is_none()); +} + +#[test] +fn returns_some_inside_dismiss_window() { + assert!(popup_area(frame(), "msg", 0, 0).is_some()); +} + +#[test] +fn returns_none_when_vertical_room_too_small() { + // base_y = 22 leaves only height 24 - 22 - 1 = 1 row of usable space, + // less than the minimum 3 needed for border + content + border. + assert!(popup_area(frame(), "msg", 22, 0).is_none()); +} + +#[test] +fn clamps_popup_width_to_frame() { + // a wider-than-the-frame message gets clamped so the popup fits + // inside frame.width minus the right-edge padding (saturating_sub(4)). + let huge = "x".repeat(200); + let area = popup_area(frame(), &huge, 0, 0).unwrap(); + assert!(area.width <= frame().width.saturating_sub(4)); +} + +#[test] +fn anchors_popup_to_right_edge() { + let area = popup_area(frame(), "msg", 0, 0).unwrap(); + // popup_w is added to base_x to reach frame.width - 2 (right-edge gutter) + assert_eq!(area.x + area.width + 2, frame().width); +} diff --git a/src/tui/tests/widgets/popups/error/render.rs b/src/tui/tests/widgets/popups/error/render.rs new file mode 100644 index 0000000..1abc40b --- /dev/null +++ b/src/tui/tests/widgets/popups/error/render.rs @@ -0,0 +1,62 @@ +use super::*; +use ratatui::Terminal; +use ratatui::backend::TestBackend; +use std::time::Instant; +use tracing::Level; + +use crate::feedback::errors::ErrorEvent; + +fn render(event: ErrorEvent, width: u16, height: u16) -> Terminal { + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| { + let popup = ErrorPopup::new(event); + f.render_widget(popup, f.area()); + }) + .unwrap(); + terminal +} + +fn event(level: Level, message: &str) -> ErrorEvent { + ErrorEvent { + id: 1, + level, + message: message.to_string(), + pushed_at: Instant::now(), + } +} + +#[test] +fn warn_level_renders() { + let term = render(event(Level::WARN, "Disk space low"), 40, 5); + insta::assert_snapshot!(term.backend()); +} + +#[test] +fn error_level_renders() { + let term = render(event(Level::ERROR, "Connection refused"), 40, 5); + insta::assert_snapshot!(term.backend()); +} + +// info-level events hit the catch-all `_` arm in the label match; previously +// there was no test covering it. +#[test] +fn info_level_renders() { + let term = render(event(Level::INFO, "Reloaded config"), 40, 5); + insta::assert_snapshot!(term.backend()); +} + +#[test] +fn long_message_wraps() { + let msg = "The Minecraft launcher could not reach the Mojang version manifest \ + after three retries. Check your network connection or proxy settings."; + let term = render(event(Level::ERROR, msg), 40, 10); + insta::assert_snapshot!(term.backend()); +} + +#[test] +fn narrow_frame_renders() { + let term = render(event(Level::WARN, "Short message"), 18, 5); + insta::assert_snapshot!(term.backend()); +} diff --git a/src/tui/widgets/popups/snapshots/rmcl__tui__widgets__popups__error__render_tests__error_level_renders.snap b/src/tui/tests/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__error_level_renders.snap similarity index 88% rename from src/tui/widgets/popups/snapshots/rmcl__tui__widgets__popups__error__render_tests__error_level_renders.snap rename to src/tui/tests/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__error_level_renders.snap index 91cab2f..9b2832e 100644 --- a/src/tui/widgets/popups/snapshots/rmcl__tui__widgets__popups__error__render_tests__error_level_renders.snap +++ b/src/tui/tests/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__error_level_renders.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/popups/error.rs +source: src/tui/tests/widgets/popups/error/render.rs expression: term.backend() --- "╭ ERROR ───────────────────────────────╮" diff --git a/src/tui/widgets/popups/snapshots/rmcl__tui__widgets__popups__error__render_tests__info_level_renders.snap b/src/tui/tests/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__info_level_renders.snap similarity index 88% rename from src/tui/widgets/popups/snapshots/rmcl__tui__widgets__popups__error__render_tests__info_level_renders.snap rename to src/tui/tests/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__info_level_renders.snap index 8331205..8ad668d 100644 --- a/src/tui/widgets/popups/snapshots/rmcl__tui__widgets__popups__error__render_tests__info_level_renders.snap +++ b/src/tui/tests/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__info_level_renders.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/popups/error.rs +source: src/tui/tests/widgets/popups/error/render.rs expression: term.backend() --- "╭ INFO ────────────────────────────────╮" diff --git a/src/tui/widgets/popups/snapshots/rmcl__tui__widgets__popups__error__render_tests__long_message_wraps.snap b/src/tui/tests/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__long_message_wraps.snap similarity index 92% rename from src/tui/widgets/popups/snapshots/rmcl__tui__widgets__popups__error__render_tests__long_message_wraps.snap rename to src/tui/tests/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__long_message_wraps.snap index be8c6ae..228aafb 100644 --- a/src/tui/widgets/popups/snapshots/rmcl__tui__widgets__popups__error__render_tests__long_message_wraps.snap +++ b/src/tui/tests/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__long_message_wraps.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/popups/error.rs +source: src/tui/tests/widgets/popups/error/render.rs expression: term.backend() --- "╭ ERROR ───────────────────────────────╮" diff --git a/src/tui/widgets/popups/snapshots/rmcl__tui__widgets__popups__error__render_tests__narrow_frame_renders.snap b/src/tui/tests/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__narrow_frame_renders.snap similarity index 80% rename from src/tui/widgets/popups/snapshots/rmcl__tui__widgets__popups__error__render_tests__narrow_frame_renders.snap rename to src/tui/tests/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__narrow_frame_renders.snap index 2a059ba..c2fac79 100644 --- a/src/tui/widgets/popups/snapshots/rmcl__tui__widgets__popups__error__render_tests__narrow_frame_renders.snap +++ b/src/tui/tests/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__narrow_frame_renders.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/popups/error.rs +source: src/tui/tests/widgets/popups/error/render.rs expression: term.backend() --- "╭ WARN ──────────╮" diff --git a/src/tui/widgets/popups/snapshots/rmcl__tui__widgets__popups__error__render_tests__warn_level_renders.snap b/src/tui/tests/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__warn_level_renders.snap similarity index 88% rename from src/tui/widgets/popups/snapshots/rmcl__tui__widgets__popups__error__render_tests__warn_level_renders.snap rename to src/tui/tests/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__warn_level_renders.snap index cbca974..45580a1 100644 --- a/src/tui/widgets/popups/snapshots/rmcl__tui__widgets__popups__error__render_tests__warn_level_renders.snap +++ b/src/tui/tests/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__warn_level_renders.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/popups/error.rs +source: src/tui/tests/widgets/popups/error/render.rs expression: term.backend() --- "╭ WARN ────────────────────────────────╮" diff --git a/src/tui/tests/widgets/popups/import_modpack/render.rs b/src/tui/tests/widgets/popups/import_modpack/render.rs new file mode 100644 index 0000000..51431ca --- /dev/null +++ b/src/tui/tests/widgets/popups/import_modpack/render.rs @@ -0,0 +1,211 @@ +use super::*; +use crate::tests::TEST_LOCK; +use ratatui::Terminal; +use ratatui::backend::TestBackend; + +// serialise against parallel tests of the same global IMPORT_STATE. +fn reset_import_state(step: ImportStep) { + let mut guard = IMPORT_STATE.lock().expect("IMPORT_STATE lock"); + *guard = ImportWizardState::default(); + guard.step = step; +} + +#[test] +fn import_modpack_renders_input_step() { + let _serial = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + reset_import_state(ImportStep::Input); + + let backend = TestBackend::new(60, 12); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| render(f, f.area(), FocusedArea::ImportPopup)) + .unwrap(); + insta::assert_snapshot!(terminal.backend()); +} + +#[test] +fn modpack_discovery_renders_active_search() { + let _serial = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + reset_import_state(ImportStep::Discover); + { + let mut state = DISCOVERY_STATE.lock().expect("DISCOVERY_STATE lock"); + *state = crate::tui::widgets::content::DiscoveryState::new_modpacks(); + state.search.activate(); + for character in "fabric".chars() { + state.search.push(character); + } + } + + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + let picker = ratatui_image::picker::Picker::halfblocks(); + terminal + .draw(|frame| render_with_picker(frame, frame.area(), FocusedArea::ImportPopup, &picker)) + .unwrap(); + + let rendered = format!("{}", terminal.backend()); + assert!(rendered.contains("/ fabric\u{2588}")); + assert!(rendered.contains("[v] versions")); + assert!(rendered.contains("[i] import")); + assert_eq!( + terminal.backend().buffer().cell((1, 1)).unwrap().bg, + crate::config::theme::THEME.as_ref().surface() + ); +} + +#[test] +fn modpack_project_page_only_shows_page_actions() { + let hints = discovery_keybinds(true); + + assert!(hints.contains(&("v", " versions"))); + assert!(!hints.iter().any(|(_, action)| *action == " pages")); + assert!(!hints.iter().any(|(_, action)| *action == " search")); + assert!(!hints.iter().any(|(_, action)| *action == " import")); +} + +#[test] +fn modpack_discovery_shows_page_navigation() { + assert!(discovery_keybinds(false).contains(&(" [/] ", " pages"))); +} + +#[test] +fn import_modpack_renders_fetching_step() { + let _serial = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + reset_import_state(ImportStep::Fetching); + + let backend = TestBackend::new(60, 12); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| render(f, f.area(), FocusedArea::ImportPopup)) + .unwrap(); + insta::assert_snapshot!(terminal.backend()); +} + +// Version step: pre-populate versions as LoadState::Loaded with synthetic +// VersionInfo entries so render walks the list path without triggering +// any network helpers. +#[test] +fn import_modpack_renders_version_step() { + use crate::net::modrinth::VersionInfo; + + let _serial = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + { + let mut guard = IMPORT_STATE.lock().expect("IMPORT_STATE lock"); + *guard = ImportWizardState::default(); + guard.step = ImportStep::Version; + guard.project_title = Some("Synthetic Pack".into()); + guard.versions = LoadState::Loaded(vec![ + VersionInfo { + id: "v1".into(), + project_id: "project".into(), + name: "1.0.0".into(), + version_number: "1.0.0".into(), + game_versions: vec!["1.20.1".into()], + loaders: vec!["fabric".into()], + version_type: crate::net::modrinth::VersionType::Release, + dependencies: Vec::new(), + date_published: String::new(), + files: vec![], + }, + VersionInfo { + id: "v2".into(), + project_id: "project".into(), + name: "0.9.0".into(), + version_number: "0.9.0".into(), + game_versions: vec!["1.20.1".into()], + loaders: vec!["fabric".into()], + version_type: crate::net::modrinth::VersionType::Release, + dependencies: Vec::new(), + date_published: String::new(), + files: vec![], + }, + ]); + } + + let backend = TestBackend::new(60, 14); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| render(f, f.area(), FocusedArea::ImportPopup)) + .unwrap(); + insta::assert_snapshot!(terminal.backend()); +} + +// Confirm step: needs a populated ImportSummary so the render path +// doesn't bail. ImportSummary is constructed manually with synthetic +// values; archive_path is a fake tempdir-ish path that never gets read. +#[test] +fn import_modpack_renders_confirm_step() { + use crate::instance::import::{ImportSummary, PackFormat}; + use crate::instance::models::ModLoader; + use std::path::PathBuf; + + let _serial = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + { + let mut guard = IMPORT_STATE.lock().expect("IMPORT_STATE lock"); + *guard = ImportWizardState::default(); + guard.step = ImportStep::Confirm; + guard.summary = Some(ImportSummary { + name: "Synthetic Pack".into(), + pack_version: "1.0.0".into(), + game_version: "1.20.1".into(), + loader: ModLoader::Fabric, + loader_version: Some("0.15.0".into()), + mod_count: 42, + override_count: 3, + format: PackFormat::Mrpack, + archive_path: PathBuf::from("/tmp/synthetic.mrpack"), + source: None, + }); + } + + let backend = TestBackend::new(60, 14); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| render(f, f.area(), FocusedArea::ImportPopup)) + .unwrap(); + insta::assert_snapshot!(terminal.backend()); +} + +#[test] +fn modpack_confirmation_area_fits_its_summary() { + let _serial = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + reset_import_state(ImportStep::Confirm); + + assert_eq!(popup_rect(Rect::new(0, 0, 100, 30)).height, 8); +} + +// Confirm step with loader_version=None: covers the branch where the +// pack didn't declare a loader version (rare upstream, but happens for +// older mmc packs). render_confirm_step has to handle the Option. +#[test] +fn import_modpack_renders_confirm_step_without_loader_version() { + use crate::instance::import::{ImportSummary, PackFormat}; + use crate::instance::models::ModLoader; + use std::path::PathBuf; + + let _serial = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + { + let mut guard = IMPORT_STATE.lock().expect("IMPORT_STATE lock"); + *guard = ImportWizardState::default(); + guard.step = ImportStep::Confirm; + guard.summary = Some(ImportSummary { + name: "Vanilla Pack".into(), + pack_version: "2.0".into(), + game_version: "1.20.1".into(), + loader: ModLoader::Vanilla, + loader_version: None, + mod_count: 0, + override_count: 12, + format: PackFormat::Mmc, + archive_path: PathBuf::from("/tmp/vanilla.zip"), + source: None, + }); + } + + let backend = TestBackend::new(60, 14); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| render(f, f.area(), FocusedArea::ImportPopup)) + .unwrap(); + insta::assert_snapshot!(terminal.backend()); +} diff --git a/src/tui/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_confirm_step.snap b/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_confirm_step.snap similarity index 76% rename from src/tui/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_confirm_step.snap rename to src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_confirm_step.snap index e569091..1f25b20 100644 --- a/src/tui/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_confirm_step.snap +++ b/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_confirm_step.snap @@ -1,8 +1,8 @@ --- -source: src/tui/widgets/popups/import_modpack/render.rs +source: src/tui/tests/widgets/popups/import_modpack/render.rs expression: terminal.backend() --- -"╭Import Modpack────────────────────────────────────────────╮" +"╭Install Modpack───────────────────────────────────────────╮" "│Name: Synthetic Pack │" "│Pack Version: 1.0.0 │" "│MC Version: 1.20.1 │" @@ -15,4 +15,4 @@ expression: terminal.backend() "│ │" "│ │" "│ │" -"╰──────────────────────────────────[h] back [Enter] import╯" +"╰─────────────────────────────────[h] back [Enter] install╯" diff --git a/src/tui/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_confirm_step_without_loader_version.snap b/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_confirm_step_without_loader_version.snap similarity index 76% rename from src/tui/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_confirm_step_without_loader_version.snap rename to src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_confirm_step_without_loader_version.snap index 9a037c3..2099ee4 100644 --- a/src/tui/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_confirm_step_without_loader_version.snap +++ b/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_confirm_step_without_loader_version.snap @@ -1,8 +1,8 @@ --- -source: src/tui/widgets/popups/import_modpack/render.rs +source: src/tui/tests/widgets/popups/import_modpack/render.rs expression: terminal.backend() --- -"╭Import Modpack────────────────────────────────────────────╮" +"╭Install Modpack───────────────────────────────────────────╮" "│Name: Vanilla Pack │" "│Pack Version: 2.0 │" "│MC Version: 1.20.1 │" @@ -15,4 +15,4 @@ expression: terminal.backend() "│ │" "│ │" "│ │" -"╰──────────────────────────────────[h] back [Enter] import╯" +"╰─────────────────────────────────[h] back [Enter] install╯" diff --git a/src/tui/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_fetching_step.snap b/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_fetching_step.snap similarity index 94% rename from src/tui/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_fetching_step.snap rename to src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_fetching_step.snap index 8e67bea..815a702 100644 --- a/src/tui/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_fetching_step.snap +++ b/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_fetching_step.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/popups/import_modpack/render.rs +source: src/tui/tests/widgets/popups/import_modpack/render.rs expression: terminal.backend() --- "╭Import Modpack────────────────────────────────────────────╮" diff --git a/src/tui/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_input_step.snap b/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_input_step.snap similarity index 86% rename from src/tui/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_input_step.snap rename to src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_input_step.snap index 1832105..d839564 100644 --- a/src/tui/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_input_step.snap +++ b/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_input_step.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/popups/import_modpack/render.rs +source: src/tui/tests/widgets/popups/import_modpack/render.rs expression: terminal.backend() --- "╭Import Modpack────────────────────────────────────────────╮" @@ -13,4 +13,4 @@ expression: terminal.backend() "│ │" "│ │" "│ │" -"╰─────────────────────────────────────────────[Enter] fetch╯" +"╰─────────────────────────────────[Enter] fetch [Esc] back╯" diff --git a/src/tui/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_version_step.snap b/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_version_step.snap similarity index 94% rename from src/tui/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_version_step.snap rename to src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_version_step.snap index e569689..74dfdfa 100644 --- a/src/tui/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_version_step.snap +++ b/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_version_step.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/popups/import_modpack/render.rs +source: src/tui/tests/widgets/popups/import_modpack/render.rs expression: terminal.backend() --- "╭Import Modpack────────────────────────────────────────────╮" diff --git a/src/tui/tests/widgets/popups/import_modpack/state.rs b/src/tui/tests/widgets/popups/import_modpack/state.rs new file mode 100644 index 0000000..d170078 --- /dev/null +++ b/src/tui/tests/widgets/popups/import_modpack/state.rs @@ -0,0 +1,206 @@ +use std::path::PathBuf; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use super::*; +use crate::instance::{ + ModLoader, + import::{ImportSummary, PackFormat}, +}; +use crate::tests::TEST_LOCK; + +fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) +} + +fn summary() -> ImportSummary { + ImportSummary { + name: "Test Pack".to_owned(), + pack_version: "1.0.0".to_owned(), + game_version: "1.21.1".to_owned(), + loader: ModLoader::Fabric, + loader_version: Some("0.16.14".to_owned()), + mod_count: 2, + override_count: 1, + format: PackFormat::Mrpack, + archive_path: PathBuf::from("test.mrpack"), + source: None, + } +} + +#[test] +fn stale_import_results_cannot_replace_the_current_request() { + let state = Arc::new(Mutex::new(ImportWizardState { + step: ImportStep::Fetching, + request_id: 2, + ..ImportWizardState::default() + })); + + assert!(!update_current_request(&state, 1, |state| { + state.step = ImportStep::Confirm; + })); + assert_eq!(state.lock().unwrap().step, ImportStep::Fetching); + assert!(update_current_request(&state, 2, |state| { + state.step = ImportStep::Confirm; + })); + assert_eq!(state.lock().unwrap().step, ImportStep::Confirm); +} + +#[test] +fn empty_input_is_ignored_and_escape_returns_to_discovery() { + let _guard = TEST_LOCK.lock().unwrap_or_else(|error| error.into_inner()); + *DISCOVERY_STATE.lock().unwrap() = crate::tui::widgets::content::DiscoveryState::new_modpacks(); + let mut instances = instances::State { + show_import_popup: true, + ..instances::State::default() + }; + *IMPORT_STATE.lock().unwrap() = ImportWizardState { + step: ImportStep::Input, + ..ImportWizardState::default() + }; + + handle_key(&key(KeyCode::Enter), &mut instances); + assert_eq!(IMPORT_STATE.lock().unwrap().step, ImportStep::Input); + + handle_key(&key(KeyCode::Esc), &mut instances); + assert_eq!(IMPORT_STATE.lock().unwrap().step, ImportStep::Discover); + assert!(instances.show_import_popup); + + handle_key(&key(KeyCode::Esc), &mut instances); + assert!(!instances.show_import_popup); +} + +#[test] +fn confirm_returns_the_import_summary() { + let _guard = TEST_LOCK.lock().unwrap_or_else(|error| error.into_inner()); + let mut instances = instances::State { + show_import_popup: true, + ..instances::State::default() + }; + { + let mut state = IMPORT_STATE.lock().unwrap(); + *state = ImportWizardState { + step: ImportStep::Confirm, + summary: Some(summary()), + ..ImportWizardState::default() + }; + } + *IMPORT_RESULT.lock().unwrap() = None; + + handle_key(&key(KeyCode::Enter), &mut instances); + let result = take_result().expect("import result"); + assert_eq!(result.summary.name, "Test Pack"); + assert!(!instances.show_import_popup); +} + +#[test] +fn discovered_modpack_version_skips_content_install_confirmation() { + let project = crate::net::modrinth::DiscoveryProject { + id: "pack-id".to_owned(), + slug: "test-pack".to_owned(), + title: "Test Pack".to_owned(), + description: "A pack".to_owned(), + downloads: 1, + icon_url: None, + icon_bytes: None, + }; + let mut discovery = crate::tui::widgets::content::DiscoveryState::new_modpacks(); + discovery.list.entries.push( + crate::tui::widgets::content::discovery::provider_project_entry( + project, + "modrinth", + "test-pack".to_owned(), + None, + ), + ); + discovery.list.list_state.select(Some(0)); + let versions = discovery.begin_versions().unwrap(); + crate::tui::widgets::content::DiscoveryState::push_action_result( + &versions.pending, + crate::tui::widgets::content::discovery::DiscoveryActionResult::Versions { + request_id: versions.request_id, + project_id: versions.project_id, + result: Ok(vec![VersionInfo { + id: "version-id".to_owned(), + project_id: "pack-id".to_owned(), + name: "1.0".to_owned(), + version_number: "1.0".to_owned(), + game_versions: vec!["1.21.1".to_owned()], + loaders: vec!["fabric".to_owned()], + version_type: crate::net::modrinth::VersionType::Release, + dependencies: Vec::new(), + date_published: String::new(), + files: Vec::new(), + }]), + }, + ); + discovery.drain_pending(); + assert!(discovery.select_minecraft_version()); + + let selected = take_discovered_version(&mut discovery).expect("selected version"); + + assert_eq!(selected.version.id, "version-id"); + assert!(discovery.version_popup.is_none()); +} + +#[test] +fn discovered_modpack_becomes_visible_after_its_icon_is_decoded() { + let _guard = TEST_LOCK.lock().unwrap_or_else(|error| error.into_inner()); + tokio::runtime::Runtime::new().unwrap().block_on(async { + *DISCOVERY_STATE.lock().unwrap() = + crate::tui::widgets::content::DiscoveryState::new_modpacks(); + + let request = DISCOVERY_STATE.lock().unwrap().begin_modpack_search(); + let mut png = std::io::Cursor::new(Vec::new()); + image::DynamicImage::new_rgba8(1, 1) + .write_to(&mut png, image::ImageFormat::Png) + .unwrap(); + let project = crate::net::modrinth::DiscoveryProject { + id: "pack-id".to_owned(), + slug: "test-pack".to_owned(), + title: "Test Pack".to_owned(), + description: "A pack".to_owned(), + downloads: 1, + icon_url: None, + icon_bytes: Some(png.into_inner()), + }; + assert!(request.stream.upsert( + crate::tui::widgets::content::discovery::provider_project_entry( + project, + "modrinth", + "testpack".to_owned(), + None, + ) + )); + crate::tui::widgets::content::DiscoveryState::push_result( + &request.pending, + request.generation, + request.offset, + Ok( + crate::tui::widgets::content::discovery::DiscoveryPageResult { + received: 1, + total_hits: 1, + }, + ), + ); + + let picker = ratatui_image::picker::Picker::halfblocks(); + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + drain(&picker); + if !DISCOVERY_STATE + .lock() + .unwrap() + .list + .filtered_indices() + .is_empty() + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("modpack icon render completed"); + }); +} diff --git a/src/tui/tests/widgets/popups/import_modpack/support.rs b/src/tui/tests/widgets/popups/import_modpack/support.rs new file mode 100644 index 0000000..266c581 --- /dev/null +++ b/src/tui/tests/widgets/popups/import_modpack/support.rs @@ -0,0 +1,13 @@ +use super::state; + +pub(crate) fn reset() { + state::IMPORT_STATE + .lock() + .expect("import wizard state") + .reset(); + *state::IMPORT_RESULT.lock().expect("import wizard result") = None; + *state::DISCOVERY_STATE + .lock() + .expect("modpack discovery state") = + crate::tui::widgets::content::DiscoveryState::new_modpacks(); +} diff --git a/src/tui/tests/widgets/popups/mod.rs b/src/tui/tests/widgets/popups/mod.rs new file mode 100644 index 0000000..cb759c4 --- /dev/null +++ b/src/tui/tests/widgets/popups/mod.rs @@ -0,0 +1,35 @@ +use super::{ + confirm::{ConfirmTarget, confirm_popup_area}, + keybind_line_fitted, word_wrap_size, +}; + +#[test] +fn fitted_keybinds_use_terminal_width_and_omit_overflow() { + let line = keybind_line_fitted(&[("⏎", " select"), ("a", " add")], 10); + + assert_eq!(line.width(), 10); + assert_eq!(line.to_string(), "[⏎] select"); +} + +#[test] +fn word_wrap_size_uses_terminal_width_for_unicode_text() { + assert_eq!(word_wrap_size("Übergröße", 20), (9, 1)); + assert_eq!(word_wrap_size("世界 test", 6), (4, 2)); + assert_eq!(word_wrap_size("one\n\ntwo", 20), (3, 3)); +} + +#[test] +fn confirmation_area_uses_display_width_for_unicode_names() { + let frame = ratatui::layout::Rect::new(0, 0, 100, 30); + let ascii = ConfirmTarget::Instance { + name: "a".repeat(30), + }; + let unicode = ConfirmTarget::Instance { + name: format!("é{}", "a".repeat(29)), + }; + + assert_eq!( + confirm_popup_area(frame, &ascii), + confirm_popup_area(frame, &unicode) + ); +} diff --git a/src/tui/tests/widgets/popups/new_instance/render.rs b/src/tui/tests/widgets/popups/new_instance/render.rs new file mode 100644 index 0000000..627399e --- /dev/null +++ b/src/tui/tests/widgets/popups/new_instance/render.rs @@ -0,0 +1,148 @@ +use super::*; +use crate::tests::TEST_LOCK; +use ratatui::Terminal; +use ratatui::backend::TestBackend; + +// WIZARD_STATE is a process-global static; without serialisation, parallel +// tests would race when each test sets the step and then renders, since +// render re-acquires the WIZARD_STATE mutex internally. this guard mutex +// ensures only one wizard snapshot test runs at a time. +fn reset_wizard_state(step: WizardStep) { + let mut guard = WIZARD_STATE.lock().expect("WIZARD_STATE lock"); + *guard = WizardState::default(); + guard.step = step; +} + +#[test] +fn new_instance_renders_name_step() { + let _serial = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + // Name is the default step; render touches no network helpers. + reset_wizard_state(WizardStep::Name); + + let backend = TestBackend::new(60, 12); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| render(f, f.area(), FocusedArea::Popup)) + .unwrap(); + insta::assert_snapshot!(terminal.backend()); +} + +#[test] +fn new_instance_renders_loader_step() { + let _serial = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + // Loader step is reached after Name; render just paints the hardcoded + // loader list, no network. + reset_wizard_state(WizardStep::Loader); + + let backend = TestBackend::new(60, 12); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| render(f, f.area(), FocusedArea::Popup)) + .unwrap(); + insta::assert_snapshot!(terminal.backend()); +} + +// Version step: pre-populate versions as LoadState::Loaded so +// ensure_versions_loaded short-circuits and never spawns a network task. +// the three synthetic versions are marked stable=true so they show with +// show_snapshots=false (the default). +#[test] +fn new_instance_renders_version_step() { + use crate::instance::loader::GameVersion; + + let _serial = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + { + let mut guard = WIZARD_STATE.lock().expect("WIZARD_STATE lock"); + *guard = WizardState::default(); + guard.step = WizardStep::Version; + guard.versions = LoadState::Loaded(vec![ + GameVersion { + id: "1.20.1".into(), + stable: true, + }, + GameVersion { + id: "1.19.4".into(), + stable: true, + }, + GameVersion { + id: "1.18.2".into(), + stable: true, + }, + ]); + } + + let backend = TestBackend::new(60, 14); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| render(f, f.area(), FocusedArea::Popup)) + .unwrap(); + insta::assert_snapshot!(terminal.backend()); +} + +// LoaderVersion step: needs both versions and loader_versions pre-loaded. +// pick a non-Vanilla loader (loader_idx=2 = Forge) so the step doesn't +// skip itself to Confirm. +#[test] +fn new_instance_renders_loader_version_step() { + use crate::instance::loader::GameVersion; + + let _serial = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + { + let mut guard = WIZARD_STATE.lock().expect("WIZARD_STATE lock"); + *guard = WizardState::default(); + guard.step = WizardStep::LoaderVersion; + guard.loader_idx = 2; // Forge + guard.versions = LoadState::Loaded(vec![GameVersion { + id: "1.20.1".into(), + stable: true, + }]); + guard.loader_versions = + LoadState::Loaded(vec!["47.2.0".into(), "47.1.0".into(), "47.0.50".into()]); + } + + let backend = TestBackend::new(60, 14); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| render(f, f.area(), FocusedArea::Popup)) + .unwrap(); + insta::assert_snapshot!(terminal.backend()); +} + +// Confirm step: paints a summary, no network, no list. requires +// versions + loader_versions Loaded so selected_*() return Some. +#[test] +fn new_instance_renders_confirm_step() { + use crate::instance::loader::GameVersion; + use tui_prompts::TextState; + + let _serial = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + { + let mut guard = WIZARD_STATE.lock().expect("WIZARD_STATE lock"); + *guard = WizardState::default(); + guard.step = WizardStep::Confirm; + guard.loader_idx = 1; // Fabric + guard.versions = LoadState::Loaded(vec![GameVersion { + id: "1.20.1".into(), + stable: true, + }]); + guard.loader_versions = LoadState::Loaded(vec!["0.15.0".into()]); + // TextState exposes only constructors; rebuilding with the + // desired initial value is the supported path. + guard.name_state = TextState::new().with_value("MyPack"); + } + + let backend = TestBackend::new(60, 12); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| render(f, f.area(), FocusedArea::Popup)) + .unwrap(); + insta::assert_snapshot!(terminal.backend()); +} + +#[test] +fn new_instance_confirmation_area_fits_its_summary() { + let _serial = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + reset_wizard_state(WizardStep::Confirm); + + assert_eq!(popup_rect(Rect::new(0, 0, 100, 30)).height, 6); +} diff --git a/src/tui/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_confirm_step.snap b/src/tui/tests/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_confirm_step.snap similarity index 94% rename from src/tui/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_confirm_step.snap rename to src/tui/tests/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_confirm_step.snap index c2901c3..7b83265 100644 --- a/src/tui/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_confirm_step.snap +++ b/src/tui/tests/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_confirm_step.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/popups/new_instance/render.rs +source: src/tui/tests/widgets/popups/new_instance/render.rs expression: terminal.backend() --- "╭New Instance──────────────────────────────────────────────╮" diff --git a/src/tui/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_loader_step.snap b/src/tui/tests/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_loader_step.snap similarity index 94% rename from src/tui/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_loader_step.snap rename to src/tui/tests/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_loader_step.snap index 2d4e976..6d44de9 100644 --- a/src/tui/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_loader_step.snap +++ b/src/tui/tests/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_loader_step.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/popups/new_instance/render.rs +source: src/tui/tests/widgets/popups/new_instance/render.rs expression: terminal.backend() --- "╭New Instance──────────────────────────────────────────────╮" diff --git a/src/tui/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_loader_version_step.snap b/src/tui/tests/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_loader_version_step.snap similarity index 94% rename from src/tui/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_loader_version_step.snap rename to src/tui/tests/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_loader_version_step.snap index 49dfc8e..cd6d286 100644 --- a/src/tui/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_loader_version_step.snap +++ b/src/tui/tests/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_loader_version_step.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/popups/new_instance/render.rs +source: src/tui/tests/widgets/popups/new_instance/render.rs expression: terminal.backend() --- "╭New Instance──────────────────────────────────────────────╮" diff --git a/src/tui/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_name_step.snap b/src/tui/tests/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_name_step.snap similarity index 94% rename from src/tui/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_name_step.snap rename to src/tui/tests/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_name_step.snap index f1363da..8bce1d4 100644 --- a/src/tui/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_name_step.snap +++ b/src/tui/tests/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_name_step.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/popups/new_instance/render.rs +source: src/tui/tests/widgets/popups/new_instance/render.rs expression: terminal.backend() --- "╭New Instance──────────────────────────────────────────────╮" diff --git a/src/tui/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_version_step.snap b/src/tui/tests/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_version_step.snap similarity index 94% rename from src/tui/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_version_step.snap rename to src/tui/tests/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_version_step.snap index d60bb6d..e8963b2 100644 --- a/src/tui/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_version_step.snap +++ b/src/tui/tests/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_version_step.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/popups/new_instance/render.rs +source: src/tui/tests/widgets/popups/new_instance/render.rs expression: terminal.backend() --- "╭New Instance──────────────────────────────────────────────╮" diff --git a/src/tui/tests/widgets/popups/new_instance/state.rs b/src/tui/tests/widgets/popups/new_instance/state.rs new file mode 100644 index 0000000..4af2ff5 --- /dev/null +++ b/src/tui/tests/widgets/popups/new_instance/state.rs @@ -0,0 +1,94 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use tui_prompts::{State as PromptState, TextState}; + +use super::*; +use crate::tests::TEST_LOCK; + +fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) +} + +#[test] +fn vanilla_wizard_reaches_confirm_and_returns_parameters() { + let _guard = TEST_LOCK.lock().unwrap_or_else(|error| error.into_inner()); + let mut instances = instances::State { + show_popup: true, + ..instances::State::default() + }; + + { + let mut state = WIZARD_STATE.lock().unwrap(); + *state = WizardState::default(); + state.name_state = TextState::new().with_value("Test Instance"); + } + *WIZARD_RESULT.lock().unwrap() = None; + + handle_key(&key(KeyCode::Enter), &mut instances); + assert_eq!(WIZARD_STATE.lock().unwrap().step, WizardStep::Loader); + + { + let mut state = WIZARD_STATE.lock().unwrap(); + state.step = WizardStep::Version; + state.versions = LoadState::Loaded(vec![GameVersion { + id: "1.21.1".to_owned(), + stable: true, + }]); + } + handle_key(&key(KeyCode::Enter), &mut instances); + assert_eq!(WIZARD_STATE.lock().unwrap().step, WizardStep::Confirm); + + handle_key(&key(KeyCode::Enter), &mut instances); + let result = take_result().expect("wizard result"); + assert_eq!(result.name, "Test Instance"); + assert_eq!(result.game_version, "1.21.1"); + assert_eq!(result.loader, ModLoader::Vanilla); + assert_eq!(result.loader_version, None); + assert!(!instances.show_popup); +} + +#[test] +fn version_filter_clamps_a_stale_selection() { + let mut state = WizardState { + versions: LoadState::Loaded(vec![ + GameVersion { + id: "1.21.1".to_owned(), + stable: true, + }, + GameVersion { + id: "25w01a".to_owned(), + stable: false, + }, + ]), + version_idx: 9, + ..WizardState::default() + }; + + clamp_version_index(&mut state); + assert_eq!(state.version_idx, 0); + assert_eq!(visible_versions(&state).len(), 1); + + state.show_snapshots = true; + assert_eq!(visible_versions(&state).len(), 2); +} + +#[test] +fn ctrl_backspace_deletes_the_word_before_the_name_cursor() { + let _guard = TEST_LOCK.lock().unwrap_or_else(|error| error.into_inner()); + let mut instances = instances::State::default(); + + { + let mut state = WIZARD_STATE.lock().unwrap(); + *state = WizardState::default(); + state.name_state = TextState::new().with_value("hello brave world"); + *state.name_state.position_mut() = "hello brave".chars().count(); + } + + handle_key( + &KeyEvent::new(KeyCode::Backspace, KeyModifiers::CONTROL), + &mut instances, + ); + + let state = WIZARD_STATE.lock().unwrap(); + assert_eq!(state.name_state.value(), "hello world"); + assert_eq!(state.name_state.position(), "hello ".chars().count()); +} diff --git a/src/tui/tests/widgets/popups/new_instance/support.rs b/src/tui/tests/widgets/popups/new_instance/support.rs new file mode 100644 index 0000000..caa25d1 --- /dev/null +++ b/src/tui/tests/widgets/popups/new_instance/support.rs @@ -0,0 +1,6 @@ +use super::state; + +pub(crate) fn reset() { + state::WIZARD_STATE.lock().expect("wizard state").reset(); + *state::WIZARD_RESULT.lock().expect("wizard result") = None; +} diff --git a/src/tui/tests/widgets/screenshots_grid.rs b/src/tui/tests/widgets/screenshots_grid.rs new file mode 100644 index 0000000..f0e9b1f --- /dev/null +++ b/src/tui/tests/widgets/screenshots_grid.rs @@ -0,0 +1,65 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::{Terminal, backend::TestBackend}; + +use super::*; + +fn key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent { + KeyEvent::new(code, modifiers) +} + +fn entry(name: &str) -> ScreenshotEntry { + ScreenshotEntry { + name: name.to_owned(), + path: name.into(), + width: 1920, + height: 1080, + } +} + +#[test] +fn grid_navigation_and_search_clamp_the_selection() { + let mut state = ScreenshotsState { + entries: vec![entry("one.png"), entry("two.png"), entry("three.png")], + cols: 2, + ..Default::default() + }; + + assert!(handle_key( + &key(KeyCode::Right, KeyModifiers::SHIFT), + &mut state + )); + assert_eq!(state.selected, 1); + + assert!(handle_key( + &key(KeyCode::Down, KeyModifiers::SHIFT), + &mut state + )); + assert_eq!(state.selected, 1); + + assert!(handle_key( + &key(KeyCode::Char('/'), KeyModifiers::NONE), + &mut state + )); + assert!(handle_key( + &key(KeyCode::Char('t'), KeyModifiers::NONE), + &mut state + )); + assert_eq!(state.selected, 0); + assert_eq!(state.search.query, "t"); +} + +#[test] +fn unicode_filename_truncation_renders_without_panicking() { + let mut state = ScreenshotsState { + entries: vec![entry(&format!("{}é.png", "a".repeat(23)))], + ..Default::default() + }; + let backend = TestBackend::new(24, 8); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal + .draw(|frame| render(frame, frame.area(), &mut state, true)) + .unwrap(); + + assert!(terminal.backend().to_string().contains("aaaa")); +} diff --git a/src/tui/tests/widgets/search.rs b/src/tui/tests/widgets/search.rs new file mode 100644 index 0000000..2f5265e --- /dev/null +++ b/src/tui/tests/widgets/search.rs @@ -0,0 +1,76 @@ +use super::*; +use crossterm::event::KeyModifiers; + +#[test] +fn confirm_keeps_query_but_deactivates() { + let mut s = SearchState::default(); + s.activate(); + s.push('a'); + s.push('b'); + s.confirm(); + assert!(!s.active); + assert_eq!(s.query, "ab"); + // filter should still match + assert!(s.matches("abc")); + assert!(!s.matches("xyz")); + s.activate(); + assert!(s.active); + assert_eq!(s.query, "ab"); +} + +#[test] +fn deactivate_clears_query() { + let mut s = SearchState::default(); + s.activate(); + s.push('x'); + s.deactivate(); + assert!(!s.active); + assert!(s.query.is_empty()); + // with empty query, everything matches + assert!(s.matches("anything")); +} + +#[test] +fn highlight_spans_marks_each_case_insensitive_match() { + let search = SearchState { + query: "di".to_owned(), + ..SearchState::default() + }; + + let spans = search.highlight_spans("Discovery DISK", Style::default()); + + assert_eq!( + spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(), + "Discovery DISK" + ); + assert!(spans[0].style.add_modifier.contains(Modifier::BOLD)); + assert!(spans[0].style.add_modifier.contains(Modifier::UNDERLINED)); + assert!(spans[2].style.add_modifier.contains(Modifier::BOLD)); + assert!(spans[2].style.add_modifier.contains(Modifier::UNDERLINED)); +} + +#[test] +fn ctrl_backspace_deletes_the_previous_word() { + let mut search = SearchState { + query: "alpha βeta ".to_owned(), + ..SearchState::default() + }; + + search.backspace(KeyModifiers::CONTROL); + + assert_eq!(search.query, "alpha "); +} + +#[test] +fn deleting_a_word_before_the_cursor_preserves_the_suffix() { + let mut text = "hello brave world".to_owned(); + let cursor = "hello brave".chars().count(); + + let cursor = delete_previous_word(&mut text, cursor); + + assert_eq!(text, "hello world"); + assert_eq!(cursor, "hello ".chars().count()); +} diff --git a/src/tui/tests/widgets/settings.rs b/src/tui/tests/widgets/settings.rs new file mode 100644 index 0000000..1027bf3 --- /dev/null +++ b/src/tui/tests/widgets/settings.rs @@ -0,0 +1,16 @@ +use super::*; + +#[test] +fn removing_selected_last_profile_clamps_selection() { + let tmp = tempfile::tempdir().unwrap(); + let mut state = SettingsState::new(tmp.path().to_path_buf()); + state.profiles = vec!["first".to_string(), "second".to_string()]; + state.active_profile = Some("second".to_string()); + state.list_state.selected = Some(2); + + state.remove_profile("second"); + + assert_eq!(state.profiles, vec!["first"]); + assert_eq!(state.active_profile, None); + assert_eq!(state.list_state.selected, Some(1)); +} diff --git a/src/tui/widgets/snapshots/rmcl__tui__widgets__instances__tests__instances_list_renders_empty.snap b/src/tui/tests/widgets/snapshots/rmcl__tui__widgets__instances__tests__instances_list_renders_empty.snap similarity index 92% rename from src/tui/widgets/snapshots/rmcl__tui__widgets__instances__tests__instances_list_renders_empty.snap rename to src/tui/tests/widgets/snapshots/rmcl__tui__widgets__instances__tests__instances_list_renders_empty.snap index 6ffe0b6..6b03112 100644 --- a/src/tui/widgets/snapshots/rmcl__tui__widgets__instances__tests__instances_list_renders_empty.snap +++ b/src/tui/tests/widgets/snapshots/rmcl__tui__widgets__instances__tests__instances_list_renders_empty.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/instances.rs +source: src/tui/tests/widgets/instances.rs expression: terminal.backend() --- "╭Instances─────────────────────────────╮" diff --git a/src/tui/widgets/snapshots/rmcl__tui__widgets__instances__tests__instances_list_renders_three_instances.snap b/src/tui/tests/widgets/snapshots/rmcl__tui__widgets__instances__tests__instances_list_renders_three_instances.snap similarity index 94% rename from src/tui/widgets/snapshots/rmcl__tui__widgets__instances__tests__instances_list_renders_three_instances.snap rename to src/tui/tests/widgets/snapshots/rmcl__tui__widgets__instances__tests__instances_list_renders_three_instances.snap index 69020ed..3394013 100644 --- a/src/tui/widgets/snapshots/rmcl__tui__widgets__instances__tests__instances_list_renders_three_instances.snap +++ b/src/tui/tests/widgets/snapshots/rmcl__tui__widgets__instances__tests__instances_list_renders_three_instances.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/instances.rs +source: src/tui/tests/widgets/instances.rs expression: terminal.backend() --- "╭Instances─────────────────────────────╮" diff --git a/src/tui/widgets/account.rs b/src/tui/widgets/account.rs index 8640a1a..15731c2 100644 --- a/src/tui/widgets/account.rs +++ b/src/tui/widgets/account.rs @@ -138,7 +138,7 @@ pub fn handle_key(key_event: &KeyEvent, state: &mut AccountState) -> bool { } KeyCode::Backspace => { let mut new_name = name.clone(); - new_name.pop(); + super::search::backspace(&mut new_name, key_event.modifiers); state.add_mode = AddMode::OfflineNameInput(new_name); true } @@ -220,14 +220,21 @@ pub fn render(frame: &mut Frame, area: Rect, focused: FocusedArea, state: &mut A .border_type(BORDER_STYLE.to_border_type()) .border_style(Style::default().fg(color)); - if focused == FocusedArea::Account { - let lines = super::popups::keybind_lines_wrapped( - &[("⏎", " select"), ("a", " add"), ("d", " del")], + let keybind_line = if focused == FocusedArea::Account { + Some(super::popups::keybind_line_fitted( + &[ + ("⏎", " select"), + ("a", " add"), + ("d", " del"), + ("Esc", " back"), + ], area.width.saturating_sub(2), - ); - for line in lines { - block = block.title_bottom(line); - } + )) + } else { + None + }; + if let Some(line) = keybind_line { + block = block.title_bottom(line); } let inner = block.inner(area); @@ -350,7 +357,7 @@ fn render_choose_popup(frame: &mut Frame) { PopupFrame { title: Line::from(" Add Account ").centered(), border_color, - bg: None, + bg: Some(theme.surface()), keybinds: Some(Line::from(Span::styled( " Esc: cancel ", Style::default().fg(dim_color), @@ -529,3 +536,7 @@ fn render_device_code_popup(frame: &mut Frame, info: &DeviceCodeInfo) { } .render(area, frame.buffer_mut()); } + +#[cfg(test)] +#[path = "../tests/widgets/account.rs"] +mod tests; diff --git a/src/tui/widgets/content/discovery.rs b/src/tui/widgets/content/discovery.rs new file mode 100644 index 0000000..30609af --- /dev/null +++ b/src/tui/widgets/content/discovery.rs @@ -0,0 +1,1831 @@ +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use crossterm::event::{KeyCode, KeyEvent}; + +use crate::instance::content::entry::ContentEntry; +use crate::instance::{ContentKind, InstanceConfig, ModLoader}; +use crate::net::modrinth::{DiscoveryProject, DiscoveryResults, VersionInfo}; + +use super::list::{ContentListState, ContentStream}; + +pub const PAGE_SIZE: usize = 100; +const PREFETCH_VIEWPORTS: usize = 2; +const MIN_PREFETCH_ITEMS: usize = 10; +const SEARCH_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(300); +const PAGE_RETRY_BASE_DELAY: std::time::Duration = std::time::Duration::from_millis(500); +const PAGE_RETRY_MAX_DELAY: std::time::Duration = std::time::Duration::from_secs(8); + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum ContentMode { + #[default] + Installed, + Discover, +} + +impl ContentMode { + pub fn toggle(self) -> Self { + match self { + Self::Installed => Self::Discover, + Self::Discover => Self::Installed, + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Installed => "Installed", + Self::Discover => "Discovery", + } + } +} + +pub struct PendingDiscoveryResult { + generation: u64, + offset: usize, + result: Result, + sources: Vec<(String, crate::instance::ProviderProject)>, +} + +pub struct DiscoveryPageError { + pub message: String, + pub retryable: bool, +} + +pub struct DiscoveryPageResult { + pub received: usize, + pub total_hits: usize, +} + +pub(crate) struct MergedDiscoveryProject { + pub stem: String, + pub provider: String, + pub project: DiscoveryProject, +} + +pub(crate) struct MergedDiscoveryResults { + pub projects: Vec, + pub sources: Vec<(String, crate::instance::ProviderProject)>, + pub received: usize, + pub total_hits: usize, +} + +pub struct DiscoveryRequest { + pub generation: u64, + pub offset: usize, + pub limit: usize, + pub pending: PendingDiscovery, + pub stream: ContentStream, + pub reconcile: bool, + pub loaded_icon_stems: std::collections::HashSet, + pub known_projects: std::collections::HashMap, +} + +pub(crate) struct ContentDiscoveryTarget { + pub instance: InstanceConfig, + pub kind: ContentKind, + pub manifest: Option, + pub minecraft_dir: PathBuf, +} + +pub(crate) enum DiscoveryTarget { + Content(Box), + Modpacks, +} + +async fn search_provider( + provider: Option<&dyn crate::instance::content::provider::ContentProvider>, + enabled: bool, + target: &DiscoveryTarget, + query: &str, + offset: usize, + limit: usize, +) -> Option> { + let provider = provider.filter(|_| enabled)?; + Some(match target { + DiscoveryTarget::Content(content) => { + provider + .search(content.kind, query, &content.instance, offset, limit) + .await + } + DiscoveryTarget::Modpacks => provider.search_modpacks(query, offset, limit).await, + }) +} + +pub(crate) fn spawn_provider_search( + query: String, + target: DiscoveryTarget, + meta_dir: PathBuf, + request: DiscoveryRequest, +) { + let DiscoveryRequest { + generation, + offset, + limit, + pending, + stream, + reconcile, + loaded_icon_stems, + known_projects, + } = request; + tokio::spawn(async move { + let client = crate::net::HttpClient::new(); + let registry = + crate::instance::content::provider::ProviderRegistry::configured(client.clone()); + let (modrinth_result, curseforge_result) = tokio::join!( + search_provider( + registry.get("modrinth"), + crate::config::SETTINGS + .content + .discovery_provider_enabled("modrinth"), + &target, + &query, + offset, + limit, + ), + search_provider( + registry.get("curseforge"), + crate::config::SETTINGS + .content + .discovery_provider_enabled("curseforge"), + &target, + &query, + offset, + limit, + ) + ); + let failure = match (&modrinth_result, &curseforge_result) { + (Some(Err(error)), Some(Err(_))) | (Some(Err(error)), None) => { + Some((error.to_string(), error.is_retryable())) + } + (None, Some(Err(error))) => Some((error.to_string(), error.is_retryable())), + (None, None) => Some(("No discovery provider is available".to_owned(), false)), + _ => None, + }; + let mut merged_sources = Vec::new(); + let result = if let Some((message, retryable)) = failure { + Err(DiscoveryPageError { message, retryable }) + } else { + let mut pages = Vec::new(); + if let Some(Ok(results)) = modrinth_result { + pages.push(("modrinth", results)); + } + if let Some(Ok(results)) = curseforge_result { + pages.push(("curseforge", results)); + } + let mut merged = merge_provider_results( + pages, + crate::config::SETTINGS.content.preferred_provider(), + known_projects, + ); + refresh_source_installed_versions(&mut merged.sources, &target); + let mut returned = std::collections::HashSet::new(); + let icon_slots = Arc::new(tokio::sync::Semaphore::new(8)); + for merged_project in merged.projects { + let stem = merged_project.stem; + let provider = merged_project.provider; + let mut project = merged_project.project; + returned.insert(stem.clone()); + let project_id = project.id.clone(); + let installed_path = match &target { + DiscoveryTarget::Content(content) => merged + .sources + .iter() + .filter(|(source_stem, _)| source_stem == &stem) + .find_map(|(_, source)| { + content.manifest.as_ref().and_then(|manifest| { + manifest.resolved_project_path( + &source.provider, + &source.project_id, + &content.minecraft_dir, + ) + }) + }), + DiscoveryTarget::Modpacks => None, + }; + let cached_icon = crate::storage::MetadataPaths::new(&meta_dir) + .provider_icons(&provider) + .join(format!("{project_id}.img")); + if !loaded_icon_stems.contains(&stem) + && let Ok(bytes) = tokio::fs::read(&cached_icon).await + && !bytes.is_empty() + { + project.icon_bytes = Some(bytes); + } + let icon_url = (!loaded_icon_stems.contains(&stem) && project.icon_bytes.is_none()) + .then(|| project.icon_url.clone()) + .flatten(); + let entry = provider_project_entry(project, &provider, stem, installed_path); + let icon = icon_url.map(|url| (url, entry.file_stem.clone(), entry.path.clone())); + if !stream.upsert(entry) { + break; + } + if let Some((url, file_stem, path)) = icon { + let client = client.clone(); + let stream = stream.clone(); + let icon_slots = icon_slots.clone(); + tokio::spawn(async move { + let Ok(_permit) = icon_slots.acquire_owned().await else { + return; + }; + match client + .get_bytes_limited(&url, crate::net::MAX_PROVIDER_ASSET_BYTES) + .await + { + Ok(bytes) if !bytes.is_empty() => { + if let Some(parent) = cached_icon.parent() { + let _ = tokio::fs::create_dir_all(parent).await; + } + let _ = tokio::fs::write(cached_icon, &bytes).await; + stream.send_icon(file_stem, path, bytes); + } + _ => { + stream.send_icon_unavailable(file_stem, path); + } + } + }); + } + } + if reconcile { + stream.retain(returned); + } + let received = merged.received; + let total_hits = merged.total_hits; + merged_sources = merged.sources; + Ok(DiscoveryPageResult { + received, + total_hits, + }) + }; + DiscoveryState::push_provider_result(&pending, generation, offset, result, merged_sources); + }); +} + +pub(crate) fn spawn_project_page(request: ProjectPageRequest) { + tokio::spawn(async move { + let client = crate::net::HttpClient::new(); + let mut image_urls = request.image_urls; + if request.cached_project.is_none() { + let progress = crate::feedback::progress::ProgressTask::start(format!( + "Loading {} from {}", + request.project_title, request.provider + )); + let registry = + crate::instance::content::provider::ProviderRegistry::configured(client.clone()); + match registry.get(&request.provider) { + Some(provider) => match provider.project(&request.project_id).await { + Ok(project) => { + image_urls = crate::tui::widgets::markdown::image_urls( + &project.title, + &project.body, + ); + DiscoveryState::push_action_result( + &request.pending, + DiscoveryActionResult::ProjectPage { + request_id: request.request_id, + project_id: request.project_id.clone(), + result: Ok(project), + }, + ); + progress.finish(); + } + Err(error) => { + progress.fail(&error); + DiscoveryState::push_action_result( + &request.pending, + DiscoveryActionResult::ProjectPage { + request_id: request.request_id, + project_id: request.project_id, + result: Err(error.to_string()), + }, + ); + return; + } + }, + None => { + let error = format!("{} content provider is unavailable", request.provider); + progress.fail(&error); + DiscoveryState::push_action_result( + &request.pending, + DiscoveryActionResult::ProjectPage { + request_id: request.request_id, + project_id: request.project_id, + result: Err(error), + }, + ); + return; + } + } + } + image_urls.sort(); + image_urls.dedup(); + let semaphore = Arc::new(tokio::sync::Semaphore::new(4)); + let mut tasks = tokio::task::JoinSet::new(); + for url in image_urls { + let client = client.clone(); + let semaphore = semaphore.clone(); + tasks.spawn(async move { + let result = async { + let _permit = semaphore + .acquire_owned() + .await + .map_err(|error| error.to_string())?; + let bytes = client + .get_bytes_limited(&url, crate::net::MAX_PROVIDER_ASSET_BYTES) + .await + .map_err(|error| error.to_string())?; + tokio::task::spawn_blocking(move || { + crate::tui::widgets::markdown::decode_image(&bytes) + }) + .await + .map_err(|error| error.to_string())? + } + .await; + (url, result) + }); + } + while let Some(task) = tasks.join_next().await { + let (url, result) = match task { + Ok(result) => result, + Err(error) => { + tracing::debug!("Project image task failed: {error}"); + continue; + } + }; + DiscoveryState::push_action_result( + &request.pending, + DiscoveryActionResult::ProjectImage { + request_id: request.request_id, + project_id: request.project_id.clone(), + url, + result, + }, + ); + } + }); +} + +pub(crate) type PendingDiscovery = Arc>>; + +pub struct VersionPopupState { + request_id: u64, + pub project_id: String, + pub provider: String, + pub project_title: String, + pub sources: Vec, + pub source_index: usize, + pub installed_path: Option, + pub current_version_id: Option, + pub minecraft_versions: Vec, + pub selected_minecraft_version: Option, + pub selecting_minecraft_version: bool, + pub selecting_world: bool, + pub worlds: ContentListState, + pub target_world: Option<(String, PathBuf)>, + pub versions: Vec, + pub selected: usize, + pub loading: bool, + pub resolving_dependencies: bool, + pub confirming: bool, + pub installing: bool, + pub dependency_plan: Option, + pub error: Option, +} + +impl VersionPopupState { + pub fn title(&self) -> String { + let installed = self.installed_path.is_some() || self.current_version_id.is_some(); + if installed + && self.current_version_id.as_deref().is_some_and(|current| { + self.selected_version() + .is_some_and(|version| version.id == current) + }) + { + format!("Reinstall {}", self.project_title) + } else if installed { + format!("Change {} version", self.project_title) + } else { + format!("Install {}", self.project_title) + } + } + + pub fn provider_label(&self) -> &str { + match self.provider.as_str() { + "curseforge" => "CurseForge", + _ => "Modrinth", + } + } + + pub fn visible_versions(&self) -> impl Iterator { + self.versions.iter().filter(|version| { + self.selected_minecraft_version + .as_ref() + .is_none_or(|selected| { + version + .game_versions + .iter() + .any(|version| version == selected) + }) + }) + } + + pub fn selected_version(&self) -> Option<&VersionInfo> { + self.visible_versions().nth(self.selected) + } + + fn item_count(&self) -> usize { + if self.selecting_minecraft_version { + self.minecraft_versions.len() + } else if self.selecting_world { + self.worlds.filtered_indices().len() + } else { + self.visible_versions().count() + } + } +} + +pub struct VersionsRequest { + pub request_id: u64, + pub project_id: String, + pub provider: String, + pub current_version_id: Option, + pub pending: PendingActions, +} + +pub struct ProjectPageRequest { + pub request_id: u64, + pub project_id: String, + pub project_title: String, + pub provider: String, + pub cached_project: Option, + pub image_urls: Vec, + pub pending: PendingActions, +} + +pub struct ProjectPageState { + request_id: u64, + project_id: String, + pub title: String, + pub document: Option, + pub error: Option, + pub scroll: usize, + pub max_scroll: usize, +} + +pub struct InstallRequest { + pub request_id: u64, + pub generation: u64, + pub project_id: String, + pub project_title: String, + pub provider: String, + pub version: VersionInfo, + pub installed_path: Option, + pub dependency_plan: Option, + pub target_world: Option<(String, PathBuf)>, + pub pending: PendingActions, +} + +pub struct DependencyRequest { + pub request_id: u64, + pub project_id: String, + pub root: crate::instance::content::dependencies::InstallRoot, + pub pending: PendingActions, +} + +pub struct InstallCompletion { + pub path: PathBuf, + pub replaced: bool, + pub skipped: bool, + pub orphaned_dependencies: Vec, +} + +pub enum DiscoveryActionResult { + ProjectPage { + request_id: u64, + project_id: String, + result: Result, + }, + ProjectImage { + request_id: u64, + project_id: String, + url: String, + result: Result, + }, + Versions { + request_id: u64, + project_id: String, + result: Result, String>, + }, + Dependencies { + request_id: u64, + project_id: String, + result: Result, + }, + Install { + request_id: u64, + generation: u64, + project_id: String, + project_title: String, + result: Result, + }, +} + +pub(crate) type PendingActions = Arc>>; + +pub struct DiscoveryState { + pub kind: ContentKind, + pub modpacks: bool, + pub list: ContentListState, + pub search: crate::tui::widgets::search::SearchState, + pub total_hits: usize, + pub error: Option, + context: Option, + generation: u64, + pending: PendingDiscovery, + pending_actions: PendingActions, + project_pages: std::collections::HashMap, + project_images: std::collections::HashMap<(String, String), image::DynamicImage>, + sources: std::collections::HashMap>, + pub project_page: Option, + pub version_popup: Option, + pending_orphan_cleanup: Option>, + next_action_request_id: u64, + stream: Option, + next_offset: usize, + page_loading: bool, + exhausted: bool, + viewport_rows: u16, + search_changed_at: Option, + retry_page_at: Option, + page_retry_attempt: u32, +} + +impl DiscoveryState { + pub fn new(kind: ContentKind) -> Self { + Self { + kind, + modpacks: false, + list: ContentListState::default(), + search: crate::tui::widgets::search::SearchState::default(), + total_hits: 0, + error: None, + context: None, + generation: 0, + pending: Arc::new(Mutex::new(Vec::new())), + pending_actions: Arc::new(Mutex::new(Vec::new())), + project_pages: std::collections::HashMap::new(), + project_images: std::collections::HashMap::new(), + sources: std::collections::HashMap::new(), + project_page: None, + version_popup: None, + pending_orphan_cleanup: None, + next_action_request_id: 0, + stream: None, + next_offset: 0, + page_loading: false, + exhausted: false, + viewport_rows: 0, + search_changed_at: None, + retry_page_at: None, + page_retry_attempt: 0, + } + } + + pub fn new_modpacks() -> Self { + let mut state = Self::new(ContentKind::ResourcePack); + state.modpacks = true; + state + } + + pub fn needs_search(&self, instance: &InstanceConfig) -> bool { + self.context.as_deref() != Some(discovery_context(instance).as_str()) + } + + pub fn unavailable_message(&self, instance: &InstanceConfig) -> Option<&'static str> { + self.kind.unavailable_message(instance.loader) + } + + pub fn set_unavailable(&mut self, instance: &InstanceConfig) { + let context = discovery_context(instance); + if self.context.as_deref() == Some(&context) && self.list.entries.is_empty() { + return; + } + self.context = None; + drop(self.begin_search(instance)); + self.stream = None; + self.page_loading = false; + self.exhausted = true; + } + + pub fn begin_search(&mut self, instance: &InstanceConfig) -> DiscoveryRequest { + self.begin_search_context(discovery_context(instance)) + } + + pub fn begin_modpack_search(&mut self) -> DiscoveryRequest { + self.begin_search_context("modpacks".to_owned()) + } + + fn begin_search_context(&mut self, context: String) -> DiscoveryRequest { + self.generation = self.generation.wrapping_add(1); + self.project_page = None; + self.version_popup = None; + self.sources.clear(); + let reconcile = + self.context.as_deref() == Some(context.as_str()) && !self.list.entries.is_empty(); + self.context = Some(context.clone()); + self.total_hits = 0; + self.error = None; + self.next_offset = 0; + self.page_loading = true; + self.exhausted = false; + self.search_changed_at = None; + self.retry_page_at = None; + self.page_retry_attempt = 0; + let loaded_icon_stems = if reconcile { + self.list + .entries + .iter() + .filter(|entry| entry.icon_bytes.is_some()) + .map(|entry| entry.file_stem.clone()) + .collect() + } else { + std::collections::HashSet::new() + }; + let stream = if reconcile { + self.list.refresh_source_stream(context) + } else { + self.list.start_source_stream(context) + }; + self.list.search.query.clone_from(&self.search.query); + self.list.set_search_filtering(false); + self.stream = Some(stream.clone()); + DiscoveryRequest { + generation: self.generation, + offset: 0, + limit: self.request_limit(), + pending: self.pending.clone(), + stream, + reconcile, + loaded_icon_stems, + known_projects: std::collections::HashMap::new(), + } + } + + pub fn begin_next_page(&mut self) -> Option { + if !self.should_load_more() { + return None; + } + self.page_loading = true; + self.retry_page_at = None; + let loaded_icon_stems = self + .list + .entries + .iter() + .filter(|entry| entry.icon_bytes.is_some()) + .map(|entry| entry.file_stem.clone()) + .collect(); + let known_projects = self + .list + .entries + .iter() + .filter_map(|entry| { + let source = entry.provider_project.as_ref()?; + let slug = entry.source_slug.as_deref()?; + Some(( + project_identity_parts(&entry.name, slug), + (entry.file_stem.clone(), source.provider.clone()), + )) + }) + .collect(); + Some(DiscoveryRequest { + generation: self.generation, + offset: self.next_offset, + limit: self.request_limit(), + pending: self.pending.clone(), + stream: self.stream.clone()?, + reconcile: false, + loaded_icon_stems, + known_projects, + }) + } + + pub fn set_viewport_rows(&mut self, rows: u16) { + self.viewport_rows = rows; + } + + fn request_limit(&self) -> usize { + let viewport_items = usize::from(self.viewport_rows / 3); + if viewport_items == 0 { + PAGE_SIZE + } else { + viewport_items.saturating_mul(4).min(PAGE_SIZE) + } + } + + pub fn begin_versions(&mut self) -> Option { + let filtered = self.list.filtered_indices(); + let index = self + .list + .list_state + .selected + .and_then(|selected| filtered.get(selected))?; + let entry = self.list.entries.get(*index)?.clone(); + let installed_path = (self.kind != ContentKind::DataPack) + .then(|| entry.installed_path.clone()) + .flatten(); + let sources = self + .sources + .get(&entry.file_stem) + .cloned() + .unwrap_or_else(|| entry.provider_project.clone().into_iter().collect()); + self.open_version_popup(&entry.name, sources, installed_path, None) + } + + pub fn begin_installed_versions( + &mut self, + entry: &ContentEntry, + record: &crate::instance::ContentFileRecord, + target_world: Option<(String, PathBuf)>, + ) -> Option { + let current = record.resolved_project()?.clone(); + let mut sources = vec![current.clone()]; + for alias in &record.provider_aliases { + if !sources.iter().any(|source| { + source.provider == alias.provider && source.project_id == alias.project_id + }) { + sources.push(alias.clone()); + } + } + if let Some(discovered) = self.sources.values().find(|discovered| { + discovered.iter().any(|candidate| { + sources.iter().any(|installed| { + candidate.provider == installed.provider + && candidate.project_id == installed.project_id + }) + }) + }) { + for source in discovered { + if !sources.iter().any(|installed| { + source.provider == installed.provider + && source.project_id == installed.project_id + }) { + sources.push(source.clone()); + } + } + } + self.open_version_popup(&entry.name, sources, Some(entry.path.clone()), target_world) + } + + pub fn begin_managed_modpack_versions( + &mut self, + project_title: &str, + source: crate::instance::ProviderProject, + ) -> Option { + let request = self.open_version_popup(project_title, vec![source], None, None)?; + self.version_popup.as_mut()?.selecting_minecraft_version = false; + Some(request) + } + + fn open_version_popup( + &mut self, + project_title: &str, + mut sources: Vec, + installed_path: Option, + target_world: Option<(String, PathBuf)>, + ) -> Option { + let preferred = crate::config::SETTINGS.content.preferred_provider(); + sources.sort_by_key(|source| source.provider != preferred); + let source = sources.first()?.clone(); + self.next_action_request_id = self.next_action_request_id.wrapping_add(1); + let request_id = self.next_action_request_id; + let current_version_id = (!source.version_id.is_empty()).then(|| source.version_id.clone()); + self.version_popup = Some(VersionPopupState { + request_id, + project_id: source.project_id.clone(), + provider: source.provider.clone(), + project_title: project_title.to_owned(), + sources, + source_index: 0, + installed_path, + current_version_id: current_version_id.clone(), + minecraft_versions: Vec::new(), + selected_minecraft_version: None, + selecting_minecraft_version: self.modpacks, + selecting_world: false, + worlds: ContentListState::default(), + target_world, + versions: Vec::new(), + selected: 0, + loading: true, + resolving_dependencies: false, + confirming: false, + installing: false, + dependency_plan: None, + error: None, + }); + Some(VersionsRequest { + request_id, + project_id: source.project_id, + provider: source.provider, + current_version_id, + pending: self.pending_actions.clone(), + }) + } + + pub fn switch_version_source(&mut self) -> Option { + let selecting_minecraft_version = self.modpacks; + let popup = self.version_popup.as_mut()?; + if popup.loading || popup.installing || popup.selecting_world || popup.sources.len() < 2 { + return None; + } + popup.source_index = (popup.source_index + 1) % popup.sources.len(); + let source = popup.sources.get(popup.source_index)?.clone(); + self.next_action_request_id = self.next_action_request_id.wrapping_add(1); + popup.request_id = self.next_action_request_id; + popup.project_id.clone_from(&source.project_id); + popup.provider.clone_from(&source.provider); + popup.minecraft_versions.clear(); + popup.selected_minecraft_version = None; + popup.selecting_minecraft_version = selecting_minecraft_version; + popup.selecting_world = false; + popup.worlds = ContentListState::default(); + if self.kind != ContentKind::DataPack || popup.current_version_id.is_none() { + popup.target_world = None; + } + popup.current_version_id = + (!source.version_id.is_empty()).then(|| source.version_id.clone()); + popup.versions.clear(); + popup.selected = 0; + popup.loading = true; + popup.resolving_dependencies = false; + popup.confirming = false; + popup.dependency_plan = None; + popup.error = None; + Some(VersionsRequest { + request_id: popup.request_id, + project_id: source.project_id, + provider: source.provider, + current_version_id: popup.current_version_id.clone(), + pending: self.pending_actions.clone(), + }) + } + + pub fn begin_project_page(&mut self) -> Option { + let filtered = self.list.filtered_indices(); + let index = self + .list + .list_state + .selected + .and_then(|selected| filtered.get(selected))?; + let entry = self.list.entries.get(*index)?; + let source = entry.provider_project.as_ref()?; + let project_id = source.project_id.clone(); + let project_title = entry.name.clone(); + self.next_action_request_id = self.next_action_request_id.wrapping_add(1); + let request_id = self.next_action_request_id; + let cached = self.project_pages.get(&project_id); + let mut document = cached.map(|project| { + crate::tui::widgets::markdown::Document::new(&project.title, &project.body) + }); + if let Some(document) = document.as_mut() { + for url in document.image_urls() { + if let Some(image) = self.project_images.get(&(project_id.clone(), url.clone())) { + document.set_image(&url, Ok(image.clone())); + } + } + } + let image_urls = document + .as_ref() + .map(crate::tui::widgets::markdown::Document::image_urls) + .unwrap_or_default() + .into_iter() + .filter(|url| { + !self + .project_images + .contains_key(&(project_id.clone(), url.clone())) + }) + .collect::>(); + self.project_page = Some(ProjectPageState { + request_id, + project_id: project_id.clone(), + title: cached + .map(|project| project.title.clone()) + .unwrap_or_else(|| project_title.clone()), + document, + error: None, + scroll: 0, + max_scroll: 0, + }); + if cached.is_some() && image_urls.is_empty() { + return None; + } + Some(ProjectPageRequest { + request_id, + project_id, + project_title, + provider: source.provider.clone(), + cached_project: cached.cloned(), + image_urls, + pending: self.pending_actions.clone(), + }) + } + + pub fn project_page_open(&self) -> bool { + self.project_page.is_some() + } + + pub fn project_link_at(&self, x: u16, y: u16) -> Option<&str> { + self.project_page.as_ref()?.document.as_ref()?.link_at(x, y) + } + + pub fn refresh_installed_manifest( + &mut self, + manifest: &crate::instance::ContentManifest, + minecraft_dir: &std::path::Path, + ) { + let installed_identity = |source: &crate::instance::ProviderProject| { + manifest.files.iter().find_map(|record| { + record + .project_for_provider(&source.provider, &source.project_id) + .cloned() + }) + }; + for sources in self.sources.values_mut() { + for source in sources { + source.version_id = installed_identity(source) + .map(|installed| installed.version_id) + .unwrap_or_default(); + } + } + let mut changed = false; + for entry in &mut self.list.entries { + if let Some(source) = entry.provider_project.as_mut() { + source.version_id = installed_identity(source) + .map(|installed| installed.version_id) + .unwrap_or_default(); + } + let installed_path = self + .sources + .get(&entry.file_stem) + .into_iter() + .flatten() + .chain(entry.provider_project.iter()) + .find_map(|project| { + manifest.resolved_project_path( + &project.provider, + &project.project_id, + minecraft_dir, + ) + }); + if entry.installed_path != installed_path { + entry.title_suffix = installed_path.is_some().then(|| "Installed".to_owned()); + entry.installed_path = installed_path; + changed = true; + } + } + if changed { + crate::feedback::request_redraw(); + } + } + + pub fn selected_is_installed(&self) -> bool { + self.selected_installed_entry().is_some() + } + + pub fn pending_installed_delete( + &self, + ) -> Option { + let entry = self.selected_installed_entry()?; + Some(crate::tui::widgets::content::list::PendingContentDelete { + name: entry.name.clone(), + path: entry.installed_path.clone()?, + }) + } + + pub fn clear_installed_path(&mut self, path: &std::path::Path) -> bool { + let Some(entry) = self + .list + .entries + .iter_mut() + .find(|entry| entry.installed_path.as_deref() == Some(path)) + else { + return false; + }; + entry.installed_path = None; + entry.title_suffix = None; + crate::feedback::request_redraw(); + true + } + + fn selected_installed_entry(&self) -> Option<&ContentEntry> { + let filtered = self.list.filtered_indices(); + let index = self + .list + .list_state + .selected + .and_then(|selected| filtered.get(selected))?; + self.list + .entries + .get(*index) + .filter(|entry| entry.installed_path.is_some()) + } + + pub fn begin_install(&mut self) -> Option { + let popup = self.version_popup.as_ref()?; + if popup.loading || popup.installing || !popup.confirming { + return None; + } + let version = popup.selected_version()?.clone(); + let request = InstallRequest { + request_id: popup.request_id, + generation: self.generation, + project_id: popup.project_id.clone(), + project_title: popup.project_title.clone(), + provider: popup.provider.clone(), + version, + installed_path: popup.installed_path.clone(), + dependency_plan: popup.dependency_plan.clone(), + target_world: popup.target_world.clone(), + pending: self.pending_actions.clone(), + }; + self.version_popup = None; + Some(request) + } + + pub fn begin_confirmation(&mut self) -> bool { + let Some(popup) = self.version_popup.as_mut() else { + return false; + }; + if popup.loading + || popup.installing + || popup.selecting_minecraft_version + || popup.selecting_world + || popup.selected_version().is_none() + { + return false; + } + popup.confirming = true; + popup.error = None; + true + } + + pub fn begin_world_selection(&mut self, mut worlds: Vec) -> bool { + let Some(popup) = self.version_popup.as_mut() else { + return false; + }; + if self.kind != ContentKind::DataPack + || popup.loading + || popup.installing + || popup.confirming + || popup.selected_version().is_none() + { + return false; + } + for world in &mut worlds { + world.icon_lines = world + .icon_bytes + .as_ref() + .and_then(|bytes| crate::instance::content::make_icon_pixels(bytes, 6, 3)) + .or_else(|| Some(crate::instance::content::fallback_icon())); + } + popup.worlds = ContentListState::default(); + popup.worlds.entries = worlds; + popup + .worlds + .list_state + .select((!popup.worlds.entries.is_empty()).then_some(0)); + popup.selecting_world = true; + popup.target_world = None; + popup.installed_path = None; + popup.dependency_plan = None; + popup.error = None; + true + } + + pub fn select_world( + &mut self, + manifest: Option<&crate::instance::ContentManifest>, + minecraft_dir: &std::path::Path, + ) -> bool { + let Some(popup) = self.version_popup.as_mut() else { + return false; + }; + if !popup.selecting_world || popup.loading || popup.installing { + return false; + } + let Some(world) = popup.worlds.selected_entry() else { + return false; + }; + let world_name = world.name.clone(); + let world_path = world.path.clone(); + popup.installed_path = manifest.and_then(|manifest| { + popup.sources.iter().find_map(|source| { + manifest.resolved_project_path_under( + &source.provider, + &source.project_id, + minecraft_dir, + &world_path.join("datapacks"), + ) + }) + }); + popup.target_world = Some((world_name, world_path)); + popup.selecting_world = false; + popup.error = None; + true + } + + pub fn begin_dependency_resolution(&mut self) -> Option { + let kind = self.kind; + let popup = self.version_popup.as_mut()?; + if popup.loading + || popup.installing + || popup.selecting_minecraft_version + || popup.confirming + { + return None; + } + let version = popup.selected_version()?.clone(); + let force_reinstall = popup.current_version_id.as_deref() == Some(version.id.as_str()); + popup.loading = true; + popup.resolving_dependencies = true; + popup.error = None; + Some(DependencyRequest { + request_id: popup.request_id, + project_id: popup.project_id.clone(), + root: crate::instance::content::dependencies::InstallRoot { + provider: popup.provider.clone(), + project_id: popup.project_id.clone(), + title: popup.project_title.clone(), + version, + installed_path: popup.installed_path.clone(), + kind, + target_world: popup.target_world.as_ref().map(|(_, path)| path.clone()), + force_reinstall, + }, + pending: self.pending_actions.clone(), + }) + } + + pub fn select_minecraft_version(&mut self) -> bool { + let Some(popup) = self.version_popup.as_mut() else { + return false; + }; + if popup.loading || popup.installing || !popup.selecting_minecraft_version { + return false; + } + let Some(version) = popup.minecraft_versions.get(popup.selected).cloned() else { + return false; + }; + popup.selected_minecraft_version = Some(version); + popup.selecting_minecraft_version = false; + popup.selected = 0; + popup.error = None; + true + } + + pub fn search_due(&self) -> bool { + self.search_changed_at + .is_some_and(|changed| changed.elapsed() >= SEARCH_DEBOUNCE) + } + + fn search_changed(&mut self) { + self.list.search.query.clone_from(&self.search.query); + self.list.set_search_filtering(false); + self.search_changed_at = Some(std::time::Instant::now()); + } + + pub fn push_result( + pending: &PendingDiscovery, + generation: u64, + offset: usize, + result: Result, + ) { + if let Ok(mut pending) = pending.lock() { + pending.push(PendingDiscoveryResult { + generation, + offset, + result, + sources: Vec::new(), + }); + crate::feedback::request_redraw(); + } + } + + pub fn push_provider_result( + pending: &PendingDiscovery, + generation: u64, + offset: usize, + result: Result, + sources: Vec<(String, crate::instance::ProviderProject)>, + ) { + if let Ok(mut pending) = pending.lock() { + pending.push(PendingDiscoveryResult { + generation, + offset, + result, + sources, + }); + crate::feedback::request_redraw(); + } + } + + pub fn push_action_result(pending: &PendingActions, result: DiscoveryActionResult) { + if let Ok(mut pending) = pending.lock() { + pending.push(result); + crate::feedback::request_redraw(); + } + } + + pub fn drain_pending(&mut self) { + let results = match self.pending.lock() { + Ok(mut pending) => std::mem::take(&mut *pending), + Err(_) => return, + }; + for pending in results { + if pending.generation != self.generation { + continue; + } + self.page_loading = false; + self.list.loading = false; + match pending.result { + Ok(result) => { + self.total_hits = result.total_hits; + self.next_offset = pending.offset.saturating_add(result.received); + self.exhausted = result.received == 0 || self.next_offset >= self.total_hits; + self.error = None; + self.retry_page_at = None; + self.page_retry_attempt = 0; + for (stem, source) in pending.sources { + let sources = self.sources.entry(stem).or_default(); + if !sources.iter().any(|candidate| { + candidate.provider == source.provider + && candidate.project_id == source.project_id + }) { + sources.push(source); + } + } + } + Err(error) => { + if error.retryable { + let multiplier = 1u32 << self.page_retry_attempt.min(4); + let delay = PAGE_RETRY_BASE_DELAY + .saturating_mul(multiplier) + .min(PAGE_RETRY_MAX_DELAY); + self.page_retry_attempt = self.page_retry_attempt.saturating_add(1); + self.retry_page_at = Some(std::time::Instant::now() + delay); + tracing::debug!( + "Discovery page at offset {} failed; retrying in {:?}: {}", + pending.offset, + delay, + error.message + ); + } else if pending.offset == 0 { + self.total_hits = 0; + self.error = Some(error.message); + self.exhausted = true; + } else { + tracing::warn!( + "Discovery page at offset {} failed: {}", + pending.offset, + error.message + ); + self.exhausted = true; + } + } + } + } + + self.drain_action_results(); + } + + fn drain_action_results(&mut self) { + let results = match self.pending_actions.lock() { + Ok(mut pending) => std::mem::take(&mut *pending), + Err(_) => return, + }; + for result in results { + match result { + DiscoveryActionResult::ProjectPage { + request_id, + project_id, + result, + } => { + let Some(page) = self.project_page.as_mut().filter(|page| { + page.request_id == request_id && page.project_id == project_id + }) else { + continue; + }; + match result { + Ok(project) => { + page.title.clone_from(&project.title); + page.document = Some(crate::tui::widgets::markdown::Document::new( + &project.title, + &project.body, + )); + if let Some(document) = page.document.as_mut() { + for url in document.image_urls() { + if let Some(image) = + self.project_images.get(&(project_id.clone(), url.clone())) + { + document.set_image(&url, Ok(image.clone())); + } + } + } + page.error = None; + page.scroll = 0; + page.max_scroll = 0; + self.project_pages.insert(project.id.clone(), project); + } + Err(error) => page.error = Some(error), + } + } + DiscoveryActionResult::ProjectImage { + request_id, + project_id, + url, + result, + } => { + if let Ok(image) = &result { + self.project_images + .insert((project_id.clone(), url.clone()), image.clone()); + } + let Some(page) = self.project_page.as_mut().filter(|page| { + page.request_id == request_id && page.project_id == project_id + }) else { + continue; + }; + if let Some(document) = page.document.as_mut() { + document.set_image(&url, result); + } + } + DiscoveryActionResult::Versions { + request_id, + project_id, + result, + } => { + let Some(popup) = self.version_popup.as_mut().filter(|popup| { + popup.request_id == request_id && popup.project_id == project_id + }) else { + continue; + }; + popup.loading = false; + match result { + Ok(versions) => { + popup.minecraft_versions = minecraft_versions(&versions); + popup.versions = versions; + popup.selected = 0; + popup.error = None; + } + Err(error) => popup.error = Some(error), + } + } + DiscoveryActionResult::Dependencies { + request_id, + project_id, + result, + } => { + let Some(popup) = self.version_popup.as_mut().filter(|popup| { + popup.request_id == request_id && popup.project_id == project_id + }) else { + continue; + }; + popup.loading = false; + popup.resolving_dependencies = false; + match result { + Ok(plan) => { + popup.dependency_plan = Some(plan); + popup.confirming = true; + popup.error = None; + } + Err(error) => popup.error = Some(error), + } + } + DiscoveryActionResult::Install { + request_id, + generation, + project_id, + project_title, + result, + } => match result { + Ok(completion) => { + if generation == self.generation { + let stem = self + .sources + .iter() + .find_map(|(stem, sources)| { + sources + .iter() + .any(|source| source.project_id == project_id) + .then(|| stem.clone()) + }) + .unwrap_or_else(|| project_id.clone()); + if let Some(entry) = self + .list + .entries + .iter_mut() + .find(|entry| entry.file_stem == stem) + { + entry.title_suffix = Some("Installed".to_owned()); + entry.installed_path = Some(completion.path.clone()); + } + } + let action = if completion.skipped { + "already installed" + } else if completion.replaced { + "version changed" + } else { + "installed" + }; + crate::feedback::errors::push_error(crate::feedback::errors::ErrorEvent { + id: request_id, + level: tracing::Level::INFO, + message: format!("{project_title}: {action}"), + pushed_at: std::time::Instant::now(), + }); + if !completion.orphaned_dependencies.is_empty() { + self.pending_orphan_cleanup = Some(completion.orphaned_dependencies); + } + } + Err(error) => { + crate::feedback::errors::push_error(crate::feedback::errors::ErrorEvent { + id: request_id, + level: tracing::Level::ERROR, + message: format!("{project_title}: {error}"), + pushed_at: std::time::Instant::now(), + }); + } + }, + } + } + } + + pub fn empty_text(&self) -> &str { + self.error.as_deref().unwrap_or(if self.modpacks { + "No modpacks found." + } else if self.kind == ContentKind::DataPack { + "No datapacks found." + } else { + "No projects found." + }) + } + + pub fn take_orphan_cleanup(&mut self) -> Option> { + self.pending_orphan_cleanup.take() + } + + fn should_load_more(&self) -> bool { + if self.page_loading + || self.exhausted + || self.error.is_some() + || self.stream.is_none() + || self.search_changed_at.is_some() + || self + .retry_page_at + .is_some_and(|retry_at| std::time::Instant::now() < retry_at) + { + return false; + } + let viewport_items = usize::from(self.viewport_rows).div_ceil(3); + let prefetch_items = viewport_items + .saturating_mul(PREFETCH_VIEWPORTS) + .max(MIN_PREFETCH_ITEMS); + let selected = self.list.list_state.selected.unwrap_or(0); + self.list.entries.len() < viewport_items.saturating_add(prefetch_items) + || selected.saturating_add(prefetch_items) >= self.list.entries.len() + } +} + +pub fn handle_key(key_event: &KeyEvent, state: &mut DiscoveryState) -> bool { + if let Some(popup) = state.version_popup.as_mut() { + if popup.confirming + && matches!(key_event.code, KeyCode::Left | KeyCode::Char('h')) + && !popup.installing + { + popup.confirming = false; + if popup.target_world.is_some() && popup.current_version_id.is_none() { + popup.selecting_world = true; + } + popup.error = None; + return true; + } + if popup.selecting_world + && matches!(key_event.code, KeyCode::Left | KeyCode::Char('h')) + && !popup.loading + && !popup.installing + { + popup.selecting_world = false; + popup.target_world = None; + popup.installed_path = None; + popup.dependency_plan = None; + popup.error = None; + return true; + } + if !popup.selecting_minecraft_version + && popup.selected_minecraft_version.is_some() + && matches!(key_event.code, KeyCode::Left | KeyCode::Char('h')) + && !popup.loading + && !popup.installing + { + popup.selecting_minecraft_version = true; + popup.selected = popup + .selected_minecraft_version + .as_ref() + .and_then(|selected| { + popup + .minecraft_versions + .iter() + .position(|version| version == selected) + }) + .unwrap_or(0); + return true; + } + match key_event.code { + KeyCode::Esc if !popup.installing => state.version_popup = None, + _ if popup.selecting_world => { + super::list::handle_key_no_toggle(key_event, &mut popup.worlds); + } + KeyCode::Char('j') | KeyCode::Down + if !popup.loading && !popup.installing && !popup.confirming => + { + if popup.selected + 1 < popup.item_count() { + popup.selected += 1; + } + } + KeyCode::Char('k') | KeyCode::Up + if !popup.loading && !popup.installing && !popup.confirming => + { + popup.selected = popup.selected.saturating_sub(1); + } + _ => {} + } + return true; + } + if let Some(page) = state.project_page.as_mut() { + match key_event.code { + KeyCode::Esc | KeyCode::Left | KeyCode::Char('h') => state.project_page = None, + KeyCode::Char('j') | KeyCode::Down => { + page.scroll = page.scroll.saturating_add(1).min(page.max_scroll); + } + KeyCode::Char('k') | KeyCode::Up => { + page.scroll = page.scroll.saturating_sub(1); + } + KeyCode::PageDown | KeyCode::Char('d') => { + page.scroll = page.scroll.saturating_add(10).min(page.max_scroll); + } + KeyCode::PageUp | KeyCode::Char('u') => { + page.scroll = page.scroll.saturating_sub(10); + } + KeyCode::Char('g') | KeyCode::Home => page.scroll = 0, + KeyCode::Char('G') | KeyCode::End => page.scroll = page.max_scroll, + _ => {} + } + return true; + } + if state.search.active { + let previous_query = state.search.query.clone(); + match key_event.code { + KeyCode::Enter => state.search.confirm(), + KeyCode::Esc => state.search.deactivate(), + KeyCode::Backspace => state.search.backspace(key_event.modifiers), + KeyCode::Char(c) => state.search.push(c), + _ => {} + } + let changed = state.search.query != previous_query; + if changed { + state.search_changed(); + } + return true; + } + if key_event.code == KeyCode::Char('/') { + state.search.activate(); + return true; + } + if let Some(next) = page_key_direction(key_event) { + if next { + state.list.next_page() + } else { + state.list.previous_page() + } + } else { + super::list::handle_key_no_toggle(key_event, &mut state.list) + } +} + +fn minecraft_versions(versions: &[VersionInfo]) -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut minecraft_versions = versions + .iter() + .flat_map(|version| &version.game_versions) + .filter(|game_version| { + !versions.iter().any(|version| { + version + .loaders + .iter() + .any(|loader| loader.eq_ignore_ascii_case(game_version)) + }) + }) + .filter(|game_version| seen.insert((*game_version).clone())) + .cloned() + .collect::>(); + minecraft_versions + .sort_by(|a, b| crate::tui::widgets::popups::compare_game_versions(b.as_str(), a.as_str())); + minecraft_versions +} + +pub(crate) fn page_key_direction(key_event: &KeyEvent) -> Option { + match key_event.code { + KeyCode::Char('[') => Some(false), + KeyCode::Char(']') => Some(true), + _ => None, + } +} + +fn discovery_context(instance: &InstanceConfig) -> String { + format!( + "{}:{}:{}", + instance.name, + instance.game_version, + loader_slug(instance.loader).unwrap_or("vanilla") + ) +} + +fn loader_slug(loader: ModLoader) -> Option<&'static str> { + match loader { + ModLoader::Vanilla => None, + ModLoader::Fabric => Some("fabric"), + ModLoader::Forge => Some("forge"), + ModLoader::NeoForge => Some("neoforge"), + ModLoader::Quilt => Some("quilt"), + } +} + +pub(crate) fn provider_project_entry( + project: DiscoveryProject, + provider: &str, + stem: String, + installed_path: Option, +) -> ContentEntry { + let provider_icon = project.icon_bytes.is_some() + || project + .icon_url + .as_deref() + .is_some_and(|url| !url.trim().is_empty()); + ContentEntry { + file_stem: stem.clone(), + name: project.title, + source_slug: Some(project.slug), + installed_path: installed_path.clone(), + provider_project: Some(crate::instance::ProviderProject { + provider: provider.to_owned(), + project_id: project.id.clone(), + version_id: String::new(), + }), + world_details: None, + title_suffix: installed_path.is_some().then(|| "Installed".to_owned()), + footer_label: Some(format!("{} downloads", format_downloads(project.downloads))), + footer_change: None, + description: project.description, + enabled: true, + icon_bytes: project.icon_bytes, + provider_icon, + provider_description: false, + path: PathBuf::from(stem), + icon_lines: Some(crate::instance::content::fallback_icon()), + } +} + +#[cfg(test)] +pub(crate) fn project_entry( + project: DiscoveryProject, + installed_path: Option, +) -> ContentEntry { + let stem = project.id.clone(); + provider_project_entry(project, "modrinth", stem, installed_path) +} + +pub(crate) fn project_identity(project: &DiscoveryProject) -> String { + project_identity_parts(&project.title, &project.slug) +} + +fn project_identity_parts(title: &str, slug: &str) -> String { + // ponytail: provider APIs expose no shared project id; title plus slug avoids + // hiding unrelated projects while still matching normal cross-provider copies. + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + format!("{}:{}", normalize(title), normalize(slug)) +} + +pub(crate) fn merge_provider_results( + mut pages: Vec<(&str, DiscoveryResults)>, + preferred: &str, + known_projects: std::collections::HashMap, +) -> MergedDiscoveryResults { + pages.sort_by_key(|(provider, _)| *provider != preferred); + let received = pages + .iter() + .map(|(_, page)| page.projects.len()) + .max() + .unwrap_or(0); + let total_hits = pages + .iter() + .map(|(_, page)| page.total_hits) + .max() + .unwrap_or(0); + let mut projects = Vec::new(); + let mut sources = Vec::new(); + let mut primary_stems = known_projects; + let mut used_stems = primary_stems + .values() + .map(|(stem, _)| stem.clone()) + .collect::>(); + + for (provider, page) in pages { + for project in page.projects { + let identity = project_identity(&project); + let project_id = project.id.clone(); + let existing = primary_stems.get(&identity).cloned(); + let duplicate_stem = existing + .as_ref() + .filter(|(_, existing_provider)| existing_provider != provider) + .map(|(stem, _)| stem.clone()); + if let Some((stem, existing_provider)) = existing + && provider == preferred + && existing_provider != preferred + { + projects.push(MergedDiscoveryProject { + stem: stem.clone(), + provider: provider.to_owned(), + project: project.clone(), + }); + primary_stems.insert(identity.clone(), (stem, provider.to_owned())); + } + let stem = duplicate_stem.unwrap_or_else(|| { + let mut stem = identity.clone(); + if !used_stems.insert(stem.clone()) { + stem = format!("{identity}:{provider}:{}", project.id); + used_stems.insert(stem.clone()); + } + primary_stems + .entry(identity) + .or_insert_with(|| (stem.clone(), provider.to_owned())); + projects.push(MergedDiscoveryProject { + stem: stem.clone(), + provider: provider.to_owned(), + project, + }); + stem + }); + sources.push(( + stem, + crate::instance::ProviderProject { + provider: provider.to_owned(), + project_id, + version_id: String::new(), + }, + )); + } + } + + MergedDiscoveryResults { + projects, + sources, + received, + total_hits, + } +} + +fn refresh_source_installed_versions( + sources: &mut [(String, crate::instance::ProviderProject)], + target: &DiscoveryTarget, +) { + let DiscoveryTarget::Content(content) = target else { + return; + }; + let Some(manifest) = &content.manifest else { + return; + }; + for (_, source) in sources { + source.version_id = manifest + .files + .iter() + .find_map(|record| record.project_for_provider(&source.provider, &source.project_id)) + .map(|installed| installed.version_id.clone()) + .unwrap_or_default(); + } +} + +fn format_downloads(downloads: u64) -> String { + match downloads { + 1_000_000.. => format!("{:.1}M", downloads as f64 / 1_000_000.0), + 1_000.. => format!("{:.1}K", downloads as f64 / 1_000.0), + _ => downloads.to_string(), + } +} + +#[cfg(test)] +#[path = "../../tests/widgets/content/discovery.rs"] +mod tests; diff --git a/src/tui/widgets/content/list.rs b/src/tui/widgets/content/list.rs index f81e31c..26d246a 100644 --- a/src/tui/widgets/content/list.rs +++ b/src/tui/widgets/content/list.rs @@ -6,7 +6,7 @@ use std::collections::{HashMap, HashSet}; use std::path::Path; -use std::sync::{Arc, Mutex, mpsc}; +use std::sync::{Arc, LazyLock, Mutex, mpsc}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::{ @@ -20,9 +20,103 @@ use ratatui_image::{CropOptions, Resize, StatefulImage, protocol::StatefulProtoc use tui_widget_list::{ListBuilder, ListState as TuiListState, ListView}; use crate::config::theme::THEME; -use crate::instance::content::mods::{ContentEntry, IconCell}; +use crate::instance::content::entry::{ContentEntry, WorldDetails, WorldGameMode}; +use crate::instance::content::icons::IconCell; +use crate::time::format_relative_time; type ScanOneFn = fn(&Path, &str, bool) -> ContentEntry; +static PROVIDER_ICON_SLOTS: LazyLock> = + LazyLock::new(|| Arc::new(tokio::sync::Semaphore::new(4))); + +#[derive(Clone, Copy, Default)] +enum ContentStreamOrder { + #[default] + Sorted, + Source, +} + +#[derive(Clone)] +pub struct ContentStream { + sender: mpsc::Sender, +} + +impl ContentStream { + pub fn send(&self, entry: ContentEntry) -> bool { + if self.sender.send(ContentStreamUpdate::Entry(entry)).is_ok() { + crate::feedback::request_redraw(); + true + } else { + false + } + } + + pub fn send_icon(&self, file_stem: String, path: std::path::PathBuf, bytes: Vec) -> bool { + if self + .sender + .send(ContentStreamUpdate::Icon { + file_stem, + path, + bytes, + }) + .is_ok() + { + crate::feedback::request_redraw(); + true + } else { + false + } + } + + pub fn send_icon_unavailable(&self, file_stem: String, path: std::path::PathBuf) -> bool { + if self + .sender + .send(ContentStreamUpdate::IconUnavailable { file_stem, path }) + .is_ok() + { + crate::feedback::request_redraw(); + true + } else { + false + } + } + + pub fn upsert(&self, entry: ContentEntry) -> bool { + if self.sender.send(ContentStreamUpdate::Upsert(entry)).is_ok() { + crate::feedback::request_redraw(); + true + } else { + false + } + } + + pub fn retain(&self, file_stems: HashSet) -> bool { + if self + .sender + .send(ContentStreamUpdate::Retain(file_stems)) + .is_ok() + { + crate::feedback::request_redraw(); + true + } else { + false + } + } +} + +enum ContentStreamUpdate { + Entry(ContentEntry), + Upsert(ContentEntry), + Retain(HashSet), + Icon { + file_stem: String, + path: std::path::PathBuf, + bytes: Vec, + }, + IconUnavailable { + file_stem: String, + path: std::path::PathBuf, + }, +} struct CachedList { entries: Vec, @@ -36,11 +130,26 @@ struct PendingContentImage { image: Option, } +struct PendingProviderIcon { + provider: String, + project_id: String, + bytes: Vec, + description: String, +} + struct DisplayMetadata { description: String, has_description: bool, } +struct PaginationState { + page_size: usize, + page_count: usize, + hits: Vec<(Rect, usize)>, +} + +const REMOVAL_GRACE: std::time::Duration = std::time::Duration::from_millis(500); + // result from the notify-triggered background diff struct WatcherDiff { toggled: Vec<(String, bool, std::path::PathBuf)>, @@ -48,6 +157,18 @@ struct WatcherDiff { added: Vec, } +pub(crate) struct ContentToggle { + pub old_path: std::path::PathBuf, + pub new_path: std::path::PathBuf, + pub enabled: bool, +} + +#[derive(Default)] +pub(crate) struct ContentWatcherUpdate { + pub toggles: Vec, + pub requires_reconcile: bool, +} + pub struct ContentListState { pub entries: Vec, pub list_state: TuiListState, @@ -56,13 +177,20 @@ pub struct ContentListState { pub loading: bool, image_protocols: HashMap, requested_images: HashSet, + pending_entry_images: HashSet, pending_images: Arc>>, + pending_provider_icons: Arc>>, + requested_provider_icons: HashSet<(String, String)>, + provider_icon_meta_dir: Option, + provider_icon_client: Option, images_dirty: bool, display_metadata: HashMap, pub search: crate::tui::widgets::search::SearchState, + filter_search: bool, cache: HashMap, // streaming: individual entries arrive here during initial load - stream_rx: Option>, + stream_rx: Option>, + stream_order: ContentStreamOrder, // file watcher: notify callback spawns background work, // precomputed diff lands here for the UI to pick up watcher_diff: Arc>>, @@ -71,6 +199,8 @@ pub struct ContentListState { // stored for the watcher to scan individual new files scan_one_fn: Option, content_ext: Option<&'static str>, + pagination: Option, + pending_removals: HashMap, } #[derive(Clone, Debug)] @@ -89,22 +219,318 @@ impl Default for ContentListState { loading: false, image_protocols: HashMap::new(), requested_images: HashSet::new(), + pending_entry_images: HashSet::new(), pending_images: Arc::new(Mutex::new(Vec::new())), + pending_provider_icons: Arc::new(Mutex::new(Vec::new())), + requested_provider_icons: HashSet::new(), + provider_icon_meta_dir: None, + provider_icon_client: None, images_dirty: true, display_metadata: HashMap::new(), search: crate::tui::widgets::search::SearchState::default(), + filter_search: true, cache: HashMap::new(), stream_rx: None, + stream_order: ContentStreamOrder::default(), watcher_diff: Arc::new(Mutex::new(None)), _watcher: None, watched_dir: None, scan_one_fn: None, content_ext: None, + pagination: None, + pending_removals: HashMap::new(), } } } impl ContentListState { + pub(crate) fn set_entries(&mut self, entries: Vec) { + self.entries = entries; + self.list_state = TuiListState::default(); + self.list_state.selected = (!self.entries.is_empty()).then_some(0); + self.image_protocols.clear(); + self.requested_images.clear(); + self.pending_entry_images.clear(); + self.pending_removals.clear(); + self.images_dirty = true; + self.rebuild_display_metadata(); + self.update_scrollbar(); + } + + pub fn apply_manifest( + &mut self, + manifest: &crate::instance::ContentManifest, + minecraft_dir: &Path, + kind: crate::instance::ContentKind, + ) { + let mut changed = false; + let mut invalidated_icons = Vec::new(); + for entry in &mut self.entries { + let Ok(relative_path) = entry.path.strip_prefix(minecraft_dir) else { + continue; + }; + let Some(record) = manifest + .record(relative_path) + .filter(|record| record.kind == kind) + else { + if entry.provider_project.take().is_some() { + if entry.title_suffix.as_deref() == Some("Update") { + entry.title_suffix = None; + } + if entry.provider_icon { + entry.icon_bytes = None; + entry.icon_lines = Some(crate::instance::content::fallback_icon()); + entry.provider_icon = false; + invalidated_icons.push(entry.file_stem.clone()); + } + if entry.provider_description { + entry.description.clear(); + entry.provider_description = false; + } + changed = true; + } + continue; + }; + let project = match &record.resolution { + crate::instance::Resolution::Resolved { project } => Some(project.clone()), + _ => None, + }; + if entry.provider_project != project { + if let Some(previous) = &entry.provider_project { + self.requested_provider_icons + .remove(&(previous.provider.clone(), previous.project_id.clone())); + } + if entry.title_suffix.as_deref() == Some("Update") { + entry.title_suffix = None; + } + if entry.provider_icon { + entry.icon_bytes = None; + entry.icon_lines = Some(crate::instance::content::fallback_icon()); + entry.provider_icon = false; + invalidated_icons.push(entry.file_stem.clone()); + } + if entry.provider_description { + entry.description.clear(); + entry.provider_description = false; + } + entry.provider_project = project.clone(); + changed = true; + } + } + if changed { + for stem in invalidated_icons { + self.image_protocols.remove(&stem); + self.requested_images.remove(&stem); + self.pending_entry_images.remove(&stem); + } + self.rebuild_display_metadata(); + crate::feedback::request_redraw(); + } + } + + pub fn enable_provider_icons( + &mut self, + meta_dir: std::path::PathBuf, + client: crate::net::HttpClient, + ) { + self.provider_icon_meta_dir = Some(meta_dir); + self.provider_icon_client = Some(client); + } + + pub fn apply_update_snapshot( + &mut self, + snapshot: Option<&crate::instance::content::updates::UpdateSnapshot>, + ) -> bool { + let mut changed = false; + for entry in &mut self.entries { + let update = entry.provider_project.as_ref().is_some_and(|installed| { + snapshot + .and_then(|snapshot| snapshot.update_for(installed)) + .is_some() + }); + let suffix = update.then(|| "Update".to_owned()); + if entry.title_suffix != suffix { + entry.title_suffix = suffix; + changed = true; + } + } + if changed { + crate::feedback::request_redraw(); + } + changed + } + + pub fn drain_provider_icons(&mut self) -> bool { + let pending = match self.pending_provider_icons.lock() { + Ok(mut pending) => pending.drain(..).collect::>(), + Err(_) => return false, + }; + let mut changed = false; + for metadata in pending { + for entry in &mut self.entries { + let matches_project = entry.provider_project.as_ref().is_some_and(|project| { + project.provider == metadata.provider + && project.project_id == metadata.project_id + }); + if !matches_project { + continue; + } + if entry.icon_bytes.is_none() && !metadata.bytes.is_empty() { + entry.icon_bytes = Some(metadata.bytes.clone()); + entry.provider_icon = true; + changed = true; + } + if entry.description.trim().is_empty() && !metadata.description.trim().is_empty() { + entry.description = metadata.description.clone(); + entry.provider_description = true; + self.display_metadata + .insert(entry.file_stem.clone(), display_metadata(entry)); + changed = true; + } + } + } + if changed { + self.images_dirty = true; + crate::feedback::request_redraw(); + } + changed + } + + fn request_visible_provider_icons(&mut self, filtered: &[usize], viewport_height: u16) { + let Some(meta_dir) = self.provider_icon_meta_dir.clone() else { + return; + }; + let Some(client) = self.provider_icon_client.clone() else { + return; + }; + let projects = self.visible_provider_projects(filtered, viewport_height); + for project in projects { + let key = (project.provider.clone(), project.project_id.clone()); + if !self.requested_provider_icons.insert(key) { + continue; + } + let pending = self.pending_provider_icons.clone(); + let slots = PROVIDER_ICON_SLOTS.clone(); + let meta_dir = meta_dir.clone(); + let client = client.clone(); + tokio::spawn(async move { + let Ok(_permit) = slots.acquire_owned().await else { + return; + }; + match load_provider_metadata(&client, &meta_dir, &project).await { + Ok((bytes, description)) => { + if let Ok(mut pending) = pending.lock() { + pending.push(PendingProviderIcon { + provider: project.provider, + project_id: project.project_id, + bytes, + description, + }); + crate::feedback::request_redraw(); + } + } + Err(error) => tracing::debug!( + "Could not load provider metadata for {} project {}: {}", + project.provider, + project.project_id, + error + ), + } + }); + } + } + + fn visible_provider_projects( + &self, + filtered: &[usize], + viewport_height: u16, + ) -> Vec { + let mut remaining = viewport_height; + let first = self.list_state.scroll_offset_index(); + let truncation = self.list_state.scroll_truncation(); + let mut projects = Vec::new(); + + for (visible_index, &entry_index) in filtered.iter().enumerate().skip(first) { + let Some(entry) = self.entries.get(entry_index) else { + continue; + }; + let height = self.entry_height(entry); + let visible_height = if visible_index == first { + height.saturating_sub(truncation) + } else { + height + }; + if visible_height == 0 { + continue; + } + if (entry.icon_bytes.is_none() || entry.description.trim().is_empty()) + && let Some(project) = entry.provider_project.clone() + { + projects.push(project); + } + remaining = remaining.saturating_sub(visible_height); + if remaining == 0 { + break; + } + } + + projects.sort_by(|left, right| { + (&left.provider, &left.project_id).cmp(&(&right.provider, &right.project_id)) + }); + projects.dedup_by(|left, right| { + left.provider == right.provider && left.project_id == right.project_id + }); + projects + } + + fn entry_height(&self, entry: &ContentEntry) -> u16 { + let icon_rows = entry.icon_lines.as_ref().map_or(0, Vec::len); + let metadata = self.display_metadata.get(&entry.file_stem); + let has_second_line = metadata.is_some_and(|metadata| metadata.has_description) + || entry.footer_label.is_some() + || entry.world_details.is_some(); + icon_rows.max(if has_second_line { 2 } else { 1 }) as u16 + } + + pub fn start_stream(&mut self, source: impl Into) -> ContentStream { + self.start_stream_with_order(source, ContentStreamOrder::Sorted) + } + + pub fn start_source_stream(&mut self, source: impl Into) -> ContentStream { + self.start_stream_with_order(source, ContentStreamOrder::Source) + } + + pub fn refresh_source_stream(&mut self, source: impl Into) -> ContentStream { + let (sender, receiver) = mpsc::channel(); + self.stream_rx = Some(receiver); + self.stream_order = ContentStreamOrder::Source; + self.loaded_for = Some(source.into()); + ContentStream { sender } + } + + fn start_stream_with_order( + &mut self, + source: impl Into, + order: ContentStreamOrder, + ) -> ContentStream { + self.images_dirty = true; + self.image_protocols.clear(); + self.requested_images.clear(); + self.pending_entry_images.clear(); + self.pending_removals.clear(); + self.requested_provider_icons.clear(); + self.entries.clear(); + self.display_metadata.clear(); + self.list_state = TuiListState::default(); + self.loading = true; + self.loaded_for = Some(source.into()); + self.update_scrollbar(); + let (sender, receiver) = mpsc::channel(); + self.stream_rx = Some(receiver); + self.stream_order = order; + ContentStream { sender } + } + pub fn request_image_loads(&mut self, picker: &ratatui_image::picker::Picker) { if !self.images_dirty { return; @@ -130,6 +556,7 @@ impl ContentListState { .retain(|stem| valid_stems.contains(stem.as_str())); let font_size = picker.font_size(); + let font_dimensions = (font_size.width, font_size.height); for entry in &self.entries { if entry.icon_bytes.is_none() || !self.requested_images.insert(entry.file_stem.clone()) { @@ -139,45 +566,51 @@ impl ContentListState { let path = entry.path.clone(); let bytes = entry.icon_bytes.clone().unwrap_or_default(); let rows = entry.icon_lines.as_ref().map_or(3, Vec::len) as u32; - let columns = square_icon_columns(rows as u16, font_size); + let columns = square_icon_columns(rows as u16, font_dimensions); let pending = self.pending_images.clone(); tokio::spawn(async move { let result = tokio::task::spawn_blocking(move || { - let image = image::load_from_memory(&bytes).ok()?; + let Some(image) = image::load_from_memory(&bytes).ok() else { + return PendingContentImage { + file_stem, + path, + icon_lines: crate::instance::content::fallback_icon(), + image: None, + }; + }; let icon_lines = if use_quadrants { - crate::instance::content::mods::make_icon_quadrants_from_image( + crate::instance::content::make_icon_quadrants_from_image( &image, columns, rows as u16, ) } else { - crate::instance::content::mods::make_icon_pixels_from_image( + crate::instance::content::make_icon_pixels_from_image( &image, columns, rows as u16, ) }; - let side = rows * u32::from(font_size.1.max(1)); + let side = rows * u32::from(font_size.height.max(1)); let image = use_image_protocol.then(|| { image.resize_exact(side, side, image::imageops::FilterType::Lanczos3) }); - Some(PendingContentImage { + PendingContentImage { file_stem, path, icon_lines, image, - }) + } }) .await - .ok() - .flatten(); + .ok(); if let Some(result) = result && let Ok(mut pending) = pending.lock() { pending.push(result); - crate::tui::request_redraw(); + crate::feedback::request_redraw(); } }); } @@ -195,6 +628,7 @@ impl ContentListState { .iter_mut() .find(|entry| entry.file_stem == result.file_stem && entry.path == result.path) { + self.pending_entry_images.remove(&result.file_stem); entry.icon_lines = Some(result.icon_lines); if let Some(image) = result.image { self.image_protocols @@ -206,27 +640,127 @@ impl ContentListState { // drain streaming entries from the initial load. each entry arrives // individually and is inserted in sorted position for a smooth fill-in - pub fn drain_pending(&mut self) { + pub fn drain_pending(&mut self) -> bool { let Some(rx) = &self.stream_rx else { - return; + return false; }; let mut received = false; let mut received_count = 0usize; let mut finished = false; + let mut restore_selected = None; loop { match rx.try_recv() { - Ok(entry) => { + Ok(ContentStreamUpdate::Entry(entry)) => { received = true; self.images_dirty = true; received_count += 1; + if entry.icon_bytes.is_some() || entry.provider_icon { + self.pending_entry_images.insert(entry.file_stem.clone()); + } self.display_metadata .insert(entry.file_stem.clone(), display_metadata(&entry)); - let pos = self + match self.stream_order { + ContentStreamOrder::Sorted => { + let pos = self + .entries + .binary_search_by(|e| { + e.name.to_lowercase().cmp(&entry.name.to_lowercase()) + }) + .unwrap_or_else(|i| i); + self.entries.insert(pos, entry); + } + ContentStreamOrder::Source => self.entries.push(entry), + } + } + Ok(ContentStreamUpdate::Upsert(mut entry)) => { + received = true; + self.images_dirty = true; + received_count += 1; + let stem = entry.file_stem.clone(); + if let Some(existing) = self .entries - .binary_search_by(|e| e.name.to_lowercase().cmp(&entry.name.to_lowercase())) - .unwrap_or_else(|i| i); - self.entries.insert(pos, entry); + .iter_mut() + .find(|existing| existing.file_stem == stem) + { + let same_source = existing.path == entry.path + && existing.provider_project == entry.provider_project; + if entry.icon_bytes.is_none() && same_source { + entry.icon_bytes = existing.icon_bytes.take(); + entry.icon_lines = existing.icon_lines.take(); + entry.provider_icon = existing.provider_icon; + } else if entry.icon_bytes != existing.icon_bytes { + self.image_protocols.remove(&entry.file_stem); + self.requested_images.remove(&entry.file_stem); + } + if entry.description.trim().is_empty() + && same_source + && existing.provider_description + { + entry.description = std::mem::take(&mut existing.description); + entry.provider_description = true; + } + *existing = entry; + } else { + self.entries.push(entry); + } + if let Some(entry) = self.entries.iter().find(|entry| entry.file_stem == stem) { + self.display_metadata + .insert(entry.file_stem.clone(), display_metadata(entry)); + if (entry.icon_bytes.is_some() || entry.provider_icon) + && !self.image_protocols.contains_key(&entry.file_stem) + { + self.pending_entry_images.insert(stem); + } else { + self.pending_entry_images.remove(&stem); + } + } + } + Ok(ContentStreamUpdate::Retain(file_stems)) => { + received = true; + let selected_stem = self.selected_file_stem(); + self.entries + .retain(|entry| file_stems.contains(&entry.file_stem)); + self.display_metadata + .retain(|stem, _| file_stems.contains(stem)); + self.image_protocols + .retain(|stem, _| file_stems.contains(stem)); + self.requested_images + .retain(|stem| file_stems.contains(stem)); + self.pending_entry_images + .retain(|stem| file_stems.contains(stem)); + restore_selected = Some(selected_stem); + self.images_dirty = true; + } + Ok(ContentStreamUpdate::Icon { + file_stem, + path, + bytes, + }) => { + received = true; + if let Some(entry) = self + .entries + .iter_mut() + .find(|entry| entry.file_stem == file_stem && entry.path == path) + { + entry.icon_bytes = Some(bytes); + entry.provider_icon = true; + self.pending_entry_images.insert(file_stem.clone()); + self.requested_images.remove(&file_stem); + self.images_dirty = true; + } + } + Ok(ContentStreamUpdate::IconUnavailable { file_stem, path }) => { + received = true; + if let Some(entry) = self + .entries + .iter_mut() + .find(|entry| entry.file_stem == file_stem && entry.path == path) + { + entry.provider_icon = false; + self.pending_entry_images.remove(&file_stem); + self.images_dirty = true; + } } Err(mpsc::TryRecvError::Empty) => break, Err(mpsc::TryRecvError::Disconnected) => { @@ -237,6 +771,10 @@ impl ContentListState { } } + if let Some(selected_stem) = restore_selected { + self.restore_selected_file_stem(selected_stem.as_deref()); + } + if received || finished { self.loading = false; if received_count > 0 { @@ -258,24 +796,34 @@ impl ContentListState { } self.update_scrollbar(); } + received || finished } // pick up the precomputed diff from the notify watcher callback. // skip while streaming is in progress to avoid duplicate entries. - pub fn drain_watcher(&mut self) { + pub(crate) fn drain_watcher(&mut self) -> ContentWatcherUpdate { if self.stream_rx.is_some() { - return; + return ContentWatcherUpdate::default(); } + let expired_removals = self.expire_pending_removals(); + let diff = match self.watcher_diff.lock() { Ok(mut slot) => slot.take(), _ => None, }; let Some(diff) = diff else { - return; + return ContentWatcherUpdate { + requires_reconcile: expired_removals, + ..ContentWatcherUpdate::default() + }; }; - self.images_dirty = true; + let mut update = ContentWatcherUpdate { + requires_reconcile: expired_removals || !diff.added.is_empty(), + ..ContentWatcherUpdate::default() + }; + self.images_dirty |= update.requires_reconcile; // apply toggles (enabled/path changes) tracing::debug!( @@ -286,23 +834,68 @@ impl ContentListState { diff.added.len() ); for (stem, enabled, path) in &diff.toggled { + self.pending_removals.remove(stem); if let Some(entry) = self.entries.iter_mut().find(|e| &e.file_stem == stem) { + let old_path = if entry.path == *path { + opposite_toggle_path(path, *enabled) + } else { + entry.path.clone() + }; + update.toggles.push(ContentToggle { + old_path, + new_path: path.clone(), + enabled: *enabled, + }); entry.enabled = *enabled; entry.path = path.clone(); + } else { + update.requires_reconcile = true; } } - // apply removals - if !diff.removed.is_empty() { - self.entries - .retain(|e| !diff.removed.contains(&e.file_stem)); - for stem in &diff.removed { - self.display_metadata.remove(stem); - } + // A version change is reported by the filesystem as a removal followed + // by an addition. Keep the old row briefly so the replacement can take + // its place without flashing out of the list. + for stem in diff.removed { + self.pending_removals + .entry(stem) + .or_insert_with(std::time::Instant::now); } // insert new entries in sorted position - for entry in diff.added { + for mut entry in diff.added { + let replacement = self.entries.iter().position(|existing| { + self.pending_removals.contains_key(&existing.file_stem) + && existing.name.eq_ignore_ascii_case(&entry.name) + }); + if let Some(index) = replacement { + let old_stem = self.entries[index].file_stem.clone(); + self.pending_removals.remove(&old_stem); + preserve_visual_metadata(&mut entry, &mut self.entries[index]); + self.display_metadata.remove(&old_stem); + let protocol = self.image_protocols.remove(&old_stem); + let image_requested = self.requested_images.remove(&old_stem); + let image_pending = self.pending_entry_images.remove(&old_stem); + self.entries[index] = entry; + let entry = &self.entries[index]; + if let Some(protocol) = protocol { + self.image_protocols + .insert(entry.file_stem.clone(), protocol); + } + if image_requested { + self.requested_images.insert(entry.file_stem.clone()); + } + if image_pending { + self.pending_entry_images.insert(entry.file_stem.clone()); + } + self.display_metadata + .insert(entry.file_stem.clone(), display_metadata(entry)); + self.images_dirty = true; + continue; + } + if entry.icon_bytes.is_some() { + self.pending_entry_images.insert(entry.file_stem.clone()); + } self.display_metadata .insert(entry.file_stem.clone(), display_metadata(&entry)); let pos = self @@ -322,6 +915,40 @@ impl ContentListState { } self.update_scrollbar(); + update + } + + fn expire_pending_removals(&mut self) -> bool { + let expired = self + .pending_removals + .iter() + .filter(|(_, since)| since.elapsed() >= REMOVAL_GRACE) + .map(|(stem, _)| stem.clone()) + .collect::>(); + let mut removed = false; + for stem in expired { + self.pending_removals.remove(&stem); + let restored = self + .entries + .iter() + .find(|entry| entry.file_stem == stem) + .is_some_and(|entry| entry.path.exists()); + if restored { + continue; + } + let before = self.entries.len(); + self.entries.retain(|entry| entry.file_stem != stem); + removed |= self.entries.len() != before; + self.display_metadata.remove(&stem); + self.image_protocols.remove(&stem); + self.requested_images.remove(&stem); + self.pending_entry_images.remove(&stem); + } + if removed { + self.images_dirty = true; + self.update_scrollbar(); + } + removed } // starts a notify file watcher on the given directory. changes trigger @@ -337,10 +964,12 @@ impl ContentListState { let ext: &'static str = self.content_ext.unwrap_or(".jar"); let scan_one = self.scan_one_fn; - let dirty = Arc::new(AtomicBool::new(false)); let running = Arc::new(AtomicBool::new(false)); - let dirty_cb = dirty.clone(); let running_cb = running.clone(); + let pending_paths = Arc::new(Mutex::new(HashSet::::new())); + let pending_paths_cb = pending_paths.clone(); + let needs_rescan = Arc::new(AtomicBool::new(false)); + let needs_rescan_cb = needs_rescan.clone(); // initialize known stems from the current directory state so existing // files are not treated as "new" on the first notify event @@ -348,28 +977,39 @@ impl ContentListState { let watch_dir = dir.clone(); let watcher = notify::recommended_watcher(move |res: Result| { - if let Err(e) = &res { - tracing::warn!( - "Content watcher event error for {}: {}", - watch_dir.display(), - e - ); - return; + let event_paths = match res { + Ok(event) => match watcher_event_handling(&event.kind) { + WatcherEventHandling::Ignore => return, + WatcherEventHandling::Paths => event.paths, + WatcherEventHandling::Rescan => { + needs_rescan_cb.store(true, Ordering::Relaxed); + event.paths + } + }, + Err(e) => { + tracing::warn!( + "Content watcher event error for {}: {}", + watch_dir.display(), + e + ); + needs_rescan_cb.store(true, Ordering::Relaxed); + Vec::new() + } + }; + if let Ok(mut pending) = pending_paths_cb.lock() { + pending.extend(event_paths); } - // mark dirty. if a thread is already running it will loop to - // pick up the change after its current diff - dirty_cb.store(true, Ordering::Relaxed); - if running_cb.swap(true, Ordering::Relaxed) { return; } let dir = watch_dir.clone(); let diff_slot = watcher_diff.clone(); - let dirty = dirty_cb.clone(); let running = running_cb.clone(); let known = known_stems.clone(); + let pending_paths = pending_paths_cb.clone(); + let needs_rescan = needs_rescan_cb.clone(); std::thread::spawn(move || { // always clear `running` even if we panic @@ -379,56 +1019,50 @@ impl ContentListState { self.0.store(false, Ordering::Relaxed); } } - let _guard = ResetOnDrop(running); + let _guard = ResetOnDrop(running.clone()); loop { - dirty.store(false, Ordering::Relaxed); std::thread::sleep(std::time::Duration::from_millis(100)); - - let result = (|| { - let on_disk = read_dir_stems(&dir, ext); - let mut known_map = known.lock().ok()?; - - let mut toggled = Vec::new(); - let mut removed = Vec::new(); - let mut added = Vec::new(); - - for (stem, (old_path, old_enabled)) in known_map.iter() { - if let Some((disk_path, disk_enabled)) = on_disk.get(stem) { - if *disk_enabled != *old_enabled || *disk_path != *old_path { - toggled.push((stem.clone(), *disk_enabled, disk_path.clone())); - } - } else { - removed.push(stem.clone()); - } - } - - for (stem, (path, enabled)) in &on_disk { - if !known_map.contains_key(stem) - && let Some(scan_one) = scan_one - { - added.push(scan_one(path, stem, *enabled)); - } - } - - *known_map = on_disk; - - Some(WatcherDiff { - toggled, - removed, - added, - }) - })(); + let paths = pending_paths + .lock() + .ok() + .map(|mut pending| pending.drain().collect::>()) + .unwrap_or_default(); + let full_rescan = needs_rescan.swap(false, Ordering::Relaxed); + let result = if full_rescan { + diff_directory(&dir, ext, scan_one, &known) + } else { + diff_event_paths(&dir, &paths, ext, scan_one, &known) + }; if let Some(diff) = result && let Ok(mut slot) = diff_slot.lock() { - *slot = Some(diff); - crate::tui::request_redraw(); + if let Some(pending) = slot.as_mut() { + merge_watcher_diff(pending, diff); + } else { + *slot = Some(diff); + } + crate::feedback::request_redraw(); } - if !dirty.load(Ordering::Relaxed) { - break; + let no_pending = pending_paths.lock().is_ok_and(|pending| pending.is_empty()); + if no_pending && !needs_rescan.load(Ordering::Relaxed) { + // Release ownership before the final check. An event + // racing with this boundary either starts a new worker + // or is observed here and kept by this worker. + running.store(false, Ordering::Release); + let still_empty = + pending_paths.lock().is_ok_and(|pending| pending.is_empty()); + if still_empty && !needs_rescan.load(Ordering::Acquire) { + break; + } + if running + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + break; + } } } }); @@ -455,11 +1089,61 @@ impl ContentListState { self.entries .iter() .enumerate() - .filter(|(_, e)| self.search.matches(&e.name)) + .filter(|(_, entry)| { + !self.pending_entry_images.contains(&entry.file_stem) + && (!self.filter_search + || self.search.matches(&entry.name) + || self.search.matches(&entry.description)) + }) .map(|(i, _)| i) .collect() } + pub fn set_search_filtering(&mut self, enabled: bool) { + let selected_stem = self.selected_file_stem(); + self.filter_search = enabled; + self.restore_selected_file_stem(selected_stem.as_deref()); + } + + fn selected_file_stem(&self) -> Option { + let filtered = self.filtered_indices(); + let entry_index = self + .list_state + .selected + .and_then(|index| filtered.get(index))?; + self.entries + .get(*entry_index) + .map(|entry| entry.file_stem.clone()) + } + + pub fn selected_entry(&self) -> Option<&ContentEntry> { + let filtered = self.filtered_indices(); + let index = self + .list_state + .selected + .and_then(|index| filtered.get(index))?; + self.entries.get(*index) + } + + pub(crate) fn selected_has_provider_project(&self) -> bool { + self.selected_entry() + .is_some_and(|entry| entry.provider_project.is_some()) + } + + fn restore_selected_file_stem(&mut self, file_stem: Option<&str>) { + let filtered = self.filtered_indices(); + self.list_state.selected = file_stem + .and_then(|stem| { + filtered.iter().position(|index| { + self.entries + .get(*index) + .is_some_and(|entry| entry.file_stem == stem) + }) + }) + .or_else(|| (!filtered.is_empty()).then_some(0)); + self.update_scrollbar(); + } + pub fn pending_delete(&self) -> Option { let filtered = self.filtered_indices(); let real_idx = self.list_state.selected.and_then(|i| filtered.get(i))?; @@ -472,6 +1156,19 @@ impl ContentListState { } impl ContentListState { + pub fn forget_instance(&mut self, instance_name: &str) { + let world_prefix = format!("{instance_name}:"); + self.cache + .retain(|source, _| source != instance_name && !source.starts_with(&world_prefix)); + if self + .loaded_for + .as_deref() + .is_some_and(|source| source == instance_name || source.starts_with(&world_prefix)) + { + self.loaded_for = None; + } + } + // saves current entries to cache before loading new ones, and restores // from cache if this instance was seen before (avoids re-scanning). // content_dir is the actual directory to scan (e.g. .minecraft/mods). @@ -487,6 +1184,8 @@ impl ContentListState { self.images_dirty = true; self.image_protocols.clear(); self.requested_images.clear(); + self.pending_entry_images.clear(); + self.pending_removals.clear(); // save current entries to cache if let Some(prev) = self.loaded_for.take() @@ -509,6 +1208,12 @@ impl ContentListState { // try cache first if let Some(cached) = self.cache.remove(instance_name) { self.entries = cached.entries; + self.pending_entry_images.extend( + self.entries + .iter() + .filter(|entry| entry.icon_bytes.is_some()) + .map(|entry| entry.file_stem.clone()), + ); self.rebuild_display_metadata(); self.list_state.selected = cached.selected; self.loading = false; @@ -524,15 +1229,7 @@ impl ContentListState { } // no cache, stream entries one by one as each file is scanned - self.entries.clear(); - self.display_metadata.clear(); - self.list_state = TuiListState::default(); - self.loading = true; - self.loaded_for = Some(instance_name.to_string()); - self.update_scrollbar(); - - let (tx, rx) = mpsc::channel(); - self.stream_rx = Some(rx); + let stream = self.start_stream(instance_name); let dir = content_dir.to_path_buf(); tracing::debug!( @@ -578,10 +1275,9 @@ impl ContentListState { }; let entry = scan_one_fn(&path, &file_stem, enabled); - if tx.send(entry).is_err() { + if !stream.send(entry) { break; // receiver dropped (instance switched) } - crate::tui::request_redraw(); } }) .await; @@ -595,6 +1291,47 @@ impl ContentListState { self.scrollbar_state = ScrollbarState::new(max).position(pos); } + pub fn previous_page(&mut self) -> bool { + let Some(pagination) = self.pagination.as_ref() else { + return false; + }; + let current = self.list_state.selected.unwrap_or(0) / pagination.page_size; + self.jump_to_page(current.saturating_sub(1)) + } + + pub fn next_page(&mut self) -> bool { + let Some(pagination) = self.pagination.as_ref() else { + return false; + }; + let current = self.list_state.selected.unwrap_or(0) / pagination.page_size; + self.jump_to_page((current + 1).min(pagination.page_count.saturating_sub(1))) + } + + pub fn click_page(&mut self, x: u16, y: u16) -> bool { + let page = self.pagination.as_ref().and_then(|pagination| { + pagination + .hits + .iter() + .find(|(area, _)| { + x >= area.x && x < area.right() && y >= area.y && y < area.bottom() + }) + .map(|(_, page)| *page) + }); + page.is_some_and(|page| self.jump_to_page(page)) + } + + fn jump_to_page(&mut self, page: usize) -> bool { + let Some(pagination) = self.pagination.as_ref() else { + return false; + }; + let target = page + .min(pagination.page_count.saturating_sub(1)) + .saturating_mul(pagination.page_size); + self.list_state.select(Some(target)); + self.update_scrollbar(); + true + } + fn rebuild_display_metadata(&mut self) { self.display_metadata = self .entries @@ -612,37 +1349,18 @@ impl ContentListState { let Some(entry) = self.entries.get(index) else { return; }; - - let new_path = if entry.enabled { - let fname = match entry.path.file_name().and_then(|n| n.to_str()) { - Some(n) => n, - None => return, - }; - let mut p = entry.path.clone(); - p.set_file_name(format!("{fname}.disabled")); - p - } else { - let fname = match entry.path.file_name().and_then(|n| n.to_str()) { - Some(n) => n, - None => return, - }; - let mut p = entry.path.clone(); - p.set_file_name(fname.trim_end_matches(".disabled")); - p - }; - - match std::fs::rename(&entry.path, &new_path) { - Ok(()) => { + match crate::instance::content::entry::toggle_entry_path(entry) { + Ok(Some(new_path)) => { let entry = &mut self.entries[index]; entry.enabled = !entry.enabled; entry.path = new_path; } + Ok(None) => {} Err(e) => { tracing::error!( - "Failed to toggle '{}' from {} to {}: {}", + "Failed to toggle '{}' at {}: {}", entry.file_stem, entry.path.display(), - new_path.display(), e ); } @@ -688,7 +1406,7 @@ fn handle_search_keys(key_event: &KeyEvent, state: &mut ContentListState) -> boo state.update_scrollbar(); } KeyCode::Backspace => { - state.search.pop(); + state.search.backspace(key_event.modifiers); state.list_state.selected = Some(0); state.update_scrollbar(); } @@ -710,43 +1428,84 @@ fn handle_search_keys(key_event: &KeyEvent, state: &mut ContentListState) -> boo false } -pub fn handle_key_no_toggle(key_event: &KeyEvent, state: &mut ContentListState) -> bool { - if handle_search_keys(key_event, state) { - return true; - } - let filtered = state.filtered_indices(); - let count = filtered.len(); - - match key_event.code { - KeyCode::Char('j') | KeyCode::Down => { - if count == 0 { - return true; - } - let current = state.list_state.selected.unwrap_or(0); - state.list_state.selected = Some((current + 1).min(count - 1)); - state.update_scrollbar(); - true - } - KeyCode::Char('k') | KeyCode::Up => { - let current = state.list_state.selected.unwrap_or(0); - state.list_state.selected = Some(current.saturating_sub(1)); - state.update_scrollbar(); - true +async fn load_provider_metadata( + client: &crate::net::HttpClient, + meta_dir: &Path, + installed: &crate::instance::ProviderProject, +) -> Result<(Vec, String), crate::net::NetError> { + let provider_id = &installed.provider; + let project_id = &installed.project_id; + let metadata = crate::storage::MetadataPaths::new(meta_dir); + let icon_path = metadata + .provider_icons(provider_id) + .join(format!("{project_id}.img")); + let cached_icon = tokio::fs::read(&icon_path) + .await + .ok() + .filter(|bytes| !bytes.is_empty() && image::load_from_memory(bytes).is_ok()); + + let registry = crate::instance::content::provider::ProviderRegistry::configured(client.clone()); + let provider = registry.get(provider_id).ok_or_else(|| { + crate::net::NetError::Parse(format!("Content provider '{provider_id}' is unavailable")) + })?; + let project_path = metadata + .provider_projects(provider_id) + .join(format!("{project_id}.json")); + let cached_project = tokio::fs::read(&project_path) + .await + .ok() + .and_then(|bytes| serde_json::from_slice(&bytes).ok()); + let project = match cached_project { + Some(project) => project, + None => { + let project = match provider.project(project_id).await { + Ok(project) => project, + Err(error) => { + return cached_icon.map(|bytes| (bytes, String::new())).ok_or(error); + } + }; + crate::storage::write_atomic( + &project_path, + &serde_json::to_vec_pretty(&project) + .map_err(|error| crate::net::NetError::Parse(error.to_string()))?, + )?; + project } - KeyCode::Enter if key_event.modifiers.contains(KeyModifiers::SHIFT) => { - if let Some(&real_idx) = state.list_state.selected.and_then(|i| filtered.get(i)) - && let Some(dir) = state.entries[real_idx].path.parent() - && let Err(e) = open::that_detached(dir) - { - tracing::error!("Failed to open directory: {}", e); + }; + let bytes = match (cached_icon, project.icon_url.as_deref()) { + (Some(bytes), _) => bytes, + (None, Some(url)) => match provider.icon(url).await { + Ok(bytes) if !bytes.is_empty() && image::load_from_memory(&bytes).is_ok() => { + crate::storage::write_atomic(&icon_path, &bytes)?; + bytes } - true - } - _ => false, - } + Ok(_) => { + tracing::debug!("Provider returned an invalid icon for project '{project_id}'"); + Vec::new() + } + Err(error) => { + tracing::debug!("Could not fetch icon for project '{project_id}': {error}"); + Vec::new() + } + }, + (None, None) => Vec::new(), + }; + Ok((bytes, project.description)) +} + +pub fn handle_key_no_toggle(key_event: &KeyEvent, state: &mut ContentListState) -> bool { + handle_key_inner(key_event, state, false) } pub fn handle_key(key_event: &KeyEvent, state: &mut ContentListState) -> bool { + handle_key_inner(key_event, state, true) +} + +fn handle_key_inner( + key_event: &KeyEvent, + state: &mut ContentListState, + toggle_on_enter: bool, +) -> bool { if handle_search_keys(key_event, state) { return true; } @@ -778,7 +1537,7 @@ pub fn handle_key(key_event: &KeyEvent, state: &mut ContentListState) -> bool { } true } - KeyCode::Enter => { + KeyCode::Enter if toggle_on_enter => { if let Some(&real_idx) = state.list_state.selected.and_then(|i| filtered.get(i)) { state.list_state.selected = Some(real_idx); state.toggle_selected(); @@ -791,6 +1550,7 @@ pub fn handle_key(key_event: &KeyEvent, state: &mut ContentListState) -> bool { } } +#[allow(clippy::too_many_arguments)] pub fn render( frame: &mut Frame, area: Rect, @@ -799,8 +1559,11 @@ pub fn render( loading_text: &str, empty_text: &str, picker: &ratatui_image::picker::Picker, + paginate: bool, + multiline_descriptions: bool, ) { let theme = THEME.as_ref(); + state.pagination = None; if state.loading { frame.render_widget( Paragraph::new(loading_text).style(Style::default().fg(theme.text_dim())), @@ -813,13 +1576,21 @@ pub fn render( if filtered.is_empty() { state.list_state.selected = None; + let text = if state.pending_entry_images.is_empty() { + empty_text + } else { + loading_text + }; frame.render_widget( - Paragraph::new(empty_text).style(Style::default().fg(theme.text_dim())), + Paragraph::new(text).style(Style::default().fg(theme.text_dim())), area, ); return; } + if state.list_state.selected.is_none() { + state.list_state.selected = Some(0); + } let count = filtered.len(); // clamp selected so the ListView builder never gets an out-of-bounds index @@ -828,12 +1599,20 @@ pub fn render( { state.list_state.selected = Some(count.saturating_sub(1)); } + let (list_area, pagination) = if paginate { + pagination_layout(area, count) + } else { + (area, None) + }; + state.request_visible_provider_icons(&filtered, list_area.height); let use_image_protocol = picker.protocol_type() != ratatui_image::picker::ProtocolType::Halfblocks; let entries = &state.entries; let display_metadata = &state.display_metadata; let filtered_rows = &filtered; + let search = &state.search; + let ready_image_stems: HashSet = state.image_protocols.keys().cloned().collect(); let builder = ListBuilder::new(move |context| { let theme = THEME.as_ref(); @@ -842,7 +1621,17 @@ pub fn render( let metadata = display_metadata.get(&entry.file_stem); let enabled = entry.enabled; let icon_pixels = &entry.icon_lines; - let has_image = entry.icon_bytes.is_some(); + let world_details = entry.world_details.as_ref(); + let title_suffix = world_details + .and_then(|details| details.game_mode) + .map(WorldGameMode::label) + .or(entry.title_suffix.as_deref()); + let world_footer = world_details.map(|details| format_relative_time(details.last_played)); + let footer_label = world_footer.as_deref().or(entry.footer_label.as_deref()); + // Keep rendering the terminal fallback until the asynchronous image + // decoder has produced a protocol. Invalid and unsupported images + // therefore remain visible as a question mark instead of blank space. + let has_image = ready_image_stems.contains(&entry.file_stem); let protocol_columns = protocol_icon_columns(entry, picker) as usize; let show_selected = is_focused && context.is_selected; let use_mc_colors = enabled; @@ -883,11 +1672,85 @@ pub fn render( stripe_bg, ), }; + let title_suffix_color = world_details + .and_then(|details| details.game_mode) + .map(world_game_mode_color) + .unwrap_or_else(|| { + if matches!(title_suffix, Some("Update" | "Skipped")) { + theme.warning() + } else { + theme.success() + } + }); + let title_suffix_style = Style::default() + .fg(theme.background()) + .bg(title_suffix_color) + .add_modifier(Modifier::BOLD); + let footer_label_style = Style::default().fg(if world_details.is_some() { + theme.text_dim() + } else { + theme.text() + }); + let footer_spans = entry.footer_change.as_ref().map_or_else( + || { + footer_label.map_or_else(Vec::new, |label| { + vec![Span::styled(label.to_owned(), footer_label_style)] + }) + }, + |(from, to)| version_change_spans(from, to), + ); + let footer_width = footer_spans.iter().map(Span::width).sum(); + let has_footer = !footer_spans.is_empty(); + let world_descriptions = world_details.map(world_descriptions); let has_icon = icon_pixels.is_some(); - let stripped_desc = metadata.map_or("", |metadata| metadata.description.as_str()); - let has_description = metadata.is_some_and(|metadata| metadata.has_description); - let compact = !has_icon && !has_description; + let mut descriptions = if let Some(lines) = world_descriptions.as_ref() { + lines.iter().map(String::as_str).collect() + } else { + metadata + .map(|metadata| { + metadata + .description + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>() + }) + .unwrap_or_default() + }; + if !multiline_descriptions { + descriptions.truncate(1); + } + let has_description = !descriptions.is_empty(); + let rendered_icon_columns = if use_image_protocol && has_image { + protocol_columns + } else { + icon_pixels + .as_ref() + .and_then(|rows| rows.first()) + .map_or(0, Vec::len) + }; + let description_width = available_description_width( + usize::from(context.cross_axis_size), + rendered_icon_columns, + has_icon, + ); + let visible_descriptions = descriptions + .iter() + .enumerate() + .map(|(index, description)| { + ellipsize( + description, + if index == 0 { + description_text_width(description_width, footer_width, has_description) + } else { + description_width + }, + ) + }) + .collect::>(); + let visible_description = visible_descriptions.first().map_or("", String::as_str); + let compact = !has_icon && !has_description && !has_footer; let selector = if show_selected { Span::styled("\u{258c}", Style::default().fg(theme.accent())) @@ -898,17 +1761,18 @@ pub fn render( if compact { let mut line = Vec::new(); line.push(selector.clone()); - if use_mc_colors { - line.extend(parse_mc_text(name, name_style)); - } else { - line.push(Span::styled(strip_mc_codes(name), name_style)); - } + line.extend(searchable_spans(search, name, name_style, use_mc_colors)); + line.extend(title_suffix_spans( + title_suffix, + description_style, + title_suffix_style, + )); let item = Text::from(vec![Line::from(line)]).style(Style::default().bg(background)); (item, 1) } else if has_icon { let icon_row_count = icon_pixels.as_ref().map(|r| r.len()).unwrap_or(0); - let text_rows = if has_description { 2 } else { 1 }; // name + optional description + let text_rows = 1 + visible_descriptions.len().max(usize::from(has_footer)); let height = icon_row_count.max(text_rows) as u16; let pad = if show_selected { @@ -925,29 +1789,39 @@ pub fn render( protocol_columns, )); line_0.push(Span::raw(" ")); - if use_mc_colors { - line_0.extend(parse_mc_text(name, name_style)); - } else { - line_0.push(Span::styled(strip_mc_codes(name), name_style)); - } + line_0.extend(searchable_spans(search, name, name_style, use_mc_colors)); + line_0.extend(title_suffix_spans( + title_suffix, + description_style, + title_suffix_style, + )); let mut lines = vec![Line::from(line_0)]; - if has_description { + for r in 1..text_rows { let mut row = vec![pad.clone()]; row.extend(icon_spans( icon_pixels.as_ref(), - 1, + r, use_image_protocol && has_image, protocol_columns, )); row.push(Span::raw(" ")); - row.push(Span::styled(stripped_desc.to_string(), description_style)); + if let Some(description) = visible_descriptions.get(r - 1) { + row.extend(search.highlight_spans(description, description_style)); + } + if r == 1 && has_footer { + row.extend(right_aligned_footer_spans( + description_width, + visible_description, + has_description, + footer_spans.clone(), + )); + } lines.push(Line::from(row)); } - let desc_rows = if has_description { 1 } else { 0 }; - for r in (1 + desc_rows)..icon_row_count { + for r in text_rows..icon_row_count { let mut row = vec![pad.clone()]; row.extend(icon_spans( icon_pixels.as_ref(), @@ -963,24 +1837,35 @@ pub fn render( } else { let mut line_0 = Vec::new(); line_0.push(selector.clone()); - if use_mc_colors { - line_0.extend(parse_mc_text(name, name_style)); - } else { - line_0.push(Span::styled(strip_mc_codes(name), name_style)); - } + line_0.extend(searchable_spans(search, name, name_style, use_mc_colors)); + line_0.extend(title_suffix_spans( + title_suffix, + description_style, + title_suffix_style, + )); let mut lines = vec![Line::from(line_0)]; - if has_description { + if has_description || has_footer { let pad = if show_selected { Span::styled("\u{258c}", Style::default().fg(theme.accent())) } else { Span::raw(" ") }; - lines.push(Line::from(vec![ - pad, - Span::styled(stripped_desc.to_string(), description_style), - ])); + let mut description = vec![pad]; + if has_description { + description + .extend(search.highlight_spans(visible_description, description_style)); + } + if has_footer { + description.extend(right_aligned_footer_spans( + description_width, + visible_description, + has_description, + footer_spans.clone(), + )); + } + lines.push(Line::from(description)); } let height = lines.len() as u16; @@ -990,17 +1875,17 @@ pub fn render( }); let list = ListView::new(builder, count); - frame.render_stateful_widget(list, area, &mut state.list_state); + frame.render_stateful_widget(list, list_area, &mut state.list_state); if picker.protocol_type() != ratatui_image::picker::ProtocolType::Halfblocks { - render_image_icons(frame, area, state, &filtered, picker); + render_image_icons(frame, list_area, state, &filtered, picker); } let scrollbar_area = Rect { - x: area.x + area.width.saturating_sub(0), - y: area.y + 1, + x: list_area.x + list_area.width.saturating_sub(0), + y: list_area.y + 1, width: 1, - height: area.height.saturating_sub(2), + height: list_area.height.saturating_sub(2), }; frame.render_stateful_widget( Scrollbar::default() @@ -1017,6 +1902,111 @@ pub fn render( scrollbar_area, &mut state.scrollbar_state, ); + + if let Some((pager_area, page_size)) = pagination { + render_pager(frame, pager_area, state, count, page_size); + } +} + +fn pagination_layout(area: Rect, item_count: usize) -> (Rect, Option<(Rect, usize)>) { + const ITEM_HEIGHT: u16 = 3; + if area.height < ITEM_HEIGHT + 1 || item_count <= usize::from(area.height / ITEM_HEIGHT).max(1) + { + return (area, None); + } + let pager_area = Rect::new(area.x, area.bottom().saturating_sub(1), area.width, 1); + let page_size = usize::from(area.height / ITEM_HEIGHT).max(1); + (area, Some((pager_area, page_size))) +} + +fn pager_pages(current: usize, page_count: usize) -> Vec> { + if page_count == 0 { + return Vec::new(); + } + let core_len = page_count.min(3); + let current = current.min(page_count - 1); + if current < 3 { + return (0..page_count.min(4)).map(Some).collect(); + } + let core_start = current.saturating_sub(1).min(page_count - core_len); + let mut pages = Vec::with_capacity(5); + if core_start > 0 { + pages.push(Some(0)); + if core_start > 1 { + pages.push(None); + } + } + pages.extend((core_start..core_start + core_len).map(Some)); + pages +} + +fn render_pager( + frame: &mut Frame, + area: Rect, + state: &mut ContentListState, + item_count: usize, + page_size: usize, +) { + let theme = THEME.as_ref(); + let page_count = item_count.div_ceil(page_size); + let current = (state.list_state.selected.unwrap_or(0) / page_size).min(page_count - 1); + let mut tokens = Vec::new(); + for page in pager_pages(current, page_count) { + match page { + Some(page) if page == current => tokens.push(( + format!("[{}]", page + 1), + Some(page), + Style::default() + .fg(theme.accent()) + .add_modifier(Modifier::BOLD), + )), + Some(page) => tokens.push(( + format!(" {} ", page + 1), + Some(page), + Style::default().fg(theme.text()), + )), + None => tokens.push(( + " \u{2026} ".to_owned(), + None, + Style::default().fg(theme.text_dim()), + )), + } + } + + let total_width = tokens + .iter() + .map(|(label, _, _)| Span::raw(label).width()) + .sum::(); + let pager_width = total_width.min(usize::from(area.width)) as u16; + let start_x = area.x + area.width.saturating_sub(pager_width) / 2; + let pager_area = Rect::new(start_x, area.y, pager_width, area.height); + let mut x = start_x; + let mut hits = Vec::new(); + let spans = tokens + .into_iter() + .map(|(label, page, style)| { + let width = Span::raw(&label).width().min(usize::from(u16::MAX)) as u16; + if let Some(page) = page + && x < area.right() + { + hits.push(( + Rect::new(x, area.y, width.min(area.right() - x), area.height), + page, + )); + } + x = x.saturating_add(width); + Span::styled(label, style) + }) + .collect::>(); + frame.render_widget( + Paragraph::new(Line::from(spans)).style(Style::default().bg(theme.border())), + pager_area, + ); + state.pagination = Some(PaginationState { + page_size, + page_count, + hits, + }); } fn render_image_icons( @@ -1113,6 +2103,21 @@ fn mc_color(code: char) -> Option { } } +fn searchable_spans( + search: &crate::tui::widgets::search::SearchState, + text: &str, + base_style: Style, + use_mc_colors: bool, +) -> Vec> { + if !search.is_empty() { + search.highlight_spans(&strip_mc_codes(text), base_style) + } else if use_mc_colors { + parse_mc_text(text, base_style) + } else { + vec![Span::styled(strip_mc_codes(text), base_style)] + } +} + // parses minecraft's section-sign (U+00A7) formatting codes into styled spans. // handles colors (0-f), bold (l), strikethrough (m), underline (n), italic (o), reset (r) fn parse_mc_text(text: &str, base_style: Style) -> Vec> { @@ -1180,13 +2185,28 @@ fn strip_mc_codes(text: &str) -> String { fn display_metadata(entry: &ContentEntry) -> DisplayMetadata { let description = strip_mc_codes(&entry.description); - let description = description.lines().next().unwrap_or("").trim().to_string(); + let description = description.trim().to_string(); DisplayMetadata { has_description: !description.is_empty(), description, } } +fn preserve_visual_metadata(entry: &mut ContentEntry, previous: &mut ContentEntry) { + if entry.icon_bytes.is_none() { + entry.icon_bytes = previous.icon_bytes.take(); + entry.icon_lines = previous.icon_lines.take(); + entry.provider_icon = previous.provider_icon; + } + if entry.description.trim().is_empty() && previous.provider_description { + entry.description = std::mem::take(&mut previous.description); + entry.provider_description = true; + } + if entry.provider_project.is_none() { + entry.provider_project = previous.provider_project.clone(); + } +} + // renders one row of a mod icon using half-block characters (U+2584). // each cell packs two vertical pixels via fg/bg colors, giving // double the vertical resolution out of the terminal @@ -1221,12 +2241,142 @@ fn icon_spans( } } -fn protocol_icon_columns( - entry: &crate::instance::content::mods::ContentEntry, - picker: &ratatui_image::picker::Picker, -) -> u16 { +fn world_game_mode_color(mode: WorldGameMode) -> Color { + let theme = THEME.as_ref(); + match mode { + WorldGameMode::Survival => theme.success(), + WorldGameMode::Creative => theme.info(), + WorldGameMode::Adventure => theme.warning(), + WorldGameMode::Spectator => theme.text_dim(), + WorldGameMode::Hardcore => theme.error(), + } +} + +fn world_descriptions(details: &WorldDetails) -> Vec { + let summary = match (&details.minecraft_version, &details.size) { + (Some(version), Some(size)) => format!("{version} • {size}"), + (Some(version), None) => version.clone(), + (None, Some(size)) => size.clone(), + (None, None) => String::new(), + }; + let mut lines = Vec::with_capacity(5); + if !summary.is_empty() { + lines.push(summary); + } + lines.extend( + details + .datapacks + .iter() + .take(3) + .map(|name| format!(" • {name}")), + ); + if details.datapacks.len() > 3 { + lines.push(format!(" +{} more", details.datapacks.len() - 3)); + } + lines +} + +fn title_suffix_spans( + suffix: Option<&str>, + spacing_style: Style, + label_style: Style, +) -> Vec> { + suffix.map_or_else(Vec::new, |suffix| { + vec![ + Span::styled(" ", spacing_style), + Span::styled(format!(" {suffix} "), label_style), + ] + }) +} + +fn available_description_width( + row_width: usize, + rendered_icon_columns: usize, + has_icon: bool, +) -> usize { + let selector_and_scrollbar = 2; + let icon_and_gap = if has_icon { + rendered_icon_columns + 1 + } else { + 0 + }; + row_width.saturating_sub(selector_and_scrollbar + icon_and_gap) +} + +fn description_text_width( + available_width: usize, + footer_width: usize, + has_description: bool, +) -> usize { + available_width.saturating_sub(footer_width + usize::from(has_description && footer_width > 0)) +} + +fn right_aligned_footer_spans( + available_width: usize, + description: &str, + has_description: bool, + footer: Vec>, +) -> Vec> { + let description_width = if has_description { + Span::raw(description).width() + } else { + 0 + }; + let footer_width = footer.iter().map(Span::width).sum::(); + let padding = available_width.saturating_sub(description_width + footer_width); + let mut spans = vec![Span::raw(" ".repeat(padding))]; + spans.extend(footer); + spans +} + +fn version_change_spans(from: &str, to: &str) -> Vec> { + let theme = THEME.as_ref(); + let old = Style::default() + .fg(theme.background()) + .bg(theme.text_dim()) + .add_modifier(Modifier::BOLD); + let new = Style::default() + .fg(theme.background()) + .bg(theme.accent()) + .add_modifier(Modifier::BOLD); + vec![ + Span::styled(format!(" {from} "), old), + Span::styled( + " ➜ ", + Style::default() + .fg(theme.text()) + .add_modifier(Modifier::BOLD), + ), + Span::styled(format!(" {to} "), new), + ] +} + +fn ellipsize(text: &str, max_width: usize) -> String { + if Span::raw(text).width() <= max_width { + return text.to_owned(); + } + if max_width <= 3 { + return ".".repeat(max_width); + } + + let content_width = max_width - 3; + let mut visible = String::new(); + for character in text.chars() { + visible.push(character); + if Span::raw(visible.as_str()).width() > content_width { + visible.pop(); + break; + } + } + visible = visible.trim_end().to_owned(); + visible.push_str("..."); + visible +} + +fn protocol_icon_columns(entry: &ContentEntry, picker: &ratatui_image::picker::Picker) -> u16 { let rows = entry.icon_lines.as_ref().map_or(3, Vec::len) as u16; - square_icon_columns(rows, picker.font_size()) + let font_size = picker.font_size(); + square_icon_columns(rows, (font_size.width, font_size.height)) } fn square_icon_columns(rows: u16, font_size: (u16, u16)) -> u16 { @@ -1235,10 +2385,177 @@ fn square_icon_columns(rows: u16, font_size: (u16, u16)) -> u16 { ((u32::from(rows) * height + width / 2) / width).max(1) as u16 } +#[derive(Debug, PartialEq, Eq)] +enum WatcherEventHandling { + Ignore, + Paths, + Rescan, +} + +fn watcher_event_handling(kind: ¬ify::EventKind) -> WatcherEventHandling { + match kind { + notify::EventKind::Access(_) => WatcherEventHandling::Ignore, + notify::EventKind::Create(_) | notify::EventKind::Remove(_) => WatcherEventHandling::Paths, + notify::EventKind::Modify(notify::event::ModifyKind::Name(_)) => { + WatcherEventHandling::Rescan + } + notify::EventKind::Modify(_) => WatcherEventHandling::Paths, + notify::EventKind::Any | notify::EventKind::Other => WatcherEventHandling::Rescan, + } +} + // reads a content directory and builds a stem -> (path, enabled) map. // used both by watch_dir to initialize known state and by the watcher // thread to detect changes. when ext is empty (worlds), only directories // are included. +fn diff_directory( + dir: &std::path::Path, + ext: &str, + scan_one: Option, + known: &Arc>>, +) -> Option { + let on_disk = read_dir_stems(dir, ext); + let mut known_map = known.lock().ok()?; + let mut toggled = Vec::new(); + let mut removed = Vec::new(); + let mut added = Vec::new(); + for (stem, (old_path, old_enabled)) in known_map.iter() { + if let Some((disk_path, disk_enabled)) = on_disk.get(stem) { + if disk_enabled != old_enabled || disk_path != old_path { + toggled.push((stem.clone(), *disk_enabled, disk_path.clone())); + } + } else { + removed.push(stem.clone()); + } + } + for (stem, (path, enabled)) in &on_disk { + if !known_map.contains_key(stem) + && let Some(scan_one) = scan_one + { + added.push(scan_one(path, stem, *enabled)); + } + } + *known_map = on_disk; + watcher_diff(toggled, removed, added) +} + +fn diff_event_paths( + dir: &std::path::Path, + paths: &[std::path::PathBuf], + ext: &str, + scan_one: Option, + known: &Arc>>, +) -> Option { + let mut known_map = known.lock().ok()?; + let mut toggled = Vec::new(); + let mut removed = Vec::new(); + let mut added = Vec::new(); + for path in paths { + if path.parent() != Some(dir) { + continue; + } + if path.exists() { + let Some((stem, enabled)) = watched_stem(path, ext) else { + continue; + }; + match known_map.get(&stem) { + Some((known_path, known_enabled)) + if known_path == path && *known_enabled == enabled => + { + // A modify event still needs a rescan so changed archive + // metadata and icons become visible. + if let Some(scan_one) = scan_one { + removed.push(stem.clone()); + added.push(scan_one(path, &stem, enabled)); + } + } + Some(_) => { + toggled.push((stem.clone(), enabled, path.clone())); + } + None => { + if let Some(scan_one) = scan_one { + added.push(scan_one(path, &stem, enabled)); + } + } + } + known_map.insert(stem, (path.clone(), enabled)); + } else { + let removed_stems = known_map + .iter() + .filter(|(_, (known_path, _))| known_path == path) + .map(|(stem, _)| stem.clone()) + .collect::>(); + for stem in removed_stems { + known_map.remove(&stem); + removed.push(stem); + } + } + } + watcher_diff(toggled, removed, added) +} + +fn watcher_diff( + toggled: Vec<(String, bool, std::path::PathBuf)>, + removed: Vec, + added: Vec, +) -> Option { + if toggled.is_empty() && removed.is_empty() && added.is_empty() { + None + } else { + Some(WatcherDiff { + toggled, + removed, + added, + }) + } +} + +fn merge_watcher_diff(pending: &mut WatcherDiff, mut next: WatcherDiff) { + pending.toggled.append(&mut next.toggled); + pending.removed.append(&mut next.removed); + pending.added.append(&mut next.added); + + pending.toggled.reverse(); + pending.toggled.sort_by(|left, right| left.0.cmp(&right.0)); + pending.toggled.dedup_by(|left, right| left.0 == right.0); + pending.removed.sort(); + pending.removed.dedup(); + pending.added.reverse(); + pending + .added + .sort_by(|left, right| left.file_stem.cmp(&right.file_stem)); + pending + .added + .dedup_by(|left, right| left.file_stem == right.file_stem); +} + +fn watched_stem(path: &std::path::Path, ext: &str) -> Option<(String, bool)> { + let name = path.file_name()?.to_str()?; + if path.is_dir() || ext.is_empty() { + let (enabled, stem) = crate::instance::content::parse_enabled_stem_dir(name); + return Some((stem, enabled)); + } + let disabled_ext = format!("{ext}.disabled"); + if let Some(stem) = name.strip_suffix(&disabled_ext) { + Some((stem.to_owned(), false)) + } else { + name.strip_suffix(ext).map(|stem| (stem.to_owned(), true)) + } +} + +fn opposite_toggle_path(path: &std::path::Path, enabled: bool) -> std::path::PathBuf { + let mut opposite = path.to_owned(); + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + return opposite; + }; + if enabled { + opposite.set_file_name(format!("{name}.disabled")); + } else if let Some(name) = name.strip_suffix(".disabled") { + opposite.set_file_name(name); + } + opposite +} + fn read_dir_stems(dir: &std::path::Path, ext: &str) -> HashMap { let mut map = HashMap::new(); let Ok(read_dir) = std::fs::read_dir(dir) else { @@ -1274,18 +2591,5 @@ fn read_dir_stems(dir: &std::path::Path, ext: &str) -> HashMap crate::instance::content::entry::ContentEntry; + +const VERSION_POPUP_HEIGHT: u16 = 18; + +#[derive(Clone, Copy)] +struct DownloadableTab { + directory: &'static str, + extension: &'static str, + scanner: ContentScanner, + loading_text: &'static str, + empty_text: &'static str, +} + #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub enum ContentTab { #[default] Mods, ResourcePacks, Shaders, + DataPacks, Screenshots, Worlds, Logs, @@ -37,11 +53,19 @@ impl ContentTab { ContentTab::Logs, ]; + const DISCOVERY: &'static [ContentTab] = &[ + ContentTab::Mods, + ContentTab::ResourcePacks, + ContentTab::Shaders, + ContentTab::DataPacks, + ]; + pub fn label(self) -> &'static str { match self { ContentTab::Mods => "Mods", ContentTab::ResourcePacks => "Resource Packs", ContentTab::Shaders => "Shaders", + ContentTab::DataPacks => "Datapacks", ContentTab::Screenshots => "Screenshots", ContentTab::Worlds => "Worlds", ContentTab::Logs => "Logs", @@ -64,6 +88,65 @@ impl ContentTab { idx - 1 }] } + + pub fn next_for_mode(self, mode: ContentMode) -> Self { + cycle_tab(self, visible_tabs(mode), true) + } + + pub fn previous_for_mode(self, mode: ContentMode) -> Self { + cycle_tab(self, visible_tabs(mode), false) + } + + fn downloadable(self) -> Option { + match self { + Self::Mods => Some(DownloadableTab { + directory: "mods", + extension: ".jar", + scanner: crate::instance::scan_one_mod, + loading_text: "Loading mods...", + empty_text: "No mods installed.", + }), + Self::ResourcePacks => Some(DownloadableTab { + directory: "resourcepacks", + extension: ".zip", + scanner: crate::instance::scan_one_resource_pack, + loading_text: "Loading resource packs...", + empty_text: "No resource packs installed.", + }), + Self::Shaders => Some(DownloadableTab { + directory: "shaderpacks", + extension: ".zip", + scanner: crate::instance::scan_one_shader, + loading_text: "Loading shaders...", + empty_text: "No shaders installed.", + }), + _ => None, + } + } +} + +fn visible_tabs(mode: ContentMode) -> &'static [ContentTab] { + match mode { + ContentMode::Installed => ContentTab::ALL, + ContentMode::Discover => ContentTab::DISCOVERY, + } +} + +fn mode_label(mode: ContentMode) -> String { + format!(" {} ", mode.label()) +} + +fn cycle_tab(current: ContentTab, tabs: &[ContentTab], forward: bool) -> ContentTab { + let index = tabs.iter().position(|tab| *tab == current).unwrap_or(0); + if forward { + tabs[(index + 1) % tabs.len()] + } else { + tabs[if index == 0 { + tabs.len() - 1 + } else { + index - 1 + }] + } } #[allow(clippy::too_many_arguments)] @@ -72,15 +155,24 @@ pub fn render( area: Rect, focused: FocusedArea, tab: ContentTab, + mode: ContentMode, instance: Option<&crate::instance::InstanceConfig>, + has_modpack_update: bool, mods_state: &mut super::list::ContentListState, + mods_discovery_state: &mut DiscoveryState, resource_packs_state: &mut super::list::ContentListState, + resource_packs_discovery_state: &mut DiscoveryState, shaders_state: &mut super::list::ContentListState, + shaders_discovery_state: &mut DiscoveryState, + datapacks_discovery_state: &mut DiscoveryState, worlds_state: &mut super::list::ContentListState, + world_datapacks_state: &mut super::list::ContentListState, + open_world_datapacks: Option<&(String, std::path::PathBuf)>, screenshots_state: &mut crate::tui::widgets::screenshots_grid::ScreenshotsState, logs_state: &mut crate::tui::widgets::logs_viewer::LogsState, instances_dir: &std::path::Path, picker: &ratatui_image::picker::Picker, + world_quick_play_supported: bool, ) { let theme = THEME.as_ref(); let is_focused = focused == FocusedArea::Content; @@ -91,7 +183,8 @@ pub fn render( theme.border() }; - let tab_titles: Vec = ContentTab::ALL + let tabs = visible_tabs(mode); + let tab_titles: Vec = tabs .iter() .enumerate() .flat_map(|(i, t)| { @@ -102,7 +195,7 @@ pub fn render( Style::default().fg(theme.text_dim()), )); } - if i == tab.index() { + if tabs.get(i) == Some(&tab) { let style = Style::default() .fg(theme.accent()) .add_modifier(Modifier::BOLD); @@ -118,9 +211,22 @@ pub fn render( .collect(); let search_line = match tab { + ContentTab::Mods if mode == ContentMode::Discover => { + mods_discovery_state.search.title_line() + } ContentTab::Mods => mods_state.search.title_line(), + ContentTab::ResourcePacks if mode == ContentMode::Discover => { + resource_packs_discovery_state.search.title_line() + } ContentTab::ResourcePacks => resource_packs_state.search.title_line(), + ContentTab::Shaders if mode == ContentMode::Discover => { + shaders_discovery_state.search.title_line() + } ContentTab::Shaders => shaders_state.search.title_line(), + ContentTab::DataPacks => datapacks_discovery_state.search.title_line(), + ContentTab::Worlds if open_world_datapacks.is_some() => { + world_datapacks_state.search.title_line() + } ContentTab::Worlds => worlds_state.search.title_line(), ContentTab::Screenshots => screenshots_state.search.title_line(), ContentTab::Logs => { @@ -132,8 +238,24 @@ pub fn render( } }; + let mode_background = match mode { + ContentMode::Installed => theme.success(), + ContentMode::Discover => theme.info(), + }; + let mut content_titles = vec![ + Span::styled( + mode_label(mode), + Style::default() + .fg(theme.background()) + .bg(mode_background) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + ]; + content_titles.extend(tab_titles); + let mut block = Block::default() - .title_top(Line::from(tab_titles)) + .title_top(Line::from(content_titles)) .borders(Borders::ALL) .border_type(BORDER_STYLE.to_border_type()) .border_style(Style::default().fg(border_color)); @@ -142,34 +264,138 @@ pub fn render( block = block.title_top(sl); } + let discovery_can_delete = match tab { + ContentTab::Mods => mods_discovery_state.selected_is_installed(), + ContentTab::ResourcePacks => resource_packs_discovery_state.selected_is_installed(), + ContentTab::Shaders => shaders_discovery_state.selected_is_installed(), + ContentTab::DataPacks => false, + _ => false, + }; + let discovery_page_open = match tab { + ContentTab::Mods => mods_discovery_state.project_page_open(), + ContentTab::ResourcePacks => resource_packs_discovery_state.project_page_open(), + ContentTab::Shaders => shaders_discovery_state.project_page_open(), + ContentTab::DataPacks => datapacks_discovery_state.project_page_open(), + _ => false, + }; + let discovery_unavailable = mode == ContentMode::Discover + && instance.is_some_and(|instance| match tab { + ContentTab::Mods => mods_discovery_state.unavailable_message(instance).is_some(), + ContentTab::ResourcePacks => resource_packs_discovery_state + .unavailable_message(instance) + .is_some(), + ContentTab::Shaders => shaders_discovery_state + .unavailable_message(instance) + .is_some(), + ContentTab::DataPacks => datapacks_discovery_state + .unavailable_message(instance) + .is_some(), + _ => false, + }); + let has_updates = match tab { + ContentTab::Mods => mods_state.entries.iter(), + ContentTab::ResourcePacks => resource_packs_state.entries.iter(), + ContentTab::Shaders => shaders_state.entries.iter(), + ContentTab::Worlds if open_world_datapacks.is_some() => { + world_datapacks_state.entries.iter() + } + _ => worlds_state.entries.iter(), + } + .any(|entry| entry.title_suffix.as_deref() == Some("Update")); + let can_change_version = match tab { + ContentTab::Mods => mods_state.selected_has_provider_project(), + ContentTab::ResourcePacks => resource_packs_state.selected_has_provider_project(), + ContentTab::Shaders => shaders_state.selected_has_provider_project(), + ContentTab::Worlds if open_world_datapacks.is_some() => { + world_datapacks_state.selected_has_provider_project() + } + _ => false, + }; + // keybinds change depending on which tab is active and whether // the content panel or instances panel has focus let kb: Option<&[(&str, &str)]> = if is_focused { - Some(match tab { - ContentTab::Mods | ContentTab::ResourcePacks | ContentTab::Shaders => &[ + Some(match (mode, tab) { + (ContentMode::Discover, _) if discovery_unavailable => { + &[("h/l", " tabs"), ("Tab", " installed")] + } + (ContentMode::Discover, _) if discovery_page_open => &[ + ("j/k", " scroll"), + ("g/G", " top/bottom"), + ("v", " versions"), + ("h", " back"), + ], + (ContentMode::Discover, _) if discovery_can_delete => &[ + ("j/k", " navigate"), + (" [/] ", " pages"), + ("Enter", " view"), + ("v", " versions"), + ("d", " delete"), + ("h/l", " tabs"), + ("/", " search"), + ("Tab", " installed"), + ], + (ContentMode::Discover, _) => &[ + ("j/k", " navigate"), + (" [/] ", " pages"), + ("Enter", " view"), + ("v", " versions"), + ("h/l", " tabs"), + ("/", " search"), + ("Tab", " installed"), + ], + (ContentMode::Installed, ContentTab::Mods) + | (ContentMode::Installed, ContentTab::ResourcePacks) + | (ContentMode::Installed, ContentTab::Shaders) => &[ ("j/k", " navigate"), ("⏎", " toggle"), + ("v", " versions"), + ("u", " update all"), + ("d", " delete"), + ("Shift+⏎", " open dir"), + ("h/l", " tabs"), + ("/", " search"), + ("Tab", " discovery"), + ], + (ContentMode::Installed, ContentTab::Worlds) if open_world_datapacks.is_some() => &[ + ("j/k", " navigate"), + ("v", " versions"), + ("u", " update all"), + ("d", " delete"), + ("Shift+Enter", " open dir"), + ("h/Esc", " back"), + ("/", " search"), + ], + (ContentMode::Installed, ContentTab::Worlds) if world_quick_play_supported => &[ + ("j/k", " navigate"), + ("Enter", " datapacks"), + ("q", " quick launch"), ("d", " delete"), ("Shift+⏎", " open dir"), ("h/l", " tabs"), ("/", " search"), + ("Tab", " discovery"), ], - ContentTab::Worlds => &[ + (ContentMode::Installed, ContentTab::Worlds) => &[ ("j/k", " navigate"), + ("Enter", " datapacks"), ("d", " delete"), ("Shift+⏎", " open dir"), ("h/l", " tabs"), ("/", " search"), + ("Tab", " discovery"), ], - ContentTab::Screenshots => &[ + (ContentMode::Installed, ContentTab::DataPacks) => &[], + (ContentMode::Installed, ContentTab::Screenshots) => &[ ("Shift+HJKL", " grid"), ("⏎", " open"), ("d", " delete"), ("Shift+⏎", " open dir"), ("h/l", " tabs"), ("/", " search"), + ("Tab", " discovery"), ], - ContentTab::Logs => { + (ContentMode::Installed, ContentTab::Logs) => { if logs_state.viewer_focused { &[ ("j/k", " scroll"), @@ -177,6 +403,7 @@ pub fn render( ("d", " delete"), ("Esc", " back"), ("/", " search"), + ("Tab", " discovery"), ] } else { &[ @@ -185,6 +412,7 @@ pub fn render( ("d", " delete"), ("h/l", " tabs"), ("/", " search"), + ("Tab", " discovery"), ] } } @@ -193,10 +421,12 @@ pub fn render( Some(&[ ("l", " launch"), ("⏎", " content"), + ("v", " versions"), ("Shift+⏎", " open dir"), ("Esc", " kill"), ("a", " add"), - ("i", " import"), + ("m", " modpacks"), + ("u", " update"), ("d", " delete"), ("r", " rename"), ("/", " search"), @@ -205,115 +435,99 @@ pub fn render( None }; - if let Some(kb) = kb { - let lines = - crate::tui::widgets::popups::keybind_lines_wrapped(kb, area.width.saturating_sub(2)); - for line in lines { - block = block.title_bottom(line); - } + let mut keybinds = kb.map_or_else(Vec::new, <[_]>::to_vec); + if is_focused && mode == ContentMode::Installed && !has_updates { + keybinds.retain(|(key, _)| *key != "u"); + } + if is_focused && mode == ContentMode::Installed && !can_change_version { + keybinds.retain(|(key, _)| *key != "v"); + } + if focused == FocusedArea::Instances + && instance.is_none_or(|instance| instance.modpack_source.is_none()) + { + keybinds.retain(|(key, _)| *key != "v"); + } + if focused == FocusedArea::Instances && !has_modpack_update { + keybinds.retain(|(key, _)| *key != "u"); } + if is_focused && !keybinds.iter().any(|(key, _)| key.contains("Esc")) { + keybinds.push(("Esc", " back")); + } + // The main panel footer stays on its border; lower-priority hints are omitted when narrow. + block = block.title_bottom(crate::tui::widgets::popups::keybind_line_fitted( + &keybinds, + area.width.saturating_sub(2), + )); let content_area = block.inner(area); frame.render_widget(block, area); - // lazy-load: only scan when switching to an instance that hasn't been loaded yet - match tab { - ContentTab::Mods => { - if let Some(instance) = instance { - if mods_state.loaded_for.as_deref() != Some(instance.name.as_str()) { - let content_dir = instances_dir - .join(&instance.name) - .join(".minecraft") - .join("mods"); - mods_state.start_load( - &content_dir, - &instance.name, - crate::instance::scan_one_mod, - ".jar", - ); - mods_state.watch_dir(content_dir); - } - super::list::render( - frame, - content_area, - mods_state, - is_focused, - "Loading mods...", - "No mods installed.", - picker, - ); - } else { - frame.render_widget( - Paragraph::new("No instance selected.") - .style(Style::default().fg(theme.text_dim())), - content_area, - ); - } - } - ContentTab::ResourcePacks => { - if let Some(instance) = instance { - if resource_packs_state.loaded_for.as_deref() != Some(instance.name.as_str()) { - let content_dir = instances_dir - .join(&instance.name) - .join(".minecraft") - .join("resourcepacks"); - resource_packs_state.start_load( - &content_dir, - &instance.name, - crate::instance::scan_one_resource_pack, - ".zip", - ); - resource_packs_state.watch_dir(content_dir); - } - super::list::render( - frame, - content_area, - resource_packs_state, - is_focused, - "Loading resource packs...", - "No resource packs installed.", - picker, - ); - } else { - frame.render_widget( - Paragraph::new("No instance selected.") - .style(Style::default().fg(theme.text_dim())), - content_area, - ); - } - } - ContentTab::Shaders => { - if let Some(instance) = instance { - if shaders_state.loaded_for.as_deref() != Some(instance.name.as_str()) { - let content_dir = instances_dir - .join(&instance.name) - .join(".minecraft") - .join("shaderpacks"); - shaders_state.start_load( - &content_dir, - &instance.name, - crate::instance::scan_one_shader, - ".zip", - ); - shaders_state.watch_dir(content_dir); - } - super::list::render( - frame, - content_area, - shaders_state, - is_focused, - "Loading shaders...", - "No shaders installed.", - picker, - ); - } else { - frame.render_widget( - Paragraph::new("No instance selected.") - .style(Style::default().fg(theme.text_dim())), - content_area, + let downloadable_state = match tab { + ContentTab::Mods => Some((mods_state, mods_discovery_state)), + ContentTab::ResourcePacks => Some((resource_packs_state, resource_packs_discovery_state)), + ContentTab::Shaders => Some((shaders_state, shaders_discovery_state)), + _ => None, + }; + if let Some((state, discovery_state)) = downloadable_state { + render_downloadable( + frame, + content_area, + instance, + state, + discovery_state, + tab.downloadable().expect("downloadable tab config"), + mode, + is_focused, + instances_dir, + picker, + ); + return; + } + + if tab == ContentTab::DataPacks { + if let Some(instance) = instance { + if worlds_state.loaded_for.as_deref() != Some(instance.name.as_str()) { + let saves = instances_dir + .join(&instance.name) + .join(crate::storage::MINECRAFT_DIR_NAME) + .join("saves"); + worlds_state.start_load( + &saves, + &instance.name, + crate::instance::scan_one_world, + "", ); + worlds_state.watch_dir(saves); } + let loading_text = format!( + "Searching {}...", + crate::config::SETTINGS.content.discovery_provider_label() + ); + render_discovery( + frame, + content_area, + datapacks_discovery_state, + instance, + is_focused, + &loading_text, + picker, + ); + } else { + frame.render_widget( + Paragraph::new("No instance selected.") + .style(Style::default().fg(theme.text_dim())), + content_area, + ); } + return; + } + + // lazy-load: only scan when switching to an instance that hasn't been loaded yet + match tab { + ContentTab::Mods + | ContentTab::ResourcePacks + | ContentTab::Shaders + | ContentTab::DataPacks => unreachable!(), ContentTab::Logs => { if let Some(instance) = instance { if logs_state.loaded_for.as_deref() != Some(instance.name.as_str()) { @@ -354,10 +568,43 @@ pub fn render( } ContentTab::Worlds => { if let Some(instance) = instance { + if let Some((world_name, world_path)) = open_world_datapacks { + let cache_key = format!("{}:{world_name}", instance.name); + let content_dir = world_path.join("datapacks"); + if world_datapacks_state.loaded_for.as_deref() != Some(cache_key.as_str()) { + world_datapacks_state.start_load( + &content_dir, + &cache_key, + crate::instance::scan_one_datapack, + ".zip", + ); + world_datapacks_state.watch_dir(content_dir); + } + super::list::render( + frame, + content_area, + world_datapacks_state, + is_focused, + "Loading datapacks...", + "No datapacks installed.", + picker, + false, + false, + ); + if datapacks_discovery_state.version_popup.is_some() { + render_version_popup( + frame, + content_area, + datapacks_discovery_state, + picker, + ); + } + return; + } if worlds_state.loaded_for.as_deref() != Some(instance.name.as_str()) { let content_dir = instances_dir .join(&instance.name) - .join(".minecraft") + .join(crate::storage::MINECRAFT_DIR_NAME) .join("saves"); worlds_state.start_load( &content_dir, @@ -375,6 +622,8 @@ pub fn render( "Loading worlds...", "No worlds saved.", picker, + false, + true, ); } else { frame.render_widget( @@ -387,6 +636,483 @@ pub fn render( } } +#[allow(clippy::too_many_arguments)] +fn render_downloadable( + frame: &mut Frame, + area: Rect, + instance: Option<&crate::instance::InstanceConfig>, + state: &mut super::list::ContentListState, + discovery_state: &mut DiscoveryState, + tab: DownloadableTab, + mode: ContentMode, + is_focused: bool, + instances_dir: &std::path::Path, + picker: &ratatui_image::picker::Picker, +) { + let Some(instance) = instance else { + frame.render_widget( + Paragraph::new("No instance selected.") + .style(Style::default().fg(THEME.as_ref().text_dim())), + area, + ); + return; + }; + if state.loaded_for.as_deref() != Some(instance.name.as_str()) { + let content_dir = instances_dir + .join(&instance.name) + .join(crate::storage::MINECRAFT_DIR_NAME) + .join(tab.directory); + state.start_load(&content_dir, &instance.name, tab.scanner, tab.extension); + state.watch_dir(content_dir); + } + if mode == ContentMode::Discover { + let loading_text = format!( + "Searching {}...", + crate::config::SETTINGS.content.discovery_provider_label() + ); + render_discovery( + frame, + area, + discovery_state, + instance, + is_focused, + &loading_text, + picker, + ); + } else { + super::list::render( + frame, + area, + state, + is_focused, + tab.loading_text, + tab.empty_text, + picker, + false, + false, + ); + if discovery_state.version_popup.is_some() { + render_version_popup(frame, area, discovery_state, picker); + } + } +} + +fn render_discovery( + frame: &mut Frame, + area: Rect, + state: &mut DiscoveryState, + instance: &crate::instance::InstanceConfig, + is_focused: bool, + loading_text: &str, + picker: &ratatui_image::picker::Picker, +) { + state.set_viewport_rows(area.height); + if let Some(message) = state.unavailable_message(instance) { + frame.render_widget( + Paragraph::new(message).style(Style::default().fg(THEME.as_ref().text_dim())), + area, + ); + return; + } + render_discovery_body(frame, area, state, is_focused, loading_text, picker); +} + +pub(crate) fn render_discovery_popup( + frame: &mut Frame, + area: Rect, + state: &mut DiscoveryState, + picker: &ratatui_image::picker::Picker, +) { + state.set_viewport_rows(area.height); + render_discovery_body(frame, area, state, true, "Searching modpacks...", picker); +} + +fn render_discovery_body( + frame: &mut Frame, + area: Rect, + state: &mut DiscoveryState, + is_focused: bool, + loading_text: &str, + picker: &ratatui_image::picker::Picker, +) { + if let Some(page) = state.project_page.as_mut() { + if let Some(error) = page.error.as_deref() { + frame.render_widget( + Paragraph::new(error) + .style(Style::default().fg(THEME.as_ref().error())) + .wrap(Wrap { trim: true }), + area, + ); + } else if let Some(document) = page.document.as_mut() { + page.max_scroll = crate::tui::widgets::markdown::render( + frame, + area, + document, + &mut page.scroll, + picker, + ); + } else { + frame.render_widget( + Paragraph::new(format!("Loading {}...", page.title)) + .style(Style::default().fg(THEME.as_ref().text_dim())), + area, + ); + } + } else { + let empty_text = state.empty_text().to_string(); + let paginate = !state.search.active && state.version_popup.is_none(); + super::list::render( + frame, + area, + &mut state.list, + is_focused, + loading_text, + &empty_text, + picker, + paginate, + false, + ); + } + if state.version_popup.is_some() { + render_version_popup(frame, area, state, picker); + } +} + +pub(crate) fn render_version_popup( + frame: &mut Frame, + area: Rect, + state: &mut DiscoveryState, + picker: &ratatui_image::picker::Picker, +) { + let Some(popup) = state.version_popup.as_mut() else { + return; + }; + if popup.selecting_world { + render_world_picker(frame, area, popup, picker); + return; + } + let popup_area = area.centered( + Constraint::Percentage(50), + Constraint::Length( + version_popup_height( + popup.confirming, + popup.dependency_plan.as_ref(), + popup.target_world.is_some(), + ) + .min(area.height.saturating_sub(2)), + ), + ); + let theme = THEME.as_ref(); + let title = popup.title(); + let loading = popup.loading; + let resolving_dependencies = popup.resolving_dependencies; + let installing = popup.installing; + let confirming = popup.confirming; + let error = popup.error.clone(); + let selected = popup.selected; + let selecting_minecraft_version = popup.selecting_minecraft_version; + let selected_version = popup + .selected_version() + .map(|version| version.version_number.clone()) + .unwrap_or_default(); + let minecraft_versions = popup + .selected_version() + .map(|version| confirmation_values(&version.game_versions)) + .unwrap_or_else(|| "Unknown".to_owned()); + let loaders = popup + .selected_version() + .map(|version| confirmation_loaders(&version.loaders)) + .unwrap_or_else(|| "Unknown".to_owned()); + let release_date = popup + .selected_version() + .map(|version| confirmation_release_date(&version.date_published)) + .unwrap_or_else(|| "Unknown".to_owned()); + let replacing = popup.installed_path.is_some(); + let reinstalling = popup.current_version_id.as_deref() + == popup.selected_version().map(|version| version.id.as_str()); + let provider_label = popup.provider_label().to_owned(); + let target_world = popup.target_world.as_ref().map(|(name, _)| name.clone()); + let dependency_installs = popup + .dependency_plan + .as_ref() + .map(|plan| { + plan.dependency_installs() + .map(|item| item.title.as_str()) + .collect::>() + .join(", ") + }) + .unwrap_or_default(); + let dependency_replacements = popup + .dependency_plan + .as_ref() + .map(|plan| { + plan.dependency_replacements() + .map(|item| format!("{} -> {}", item.title, item.version.version_number)) + .collect::>() + .join(", ") + }) + .unwrap_or_default(); + let optional_dependencies = popup + .dependency_plan + .as_ref() + .map(|plan| plan.optional_dependencies) + .unwrap_or_default(); + let can_switch_provider = popup.sources.len() > 1; + let current_version_id = popup.current_version_id.clone(); + let items = if selecting_minecraft_version { + popup + .minecraft_versions + .iter() + .map(|version| ListItem::new(version.clone()).style(Style::default().fg(theme.text()))) + .collect::>() + } else { + popup + .visible_versions() + .enumerate() + .map(|(index, version)| { + let mut spans = vec![Span::styled( + discovery_version_label(version), + Style::default() + .fg(if index == selected { + theme.accent() + } else { + theme.text() + }) + .add_modifier(if index == selected { + Modifier::BOLD + } else { + Modifier::empty() + }), + )]; + if current_version_id.as_deref() == Some(version.id.as_str()) { + spans.extend([ + Span::raw(" "), + Span::styled( + " Installed ", + Style::default() + .fg(theme.background()) + .bg(theme.success()) + .add_modifier(Modifier::BOLD), + ), + ]); + } + ListItem::new(Line::from(spans)) + }) + .collect::>() + }; + let keybinds = if confirming { + crate::tui::widgets::popups::keybind_line(&[ + ("h", " back"), + ( + "Enter", + if reinstalling { + " reinstall" + } else if replacing { + " change" + } else { + " install" + }, + ), + ]) + } else { + let mut keybinds = vec![("j/k", " navigate")]; + if can_switch_provider { + keybinds.push(("Tab", " source")); + } + if popup.selected_minecraft_version.is_some() { + keybinds.push(("h", " back")); + } + keybinds.extend([("Enter", " continue"), ("Esc", " close")]); + crate::tui::widgets::popups::keybind_line(&keybinds) + }; + + let popup_frame = crate::tui::widgets::popups::base::PopupFrame { + title: styled_title(&title, false), + border_color: theme.accent(), + bg: Some(theme.surface()), + keybinds: Some(keybinds), + search_line: Some( + Line::from(Span::styled( + format!(" {provider_label} "), + Style::default() + .fg(theme.accent()) + .add_modifier(Modifier::BOLD), + )) + .alignment(Alignment::Right), + ), + content: Box::new(move |area, buffer| { + if installing { + Paragraph::new("Installing...") + .style(Style::default().fg(THEME.as_ref().text_dim())) + .render(area, buffer); + } else if loading { + Paragraph::new(if resolving_dependencies { + "Resolving required dependencies..." + } else { + "Loading compatible versions..." + }) + .style(Style::default().fg(THEME.as_ref().text_dim())) + .render(area, buffer); + } else if let Some(error) = &error { + Paragraph::new(error.as_str()) + .style(Style::default().fg(THEME.as_ref().error())) + .wrap(Wrap { trim: true }) + .render(area, buffer); + } else if confirming { + let mut rows = vec![ + ("Version", selected_version.as_str()), + ("Minecraft", minecraft_versions.as_str()), + ("Loader", loaders.as_str()), + ("Released", release_date.as_str()), + ]; + if let Some(world) = target_world.as_deref() { + rows.push(("World", world)); + } + if !dependency_installs.is_empty() { + rows.push(("Also installs", dependency_installs.as_str())); + } + if !dependency_replacements.is_empty() { + rows.push(("Also changes", dependency_replacements.as_str())); + } + let optional_text; + if optional_dependencies > 0 { + optional_text = optional_dependencies.to_string(); + rows.push(("Optional not installed", optional_text.as_str())); + } + crate::tui::widgets::popups::base::render_summary(&rows, area, buffer); + } else if items.is_empty() { + Paragraph::new(if selecting_minecraft_version { + "No compatible Minecraft versions found." + } else { + "No compatible versions found." + }) + .style(Style::default().fg(THEME.as_ref().text_dim())) + .render(area, buffer); + } else { + crate::tui::widgets::popups::new_instance::render_select_list( + items.clone(), + selected, + area, + buffer, + ); + } + }), + }; + frame.render_widget(popup_frame, popup_area); +} + +fn render_world_picker( + frame: &mut Frame, + area: Rect, + popup: &mut super::discovery::VersionPopupState, + picker: &ratatui_image::picker::Picker, +) { + let popup_area = area.centered( + Constraint::Percentage(50), + Constraint::Length(VERSION_POPUP_HEIGHT.min(area.height.saturating_sub(2))), + ); + let theme = THEME.as_ref(); + let title = popup.title(); + let provider_label = popup.provider_label().to_owned(); + let keybinds = world_picker_keybinds(!popup.worlds.entries.is_empty()); + let frame_widget = crate::tui::widgets::popups::base::PopupFrame { + title: styled_title(&title, false), + border_color: theme.accent(), + bg: Some(theme.surface()), + keybinds: Some(crate::tui::widgets::popups::keybind_line(keybinds)), + search_line: Some( + Line::from(Span::styled( + format!(" {provider_label} "), + Style::default() + .fg(theme.accent()) + .add_modifier(Modifier::BOLD), + )) + .alignment(Alignment::Right), + ), + content: Box::new(|_, _| {}), + }; + frame.render_widget(frame_widget, popup_area); + super::list::render( + frame, + popup_area.inner(Margin::new(1, 1)), + &mut popup.worlds, + true, + "Loading worlds...", + "No worlds available.", + picker, + false, + false, + ); +} + +fn world_picker_keybinds(has_worlds: bool) -> &'static [(&'static str, &'static str)] { + if has_worlds { + &[ + ("j/k", " navigate"), + ("Enter", " continue"), + ("h", " back"), + ("Esc", " close"), + ] + } else { + &[("h", " back"), ("Esc", " close")] + } +} + +fn version_popup_height( + confirming: bool, + plan: Option<&crate::instance::content::dependencies::DependencyPlan>, + has_world: bool, +) -> u16 { + if !confirming { + return VERSION_POPUP_HEIGHT; + } + let Some(plan) = plan else { + return 6 + u16::from(has_world); + }; + 6 + u16::from(has_world) + + u16::from(plan.dependency_installs().next().is_some()) + + u16::from(plan.dependency_replacements().next().is_some()) + + u16::from(plan.optional_dependencies > 0) +} + +fn confirmation_values(values: &[String]) -> String { + if values.is_empty() { + "Unknown".to_owned() + } else { + values.join(", ") + } +} + +fn confirmation_loaders(loaders: &[String]) -> String { + let loaders = loaders + .iter() + .map(|loader| match loader.as_str() { + "fabric" => "Fabric", + "forge" => "Forge", + "neoforge" => "NeoForge", + "quilt" => "Quilt", + "minecraft" => "Minecraft", + "datapack" => "Datapack", + other => other, + }) + .map(str::to_owned) + .collect::>(); + confirmation_values(&loaders) +} + +fn confirmation_release_date(value: &str) -> String { + if value.is_empty() { + return "Unknown".to_owned(); + } + chrono::DateTime::parse_from_rfc3339(value) + .map(|date| date.format("%Y-%m-%d").to_string()) + .unwrap_or_else(|_| value.to_owned()) +} + +fn discovery_version_label(version: &crate::net::modrinth::VersionInfo) -> String { + version.version_number.clone() +} + // the header bar above the content tabs, showing instance name, loader info, // and a spinner/error indicator when the instance is running or crashed pub fn title( @@ -423,8 +1149,8 @@ pub fn title( let [left_area, right_area] = Layout::horizontal([Constraint::Min(0), Constraint::Length(32)]).areas(inner); - use crate::running::RunState; - let run_state = crate::running::get(&inst.name); + use crate::instance::runtime::RunState; + let run_state = crate::instance::runtime::get(&inst.name); match run_state { Some(RunState::Authenticating) @@ -491,3 +1217,7 @@ pub fn title( } } } + +#[cfg(test)] +#[path = "../../tests/widgets/content/tabs.rs"] +mod tests; diff --git a/src/tui/widgets/content/update.rs b/src/tui/widgets/content/update.rs new file mode 100644 index 0000000..811ed19 --- /dev/null +++ b/src/tui/widgets/content/update.rs @@ -0,0 +1,299 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +use ratatui::{ + Frame, + layout::{Constraint, Margin}, +}; + +use crate::instance::ContentKind; +use crate::instance::content::entry::ContentEntry; +use crate::instance::content::updates::{BulkUpdatePlan, UpdateSnapshot}; +use crate::tui::widgets::popups::base::PopupFrame; + +pub enum PendingResult { + Prepared(UpdateSnapshot, BulkUpdatePlan), + Applied(Result<(), String>), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Phase { + Checking, + Conflicts, + Review, + Applying, +} + +pub struct State { + pub phase: Phase, + pub plan: Option, + pub snapshot: Option, + pub pending: Arc>>, + pub kind: ContentKind, + pub target_world: Option<(String, PathBuf)>, + pub completed: bool, + pub applied: bool, + pub list: super::list::ContentListState, + source_entries: HashMap, +} + +impl State { + pub fn checking( + kind: ContentKind, + target_world: Option<(String, PathBuf)>, + entries: Vec, + ) -> Self { + Self { + phase: Phase::Checking, + plan: None, + snapshot: None, + pending: Arc::new(Mutex::new(Vec::new())), + kind, + target_world, + completed: false, + applied: false, + list: super::list::ContentListState::default(), + source_entries: entries + .into_iter() + .map(|entry| (entry.path.clone(), entry)) + .collect(), + } + } + + pub fn drain(&mut self) -> bool { + let pending = match self.pending.lock() { + Ok(mut pending) => pending.drain(..).collect::>(), + Err(_) => return false, + }; + let mut changed = false; + for result in pending { + changed = true; + match result { + PendingResult::Prepared(snapshot, plan) => { + self.phase = if !plan.conflicts.is_empty() { + Phase::Conflicts + } else if !plan.roots.is_empty() { + Phase::Review + } else { + self.completed = true; + Phase::Review + }; + self.snapshot = Some(snapshot); + self.plan = Some(plan); + self.rebuild_list(); + } + PendingResult::Applied(Ok(())) => { + self.completed = true; + self.applied = true; + } + PendingResult::Applied(Err(error)) => { + crate::feedback::errors::push_error(crate::feedback::errors::ErrorEvent { + id: 0, + level: tracing::Level::ERROR, + message: format!("Failed to update content: {error}"), + pushed_at: Instant::now(), + }); + self.completed = true; + } + } + } + changed + } + + pub fn push(&self, result: PendingResult) { + if let Ok(mut pending) = self.pending.lock() { + pending.push(result); + crate::feedback::request_redraw(); + } + } + + pub fn visible(&self) -> bool { + matches!(self.phase, Phase::Conflicts | Phase::Review) && !self.completed + } + + pub fn has_updates(&self) -> bool { + self.plan + .as_ref() + .is_some_and(|plan| !plan.roots.is_empty()) + } + + pub fn show_review(&mut self) { + self.phase = Phase::Review; + self.rebuild_list(); + } + + pub fn show_conflicts(&mut self) { + if self + .plan + .as_ref() + .is_some_and(|plan| !plan.conflicts.is_empty()) + { + self.phase = Phase::Conflicts; + self.rebuild_list(); + } + } + + fn rebuild_list(&mut self) { + let entries = match self.phase { + Phase::Conflicts => self + .plan + .as_ref() + .into_iter() + .flat_map(|plan| plan.conflicts.iter()) + .map(|conflict| { + let mut entry = self.entry_for(&conflict.installed_path, &conflict.title); + entry.description = user_conflict_reason(&conflict.reason); + entry.title_suffix = Some("Skipped".to_owned()); + entry.footer_label = None; + entry.footer_change = None; + entry + }) + .collect(), + Phase::Review => self + .plan + .as_ref() + .into_iter() + .flat_map(|plan| plan.roots.iter()) + .map(|root| { + let mut entry = self.entry_for(&root.installed_path, &root.title); + entry.title_suffix = Some("Update".to_owned()); + entry.footer_label = None; + entry.footer_change = Some(( + root.current_version.clone(), + root.target.version_number.clone(), + )); + entry + }) + .collect(), + Phase::Checking | Phase::Applying => Vec::new(), + }; + self.list.set_entries(entries); + } + + fn entry_for(&self, path: &Path, title: &str) -> ContentEntry { + self.source_entries + .get(path) + .cloned() + .unwrap_or_else(|| ContentEntry { + file_stem: path + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or(title) + .to_owned(), + name: title.to_owned(), + source_slug: None, + installed_path: Some(path.to_owned()), + provider_project: None, + world_details: None, + title_suffix: None, + footer_label: None, + footer_change: None, + description: String::new(), + enabled: true, + icon_bytes: None, + provider_icon: false, + provider_description: false, + path: path.to_owned(), + icon_lines: Some(crate::instance::content::fallback_icon()), + }) + } +} + +pub fn render(frame: &mut Frame, state: &mut State, picker: &ratatui_image::picker::Picker) { + if !state.visible() { + return; + } + let count = state.list.entries.len() as u16; + let height = count.saturating_mul(3).saturating_add(2).clamp(5, 20); + let area = frame.area().centered( + Constraint::Percentage(60), + Constraint::Length(height.min(frame.area().height.saturating_sub(4))), + ); + let theme = crate::config::theme::THEME.as_ref(); + let (title, keybinds) = match state.phase { + Phase::Conflicts => ( + "Updates needing attention", + if state.has_updates() { + crate::tui::widgets::popups::keybind_line(&[ + ("j/k", " navigate"), + ("Enter", " review updates"), + ("h/Esc", " close"), + ]) + } else { + crate::tui::widgets::popups::keybind_line(&[ + ("j/k", " navigate"), + ("h/Esc", " close"), + ]) + }, + ), + Phase::Review => ( + "Update installed content", + if state + .plan + .as_ref() + .is_some_and(|plan| !plan.conflicts.is_empty()) + { + crate::tui::widgets::popups::keybind_line(&[ + ("j/k", " navigate"), + ("h", " back"), + ("Enter", " update all"), + ("Esc", " close"), + ]) + } else { + crate::tui::widgets::popups::keybind_line(&[ + ("j/k", " navigate"), + ("Enter", " update all"), + ("Esc", " close"), + ]) + }, + ), + Phase::Checking | Phase::Applying => return, + }; + let popup = PopupFrame { + title: crate::tui::widgets::styled_title(title, false), + border_color: theme.accent(), + bg: Some(theme.surface()), + keybinds: Some(keybinds), + search_line: None, + content: Box::new(|_, _| {}), + }; + frame.render_widget(popup, area); + super::list::render( + frame, + area.inner(Margin::new(1, 1)), + &mut state.list, + true, + "", + "", + picker, + false, + true, + ); +} + +fn user_conflict_reason(reason: &str) -> String { + let reason = reason.strip_prefix("Parse error: ").unwrap_or(reason); + if let Some(details) = reason.strip_prefix("Conflicting selected versions for '") + && let Some((dependency, _)) = details.split_once("': '") + { + return format!( + "Other selected updates require different versions of {dependency}.\nThis mod was left unchanged; update it separately with v." + ); + } + if let Some(details) = reason.strip_prefix("Conflicting required versions for '") + && let Some((dependency, _)) = details.split_once("': '") + { + return format!( + "This update requires incompatible versions of {dependency}.\nThis mod was left unchanged; choose another version with v." + ); + } + format!( + "This update could not be prepared: {reason}. The installed version was left unchanged; try updating it separately with v." + ) +} + +#[cfg(test)] +#[path = "../../tests/widgets/content/update.rs"] +mod tests; diff --git a/src/tui/widgets/instances.rs b/src/tui/widgets/instances.rs index d33a5f6..6c00134 100644 --- a/src/tui/widgets/instances.rs +++ b/src/tui/widgets/instances.rs @@ -11,34 +11,58 @@ use ratatui::{ text::{Line, Span, Text}, widgets::{Block, Borders, Scrollbar, ScrollbarOrientation, ScrollbarState}, }; +use std::collections::HashMap; +use std::sync::{Arc, LazyLock, Mutex}; use tui_widget_list::{ListBuilder, ListState as TuiListState, ListView}; use crate::instance::models::InstanceConfig; -use crate::running::{RunState, get as get_run_state}; +use crate::instance::runtime::{RunState, get as get_run_state}; +use crate::time::format_relative_time; use crate::tui::app::FocusedArea; use super::{WidgetKey, search::SearchState, styled_title}; -// rough human-friendly time delta. not trying to be precise here, -// "2 months ago" is close enough when months are ~30 days -fn format_last_played(last_played: Option>) -> String { - let Some(dt) = last_played else { - return "Never played".to_string(); - }; - let secs = chrono::Utc::now() - .signed_duration_since(dt) - .num_seconds() - .max(0) as u64; - match secs { - 0..=59 => "Just now".to_string(), - 60..=3599 => format!("{} minutes ago", secs / 60), - 3600..=86399 => format!("{} hours ago", secs / 3600), - 86400..=2591999 => format!("{} days ago", secs / 86400), - 2592000..=31535999 => format!("{} months ago", secs / 2592000), - _ => "Over a year ago".to_string(), +type PendingModpackUpdate = ( + String, + crate::instance::ProviderProject, + Option, +); +static PENDING_MODPACK_UPDATES: LazyLock>> = + LazyLock::new(|| Mutex::new(Vec::new())); +static MODPACK_UPDATE_SLOTS: LazyLock> = + LazyLock::new(|| Arc::new(tokio::sync::Semaphore::new(4))); + +pub fn spawn_modpack_update_checks(instances: &[InstanceConfig]) { + for instance in instances { + spawn_modpack_update_check(instance); } } +pub fn spawn_modpack_update_check(instance: &InstanceConfig) { + let Some(source) = instance.modpack_source.clone() else { + return; + }; + let instance = instance.clone(); + tokio::spawn(async move { + let Ok(_permit) = MODPACK_UPDATE_SLOTS.clone().acquire_owned().await else { + return; + }; + let Ok(versions) = crate::instance::import::provider_versions(&source).await else { + return; + }; + let update = versions.first().cloned().filter(|_| { + crate::instance::content::provider::has_newer_compatible_version( + &versions, + &source.version_id, + ) + }); + if let Ok(mut pending) = PENDING_MODPACK_UPDATES.lock() { + pending.push((instance.name, source, update)); + crate::feedback::request_redraw(); + } + }); +} + #[derive(Debug, Default)] pub struct State { pub instances: Vec, @@ -48,6 +72,7 @@ pub struct State { pub show_import_popup: bool, pub search: SearchState, pub renaming: Option, + pub(crate) modpack_updates: HashMap, } impl State { @@ -61,6 +86,7 @@ impl State { show_import_popup: false, search: SearchState::default(), renaming: None, + modpack_updates: HashMap::new(), }; if count > 0 { s.list_state.selected = Some(0); @@ -141,15 +167,45 @@ impl State { self.instances.retain(|i| i.name != name); let after = self.instances.len(); if after < before { + self.modpack_updates.remove(name); self.update_scrollbar(); } } + pub fn drain_modpack_updates(&mut self) { + let Ok(mut pending) = PENDING_MODPACK_UPDATES.lock() else { + return; + }; + for (name, source, update) in pending.drain(..) { + if self + .instances + .iter() + .find(|instance| instance.name == name) + .and_then(|instance| instance.modpack_source.as_ref()) + != Some(&source) + { + continue; + } + if let Some(update) = update { + self.modpack_updates.insert(name, update); + } else { + self.modpack_updates.remove(&name); + } + } + } + pub fn add_instance(&mut self, instance: InstanceConfig) { self.instances.push(instance); + self.list_state.selected = self.filtered_indices().len().checked_sub(1); self.update_scrollbar(); } + pub fn selected_modpack_update(&self) -> Option { + self.selected_instance() + .and_then(|instance| self.modpack_updates.get(&instance.name)) + .cloned() + } + pub fn replace_instance(&mut self, old_name: &str, instance: InstanceConfig) { if let Some(existing) = self .instances @@ -179,7 +235,7 @@ impl WidgetKey for State { self.update_scrollbar(); } KeyCode::Backspace => { - self.search.pop(); + self.search.backspace(key_event.modifiers); self.list_state.selected = Some(0); self.update_scrollbar(); } @@ -203,7 +259,7 @@ impl WidgetKey for State { self.show_popup = true; self.update_scrollbar(); } - KeyCode::Char('i') => { + KeyCode::Char('m') => { self.show_import_popup = true; } KeyCode::Char('d') => {} @@ -291,10 +347,9 @@ pub fn render(frame: &mut Frame, area: Rect, focused: FocusedArea, state: &mut S ), ]) } else { - Line::from(vec![ - selector.clone(), - Span::styled(instance.name.as_str(), name_style), - ]) + let mut spans = vec![selector.clone()]; + spans.extend(state.search.highlight_spans(&instance.name, name_style)); + Line::from(spans) }; let (meta_text, meta_text_style) = match get_run_state(&instance.name) { @@ -305,7 +360,7 @@ pub fn render(frame: &mut Frame, area: Rect, focused: FocusedArea, state: &mut S Some(RunState::Running) | Some(RunState::Starting) => { ("Playing".to_string(), Style::default().fg(theme.success())) } - _ => (format_last_played(instance.last_played), meta_style), + _ => (format_relative_time(instance.last_played), meta_style), }; let meta_line = Line::from(vec![ @@ -339,85 +394,5 @@ pub fn render(frame: &mut Frame, area: Rect, focused: FocusedArea, state: &mut S } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn format_last_played_none_returns_never_played() { - assert_eq!(format_last_played(None), "Never played"); - } - - // each #[case] picks a "seconds ago" value that lands in exactly one - // bucket of the match. mutating any bucket boundary (e.g. 3600 to 3601, - // or "minutes" to "seconds") makes one of these cases fail. - #[rstest::rstest] - #[case::just_now(0, "Just now")] - #[case::just_now_upper(59, "Just now")] - #[case::minutes(60, "1 minutes ago")] - #[case::minutes_upper(3599, "59 minutes ago")] - #[case::hours(3600, "1 hours ago")] - #[case::hours_upper(86_399, "23 hours ago")] - #[case::days(86_400, "1 days ago")] - #[case::days_upper(2_591_999, "29 days ago")] - #[case::months(2_592_000, "1 months ago")] - #[case::months_upper(31_535_999, "12 months ago")] - #[case::over_a_year(31_536_000, "Over a year ago")] - fn format_last_played_buckets(#[case] seconds_ago: i64, #[case] expected: &str) { - let dt = chrono::Utc::now() - chrono::Duration::seconds(seconds_ago); - assert_eq!(format_last_played(Some(dt)), expected); - } - - use crate::instance::models::{InstanceConfig, ModLoader}; - use ratatui::Terminal; - use ratatui::backend::TestBackend; - - fn synthetic_instance(name: &str) -> InstanceConfig { - // last_played intentionally None so the rendered text is the - // deterministic "Never played" string. anything else would make the - // snapshot drift relative to chrono::Utc::now(). - InstanceConfig { - name: name.to_string(), - game_version: "1.20.1".to_string(), - loader: ModLoader::Vanilla, - loader_version: None, - created: chrono::Utc::now(), - last_played: None, - java_path: None, - memory_max: None, - memory_min: None, - jvm_args: vec![], - resolution: None, - config_sync_profile: None, - } - } - - #[test] - fn instances_list_renders_three_instances() { - let mut state = State::with_instances(vec![ - synthetic_instance("Vanilla 1.20.1"), - synthetic_instance("Forge Pack"), - synthetic_instance("Fabric Test"), - ]); - - let backend = TestBackend::new(40, 12); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|f| render(f, f.area(), FocusedArea::Instances, &mut state)) - .unwrap(); - - insta::assert_snapshot!(terminal.backend()); - } - - #[test] - fn instances_list_renders_empty() { - let mut state = State::with_instances(vec![]); - - let backend = TestBackend::new(40, 8); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|f| render(f, f.area(), FocusedArea::Instances, &mut state)) - .unwrap(); - - insta::assert_snapshot!(terminal.backend()); - } -} +#[path = "../tests/widgets/instances.rs"] +mod tests; diff --git a/src/tui/widgets/logs_viewer.rs b/src/tui/widgets/logs_viewer.rs index 28413fa..0887fae 100644 --- a/src/tui/widgets/logs_viewer.rs +++ b/src/tui/widgets/logs_viewer.rs @@ -18,7 +18,7 @@ use tui_widget_list::{ListBuilder, ListState as TuiListState, ListView}; use crate::config::theme::{BORDER_STYLE, THEME}; use crate::instance::launch::parser::LogLevel; -use crate::instance::log_files::{LogFileEntry, read_log_file, scan_log_files}; +use crate::instance::logs::files::{LogFileEntry, read_log_file, scan_log_files}; type PendingLogs = Arc)>>>; @@ -93,7 +93,7 @@ impl LogsState { if let Ok(mut slot) = pending.lock() { *slot = Some((tag, entries)); - crate::tui::request_redraw(); + crate::feedback::request_redraw(); } }); } @@ -140,11 +140,11 @@ impl LogsState { return; }; if !matches!( - crate::running::get(name), + crate::instance::runtime::get(name), Some( - crate::running::RunState::Authenticating - | crate::running::RunState::Starting - | crate::running::RunState::Running + crate::instance::runtime::RunState::Authenticating + | crate::instance::runtime::RunState::Starting + | crate::instance::runtime::RunState::Running ) ) { return; @@ -164,7 +164,7 @@ impl LogsState { if let Ok(mut slot) = pending.lock() { *slot = Some((tag, entries)); - crate::tui::request_redraw(); + crate::feedback::request_redraw(); } }); } @@ -174,29 +174,49 @@ impl LogsState { fn has_live(&self) -> bool { let name = self.loaded_for.as_deref().unwrap_or(""); matches!( - crate::running::get(name), - Some(crate::running::RunState::Running) - | Some(crate::running::RunState::Starting) - | Some(crate::running::RunState::Crashed(_)) + crate::instance::runtime::get(name), + Some(crate::instance::runtime::RunState::Running) + | Some(crate::instance::runtime::RunState::Starting) + | Some(crate::instance::runtime::RunState::Crashed(_)) ) } fn display_count(&self) -> usize { - self.entries.len() + if self.has_live() { 1 } else { 0 } + self.display_indices().len() + } + + fn live_display_name(&self) -> String { + self.entries + .first() + .map(|entry| entry.name.trim_end_matches(".log").to_owned()) + .unwrap_or_else(|| "Live".to_owned()) + } + + fn display_indices(&self) -> Vec> { + let mut indices = Vec::new(); + if self.has_live() && self.search.matches(&self.live_display_name()) { + indices.push(None); + } + indices.extend( + self.entries + .iter() + .enumerate() + .filter(|(_, entry)| self.search.matches(entry.name.trim_end_matches(".log"))) + .map(|(index, _)| Some(index)), + ); + indices } fn is_live_selected(&self) -> bool { - self.has_live() && self.list_state.selected == Some(0) + self.list_state + .selected + .and_then(|selected| self.display_indices().get(selected).copied()) + == Some(None) } fn file_index_for_selected(&self) -> Option { - let sel = self.list_state.selected?; - let offset = if self.has_live() { 1 } else { 0 }; - if sel < offset { - None - } else { - Some(sel - offset) - } + let selected = self.list_state.selected?; + self.display_indices().get(selected).copied().flatten() } fn load_selected_content(&mut self) { @@ -228,6 +248,15 @@ impl LogsState { fn update_scrollbar(&mut self) { let count = self.display_count(); let max = count.saturating_sub(1); + if count == 0 { + self.list_state.selected = None; + } else if self + .list_state + .selected + .is_none_or(|selected| selected >= count) + { + self.list_state.selected = Some(max); + } let pos = self.list_state.selected.unwrap_or(0); self.scrollbar_state = ScrollbarState::new(max).position(pos); } @@ -284,7 +313,7 @@ pub fn handle_key(key_event: &KeyEvent, state: &mut LogsState) -> bool { state.viewer_scroll = 0; } KeyCode::Backspace => { - state.viewer_search.pop(); + state.viewer_search.backspace(key_event.modifiers); state.viewer_scroll = 0; } KeyCode::Char(c) => { @@ -345,21 +374,25 @@ pub fn handle_key(key_event: &KeyEvent, state: &mut LogsState) -> bool { KeyCode::Enter => { state.search.confirm(); state.list_state.selected = Some(0); + state.load_selected_content(); state.update_scrollbar(); } KeyCode::Esc => { state.search.deactivate(); state.list_state.selected = Some(0); + state.load_selected_content(); state.update_scrollbar(); } KeyCode::Backspace => { - state.search.pop(); + state.search.backspace(key_event.modifiers); state.list_state.selected = Some(0); + state.load_selected_content(); state.update_scrollbar(); } KeyCode::Char(c) => { state.search.push(c); state.list_state.selected = Some(0); + state.load_selected_content(); state.update_scrollbar(); } _ => {} @@ -435,17 +468,11 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut LogsState, is_focused: let [list_area, viewer_area] = Layout::horizontal([Constraint::Length(30), Constraint::Min(0)]).areas(area); - render_list(frame, list_area, state, is_focused, has_live); + render_list(frame, list_area, state, is_focused); render_viewer(frame, viewer_area, state, is_focused, has_live); } -fn render_list( - frame: &mut Frame, - area: Rect, - state: &mut LogsState, - is_focused: bool, - has_live: bool, -) { +fn render_list(frame: &mut Frame, area: Rect, state: &mut LogsState, is_focused: bool) { let theme = THEME.as_ref(); let list_focused = is_focused && !state.viewer_focused; let border_color = if list_focused { @@ -464,21 +491,21 @@ fn render_list( let display_count = state.display_count(); - let entries_snapshot: Vec<(String, bool)> = { - let mut v = Vec::new(); - if has_live { - let live_name = state - .entries - .first() - .map(|e| e.name.trim_end_matches(".log")) - .unwrap_or("Live"); - v.push((live_name.to_string(), true)); - } - for e in &state.entries { - v.push((e.name.trim_end_matches(".log").to_string(), false)); - } - v - }; + let entries_snapshot: Vec<(String, bool)> = state + .display_indices() + .into_iter() + .map(|index| match index { + Some(index) => ( + state.entries[index] + .name + .trim_end_matches(".log") + .to_owned(), + false, + ), + None => (state.live_display_name(), true), + }) + .collect(); + let search = &state.search; let builder = ListBuilder::new(move |context| { let (name, is_live) = &entries_snapshot[context.index]; @@ -509,11 +536,9 @@ fn render_list( } else { Span::raw(" ") }; - let item = ratatui::text::Text::from(Line::from(vec![ - selector, - Span::styled(name.clone(), style), - ])) - .style(Style::default().bg(bg)); + let mut spans = vec![selector]; + spans.extend(search.highlight_spans(name, style)); + let item = ratatui::text::Text::from(Line::from(spans)).style(Style::default().bg(bg)); (item, 1) }); @@ -555,7 +580,7 @@ fn render_viewer( let all_lines: Vec = if is_live { let name = state.loaded_for.as_deref().unwrap_or(""); - crate::instance_logs::get_entries(name) + crate::instance::logs::live::get_entries(name) .into_iter() .map(|line| ViewerLine { text: line.text, @@ -662,3 +687,7 @@ fn line_level_style(line: &str) -> Style { Style::default().fg(theme.text()) } } + +#[cfg(test)] +#[path = "../tests/widgets/logs_viewer.rs"] +mod tests; diff --git a/src/tui/widgets/markdown.rs b/src/tui/widgets/markdown.rs new file mode 100644 index 0000000..4070e5d --- /dev/null +++ b/src/tui/widgets/markdown.rs @@ -0,0 +1,1806 @@ +use std::collections::HashMap; +use std::ops::Range; +use std::sync::{Arc, Mutex}; + +use html_to_markdown_rs::{ + ConversionOptions, ImageMetadata, + visitor::{HtmlVisitor, NodeContext, VisitResult, VisitorHandle}, +}; +use image::DynamicImage; +use pulldown_cmark::{CodeBlockKind, Event, Options as MarkdownOptions, Parser, Tag, TagEnd}; +use ratatui::{ + Frame, + layout::{Alignment, Rect, Size}, + style::{Color, Modifier, Style}, + text::{Line, Span, Text}, + widgets::{Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget}, +}; +use ratatui_image::{ + Resize, + picker::ProtocolType, + sliced::{SignedPosition, SlicedImage, SlicedProtocol}, +}; +use tui_markdown::{Options as TuiMarkdownOptions, StyleSheet, from_str_with_options}; +use unicode_segmentation::UnicodeSegmentation; + +use crate::config::settings::ImageProtocol; +use crate::config::theme::THEME; +use crate::instance::content::{ + IconCell, fallback_icon, make_icon_pixels_from_image, make_icon_quadrants_from_image, +}; + +const MAX_DOCUMENT_WIDTH: u16 = 110; +const MAX_PROJECT_IMAGE_DIMENSION: u32 = 8192; +const MAX_PROJECT_IMAGE_ALLOCATION: u64 = 64 * 1024 * 1024; + +struct TextBlock { + source: String, + rendered: Option, +} + +struct TextRender { + width: u16, + text: Text<'static>, + height: usize, + links: Vec, +} + +struct TextLink { + x: u16, + y: usize, + width: u16, + url: String, +} + +struct LinkTarget { + width: usize, + url: String, +} + +struct LinkHit { + area: Rect, + url: String, +} + +#[derive(Clone)] +struct ImageReference { + url: String, + alt: String, + link: Option, + size: ImageSizeHint, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct ImageSizeHint { + width: Option, + height: Option, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum ImageAlignment { + #[default] + Left, + Center, +} + +enum DocumentBlock { + Text(TextBlock), + ImageRow { + images: Vec, + alignment: ImageAlignment, + }, +} + +enum ImageLoad { + Pending, + Ready(DynamicImage), + Failed, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +struct ImageRenderKey { + width: u16, + height: u16, + protocol: ProtocolType, + mode: ImageProtocol, +} + +struct PreparedImage { + protocol: Option, + raster: Vec>, +} + +struct PreparedImageResult { + url: String, + key: ImageRenderKey, + result: Result, +} + +struct DocumentImage { + load: ImageLoad, + prepared: Option<(ImageRenderKey, PreparedImage)>, + pending: Option, +} + +impl Default for DocumentImage { + fn default() -> Self { + Self { + load: ImageLoad::Pending, + prepared: None, + pending: None, + } + } +} + +pub struct Document { + blocks: Vec, + images: HashMap, + prepared_images: Arc>>, + link_hits: Vec, +} + +impl Document { + pub fn new(title: &str, body: &str) -> Self { + let normalized = normalize_html(body); + let source = strip_duplicate_title(&normalized.markdown, title); + let blocks = split_images( + &source, + &normalized.image_sizes, + &normalized.image_alignments, + ); + let images = blocks + .iter() + .flat_map(|block| match block { + DocumentBlock::ImageRow { images, .. } => images.as_slice(), + DocumentBlock::Text(_) => &[], + }) + .map(|image| (image.url.clone(), DocumentImage::default())) + .collect(); + Self { + blocks, + images, + prepared_images: Arc::new(Mutex::new(Vec::new())), + link_hits: Vec::new(), + } + } + + pub fn image_urls(&self) -> Vec { + self.images.keys().cloned().collect() + } + + pub fn set_image(&mut self, url: &str, result: Result) { + let Some(image) = self.images.get_mut(url) else { + return; + }; + image.load = match result { + Ok(decoded) => ImageLoad::Ready(decoded), + Err(error) => { + tracing::debug!("Failed to load project image {url}: {error}"); + ImageLoad::Failed + } + }; + image.prepared = None; + image.pending = None; + } + + pub fn link_at(&self, x: u16, y: u16) -> Option<&str> { + self.link_hits + .iter() + .find(|link| link.area.contains((x, y).into())) + .map(|link| link.url.as_str()) + } + + fn drain_prepared_images(&mut self) { + let results = match self.prepared_images.lock() { + Ok(mut pending) => std::mem::take(&mut *pending), + Err(_) => return, + }; + for result in results { + let Some(image) = self.images.get_mut(&result.url) else { + continue; + }; + if image.pending != Some(result.key) { + continue; + } + image.pending = None; + match result.result { + Ok(prepared) => image.prepared = Some((result.key, prepared)), + Err(error) => { + tracing::debug!("Failed to prepare project image {}: {error}", result.url); + } + } + } + } +} + +#[derive(Debug)] +struct FoundImage { + range: Range, + url: String, + alt: String, + link: Option, + alignment: ImageAlignment, +} + +pub fn image_urls(title: &str, body: &str) -> Vec { + Document::new(title, body).image_urls() +} + +pub fn decode_image(bytes: &[u8]) -> Result { + let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)) + .with_guessed_format() + .map_err(|error| error.to_string())?; + let mut limits = image::Limits::default(); + limits.max_image_width = Some(MAX_PROJECT_IMAGE_DIMENSION); + limits.max_image_height = Some(MAX_PROJECT_IMAGE_DIMENSION); + limits.max_alloc = Some(MAX_PROJECT_IMAGE_ALLOCATION); + reader.limits(limits); + if let Ok(image) = reader.decode() { + return Ok(image); + } + let mut options = resvg::usvg::Options::default(); + options.fontdb_mut().load_system_fonts(); + let tree = resvg::usvg::Tree::from_data(bytes, &options).map_err(|error| error.to_string())?; + let size = tree.size().to_int_size(); + let allocation = u64::from(size.width()) + .checked_mul(u64::from(size.height())) + .and_then(|pixels| pixels.checked_mul(4)) + .ok_or_else(|| "SVG image dimensions are too large".to_owned())?; + if size.width() > MAX_PROJECT_IMAGE_DIMENSION + || size.height() > MAX_PROJECT_IMAGE_DIMENSION + || allocation > MAX_PROJECT_IMAGE_ALLOCATION + { + return Err("SVG image dimensions are too large".to_owned()); + } + let mut pixmap = resvg::tiny_skia::Pixmap::new(size.width(), size.height()) + .ok_or_else(|| "SVG image dimensions are too large".to_owned())?; + resvg::render( + &tree, + resvg::tiny_skia::Transform::default(), + &mut pixmap.as_mut(), + ); + let pixels = image::RgbaImage::from_raw(size.width(), size.height(), pixmap.take()) + .ok_or_else(|| "SVG renderer returned an invalid pixel buffer".to_owned())?; + Ok(DynamicImage::ImageRgba8(pixels)) +} + +fn markdown_options() -> MarkdownOptions { + MarkdownOptions::ENABLE_TABLES + | MarkdownOptions::ENABLE_STRIKETHROUGH + | MarkdownOptions::ENABLE_TASKLISTS +} + +#[derive(Default)] +struct NormalizedDocument { + markdown: String, + image_sizes: HashMap, + image_alignments: HashMap, +} + +fn normalize_html(source: &str) -> NormalizedDocument { + let mut regions = Vec::new(); + let mut html_block_start = None; + let mut inline_container = None; + for (event, range) in Parser::new_ext(source, markdown_options()).into_offset_iter() { + match event { + Event::Start(Tag::HtmlBlock) => html_block_start = Some(range.start), + Event::End(TagEnd::HtmlBlock) => { + if let Some(start) = html_block_start.take() { + regions.push(start..range.end); + } + } + Event::Start(Tag::Paragraph | Tag::Heading { .. }) => { + inline_container = Some((range.start, false)); + } + Event::InlineHtml(_) => { + if let Some((_, contains_html)) = inline_container.as_mut() { + *contains_html = true; + } + } + Event::End(TagEnd::Paragraph | TagEnd::Heading(_)) => { + if let Some((start, true)) = inline_container.take() { + regions.push(start..range.end); + } + } + _ => {} + } + } + regions.sort_by_key(|range| range.start); + regions.dedup(); + + let mut normalized = source.to_owned(); + let mut image_sizes = HashMap::new(); + let mut image_alignments = HashMap::new(); + for range in regions.into_iter().rev() { + let html = &source[range.clone()]; + match convert_html_fragment(html) { + Ok(fragment) => { + image_sizes.extend(fragment.image_sizes); + image_alignments.extend(fragment.image_alignments); + normalized.replace_range(range, &fragment.markdown); + } + Err(error) => { + tracing::debug!("Failed to convert project HTML: {error}"); + } + } + } + NormalizedDocument { + markdown: normalized, + image_sizes, + image_alignments, + } +} + +fn convert_html_fragment(html: &str) -> Result { + let image_alignments = Arc::new(Mutex::new(HashMap::new())); + let visitor: VisitorHandle = Arc::new(Mutex::new(ImageAlignmentVisitor { + centered: Vec::new(), + image_alignments: image_alignments.clone(), + })); + let options = ConversionOptions { + compact_tables: true, + strip_tags: vec!["ins".to_owned(), "u".to_owned()], + visitor: Some(visitor), + ..ConversionOptions::default() + }; + let result = html_to_markdown_rs::convert(&format!("
{html}
"), Some(options)) + .map_err(|error| error.to_string())?; + let image_sizes = result + .metadata + .images + .iter() + .filter_map(image_size_hint) + .collect(); + let image_alignments = image_alignments + .lock() + .map(|alignments| alignments.clone()) + .unwrap_or_default(); + Ok(NormalizedDocument { + markdown: result.content.unwrap_or_default(), + image_sizes, + image_alignments, + }) +} + +#[derive(Debug)] +struct ImageAlignmentVisitor { + centered: Vec, + image_alignments: Arc>>, +} + +impl HtmlVisitor for ImageAlignmentVisitor { + fn visit_element_start(&mut self, context: &NodeContext<'_>) -> VisitResult { + let attributes = context.attributes(); + let centered = self.centered.last().copied().unwrap_or_default() + || context.tag_name.eq_ignore_ascii_case("center") + || attributes + .get("align") + .is_some_and(|align| align.eq_ignore_ascii_case("center")) + || attributes.get("style").is_some_and(|style| { + style.split(';').any(|declaration| { + declaration.split_once(':').is_some_and(|(name, value)| { + name.trim().eq_ignore_ascii_case("text-align") + && value.trim().eq_ignore_ascii_case("center") + }) + }) + }); + self.centered.push(centered); + VisitResult::Continue + } + + fn visit_element_end(&mut self, _context: &NodeContext<'_>, _output: &str) -> VisitResult { + self.centered.pop(); + VisitResult::Continue + } + + fn visit_image( + &mut self, + _context: &NodeContext<'_>, + source: &str, + _alt: &str, + _title: Option<&str>, + ) -> VisitResult { + if self.centered.last().copied().unwrap_or_default() + && let Ok(mut alignments) = self.image_alignments.lock() + { + alignments.insert(source.to_owned(), ImageAlignment::Center); + } + VisitResult::Continue + } +} + +fn image_size_hint(image: &ImageMetadata) -> Option<(String, ImageSizeHint)> { + let mut hint = image + .dimensions + .map(|dimensions| ImageSizeHint { + width: Some(dimensions.width), + height: Some(dimensions.height), + }) + .unwrap_or_default(); + hint.width = hint.width.or_else(|| { + image + .attributes + .get("width") + .and_then(|value| pixel_size(value)) + }); + hint.height = hint.height.or_else(|| { + image + .attributes + .get("height") + .and_then(|value| pixel_size(value)) + }); + (hint != ImageSizeHint::default()).then(|| (image.src.clone(), hint)) +} + +fn pixel_size(value: &str) -> Option { + let value = value.trim(); + let value = value.strip_suffix("px").unwrap_or(value).trim(); + (!value.is_empty() && value.chars().all(|character| character.is_ascii_digit())) + .then(|| value.parse().ok()) + .flatten() +} + +fn strip_duplicate_title(source: &str, title: &str) -> String { + let mut heading_start = None; + let mut heading_text = String::new(); + for (event, range) in Parser::new_ext(source, markdown_options()).into_offset_iter() { + match event { + Event::Start(Tag::Heading { .. }) + if source[..range.start].trim().is_empty() && heading_start.is_none() => + { + heading_start = Some(range.start); + } + Event::Text(text) | Event::Code(text) if heading_start.is_some() => { + heading_text.push_str(&text); + } + Event::End(TagEnd::Heading(_)) if heading_start.is_some() => { + if heading_text.trim().eq_ignore_ascii_case(title.trim()) { + return source[range.end..].trim_start_matches('\n').to_owned(); + } + break; + } + Event::Start(_) if heading_start.is_none() => break, + _ => {} + } + } + source.to_owned() +} + +fn split_images( + source: &str, + image_sizes: &HashMap, + image_alignments: &HashMap, +) -> Vec { + let mut images = Vec::::new(); + let mut image_stack = Vec::::new(); + let mut link_stack = Vec::<(usize, usize, String)>::new(); + + for (event, range) in Parser::new_ext(source, markdown_options()).into_offset_iter() { + match event { + Event::Start(Tag::Link { dest_url, .. }) => { + link_stack.push((range.start, images.len(), dest_url.into_string())); + } + Event::End(TagEnd::Link) => { + if let Some((start, first_image, url)) = link_stack.pop() + && first_image < images.len() + { + images[first_image].range.start = start; + for image in &mut images[first_image..] { + image.link = Some(url.clone()); + } + if let Some(last) = images.last_mut() { + last.range.end = range.end; + } + } + } + Event::Start(Tag::Image { dest_url, .. }) => { + image_stack.push(FoundImage { + range: range.start..range.end, + url: dest_url.to_string(), + alt: String::new(), + link: None, + alignment: image_alignments + .get(dest_url.as_ref()) + .copied() + .unwrap_or(ImageAlignment::Left), + }); + } + Event::Text(text) if !image_stack.is_empty() => { + if let Some(image) = image_stack.last_mut() { + image.alt.push_str(&text); + } + } + Event::End(TagEnd::Image) => { + if let Some(mut image) = image_stack.pop() { + image.range.end = range.end; + images.push(image); + } + } + _ => {} + } + } + + images.sort_by_key(|image| image.range.start); + let mut blocks = Vec::new(); + let mut image_row = Vec::new(); + let mut cursor = 0; + for image in images { + if image.range.start < cursor + || image.range.end < image.range.start + || image.range.end > source.len() + || !source.is_char_boundary(image.range.start) + || !source.is_char_boundary(image.range.end) + { + tracing::debug!("Ignoring invalid project image source range"); + continue; + } + let between = &source[cursor..image.range.start]; + let stays_in_row = !image_row.is_empty() + && between.trim().is_empty() + && !between.contains("\n\n") + && image_row + .first() + .is_some_and(|(_, alignment)| *alignment == image.alignment); + if !stays_in_row { + push_image_row(&mut blocks, &mut image_row); + push_text_block(&mut blocks, between); + } + image_row.push(( + ImageReference { + size: image_sizes.get(&image.url).copied().unwrap_or_default(), + url: image.url, + alt: image.alt, + link: image.link, + }, + image.alignment, + )); + cursor = image.range.end; + } + push_image_row(&mut blocks, &mut image_row); + if cursor < source.len() { + push_text_block(&mut blocks, &source[cursor..]); + } + blocks +} + +fn push_image_row( + blocks: &mut Vec, + row: &mut Vec<(ImageReference, ImageAlignment)>, +) { + if !row.is_empty() { + let alignment = row[0].1; + blocks.push(DocumentBlock::ImageRow { + images: std::mem::take(row) + .into_iter() + .map(|(image, _)| image) + .collect(), + alignment, + }); + } +} + +fn push_text_block(blocks: &mut Vec, source: &str) { + let text = source.trim_matches('\n'); + if !text.trim().is_empty() { + blocks.push(DocumentBlock::Text(TextBlock { + source: format!("{text}\n"), + rendered: None, + })); + } +} + +fn external_markdown_text(source: &str, width: u16) -> (Text<'static>, Vec) { + let styles = MarkdownStyleSheet; + let rule_style = styles.table_border(); + let list_marker_style = Style::default().fg(THEME.as_ref().text_dim()); + let table_separator_style = styles.table_border(); + let code_style = styles.code(); + let code_language_style = Style::default() + .fg(THEME.as_ref().text_dim()) + .bg(THEME.as_ref().surface()); + let links = markdown_links(source); + let options = TuiMarkdownOptions::new(styles); + let mut text = own_text(from_str_with_options(source, &options)); + text.lines = render_code_blocks( + text.lines, + markdown_code_languages(source), + width, + code_style, + code_language_style, + ); + text.lines = unbox_tables(text.lines); + for line in &mut text.lines { + let line_style = Style::default().fg(THEME.as_ref().text()).patch(line.style); + line.style = line_style; + line.spans.retain(|span| { + let content = span.content.as_ref(); + content != "*" + && !(span.style.add_modifier.contains(Modifier::UNDERLINED) + && content.starts_with("(http") + && content.ends_with(')')) + }); + remove_link_destination(line); + for span in &mut line.spans { + span.style = line_style.patch(span.style); + if span.content.contains("**") { + span.content = span.content.replace("**", "").into(); + } + } + } + text.lines = wrap_tables(text.lines, width, table_separator_style) + .into_iter() + .map(|line| { + if line.to_string() == "---" { + Line::styled("─".repeat(usize::from(width)), rule_style) + } else { + line + } + }) + .flat_map(|line| wrap_list_line(line, width, list_marker_style)) + .flat_map(|line| wrap_line(line, width)) + .collect(); + (text, links) +} + +fn markdown_code_languages(source: &str) -> Vec { + Parser::new_ext(source, markdown_options()) + .filter_map(|event| match event { + Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(language))) => { + Some(language.into_string()) + } + Event::Start(Tag::CodeBlock(CodeBlockKind::Indented)) => Some(String::new()), + _ => None, + }) + .collect() +} + +fn render_code_blocks( + lines: Vec>, + languages: Vec, + width: u16, + code_style: Style, + language_style: Style, +) -> Vec> { + let mut output = Vec::new(); + let mut lines = lines.into_iter().peekable(); + let mut languages = languages.into_iter(); + while let Some(line) = lines.next() { + if line.style != code_style { + output.push(line); + continue; + } + let mut content = vec![line.to_string()]; + while lines.peek().is_some_and(|line| line.style == code_style) { + content.push(lines.next().expect("peeked code line").to_string()); + } + output.extend(code_block_lines( + &languages.next().unwrap_or_default(), + &content.join("\n"), + width, + code_style, + language_style, + )); + } + output +} + +fn unbox_tables(lines: Vec>) -> Vec> { + lines + .into_iter() + .filter_map(|mut line| { + let text = line.to_string(); + if is_table_border(&text, '┌', '┬', '┐') || is_table_border(&text, '└', '┴', '┘') + { + return None; + } + if is_table_border(&text, '├', '┼', '┤') { + strip_outer_chars(&mut line, '├', '┤'); + } else if text.starts_with('│') && text.ends_with('│') { + strip_outer_chars(&mut line, '│', '│'); + } + Some(line) + }) + .collect() +} + +fn is_table_border(text: &str, left: char, middle: char, right: char) -> bool { + text.starts_with(left) + && text.ends_with(right) + && text.chars().all(|character| { + matches!(character, '─') + || character == left + || character == middle + || character == right + }) +} + +fn strip_outer_chars(line: &mut Line<'static>, left: char, right: char) { + if let Some(span) = line.spans.iter_mut().find(|span| !span.content.is_empty()) { + span.content = span.content.trim_start_matches(left).to_owned().into(); + } + if let Some(span) = line + .spans + .iter_mut() + .rev() + .find(|span| !span.content.is_empty()) + { + span.content = span.content.trim_end_matches(right).to_owned().into(); + } + line.spans.retain(|span| !span.content.is_empty()); +} + +fn remove_link_destination(line: &mut Line<'static>) { + let spans = std::mem::take(&mut line.spans); + let mut output = Vec::with_capacity(spans.len()); + let mut index = 0; + while index < spans.len() { + if index + 2 < spans.len() + && spans[index].content.ends_with(" (") + && spans[index + 1] + .style + .add_modifier + .contains(Modifier::UNDERLINED) + && spans[index + 2].content.starts_with(')') + { + let mut before = spans[index].clone(); + before.content = before.content.trim_end_matches(" (").to_owned().into(); + if !before.content.is_empty() { + output.push(before); + } + let mut after = spans[index + 2].clone(); + after.content = after.content.trim_start_matches(')').to_owned().into(); + if !after.content.is_empty() { + output.push(after); + } + index += 3; + } else { + output.push(spans[index].clone()); + index += 1; + } + } + line.spans = output; +} + +fn own_text(text: Text<'_>) -> Text<'static> { + Text { + lines: text + .lines + .into_iter() + .map(|line| Line { + spans: line + .spans + .into_iter() + .map(|span| Span::styled(span.content.into_owned(), span.style)) + .collect(), + style: line.style, + alignment: line.alignment, + }) + .collect(), + style: text.style, + alignment: text.alignment, + } +} + +fn markdown_links(source: &str) -> Vec { + let mut links = Vec::new(); + let mut current = None::<(String, String)>; + let mut in_table = false; + for event in Parser::new_ext(source, markdown_options()) { + match event { + Event::Start(Tag::Table(_)) => in_table = true, + Event::End(TagEnd::Table) => in_table = false, + Event::Start(Tag::Link { dest_url, .. }) if !in_table => { + current = Some((dest_url.into_string(), String::new())); + } + Event::Text(text) | Event::Code(text) if current.is_some() => { + if let Some((_, label)) = current.as_mut() { + label.push_str(&text); + } + } + Event::End(TagEnd::Link) => { + if let Some((url, label)) = current.take() { + links.push(LinkTarget { + width: Span::raw(label).width(), + url, + }); + } + } + _ => {} + } + } + links +} + +fn wrap_tables( + lines: Vec>, + width: u16, + separator_style: Style, +) -> Vec> { + let mut output = Vec::new(); + let mut lines = lines.into_iter().peekable(); + while let Some(header) = lines.next() { + let Some(separator) = lines.peek() else { + output.push(header); + break; + }; + let Some(columns) = table_column_count(separator, separator_style) else { + output.push(header); + continue; + }; + let Some(header_cells) = table_cells(&header, columns, separator_style) else { + output.push(header); + continue; + }; + + lines.next(); + let mut rows = vec![header_cells]; + while let Some(line) = lines.peek() { + let Some(cells) = table_cells(line, columns, separator_style) else { + break; + }; + rows.push(cells); + lines.next(); + } + output.extend(render_table_rows(rows, width, separator_style)); + } + output +} + +fn table_column_count(line: &Line<'_>, separator_style: Style) -> Option { + let text = line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + (!text.is_empty() + && line.spans.iter().all(|span| span.style == separator_style) + && text.chars().all(|character| matches!(character, '─' | '┼'))) + .then(|| text.chars().filter(|character| *character == '┼').count() + 1) +} + +fn table_cells( + line: &Line<'static>, + columns: usize, + separator_style: Style, +) -> Option>>> { + let mut cells = vec![Vec::new()]; + for span in &line.spans { + if span.style == separator_style && span.content.contains('│') { + cells.push(Vec::new()); + } else { + cells.last_mut()?.push(span.clone()); + } + } + if cells.len() != columns { + return None; + } + for cell in &mut cells { + trim_span_start(cell); + trim_span_end(cell); + } + Some(cells) +} + +fn trim_span_start(spans: &mut Vec>) { + while let Some(first) = spans.first_mut() { + let trimmed = first.content.trim_start_matches(char::is_whitespace); + if trimmed.is_empty() { + spans.remove(0); + } else { + first.content = trimmed.to_owned().into(); + break; + } + } +} + +fn trim_span_end(spans: &mut Vec>) { + while let Some(last) = spans.last_mut() { + let trimmed = last.content.trim_end_matches(char::is_whitespace); + if trimmed.is_empty() { + spans.pop(); + } else { + last.content = trimmed.to_owned().into(); + break; + } + } +} + +fn render_table_rows( + rows: Vec>>>, + width: u16, + separator_style: Style, +) -> Vec> { + let columns = rows.first().map_or(0, Vec::len); + if columns == 0 { + return Vec::new(); + } + let width = usize::from(width.max(1)); + let spaced_separator = " │ "; + let compact_separator = "│"; + let separator = if width >= columns.saturating_add(3 * columns.saturating_sub(1)) { + spaced_separator + } else { + compact_separator + }; + let separator_width = Span::raw(separator).width() * columns.saturating_sub(1); + if width <= separator_width || width.saturating_sub(separator_width) < columns { + return rows + .into_iter() + .map(|row| Line::from(row.into_iter().flatten().collect::>>())) + .flat_map(|line| wrap_line(line, width as u16)) + .collect(); + } + + let available = width - separator_width; + let natural = (0..columns) + .map(|column| { + rows.iter() + .map(|row| row[column].iter().map(Span::width).sum::()) + .max() + .unwrap_or(1) + .max(1) + }) + .collect::>(); + let widths = fit_column_widths(&natural, available); + let mut output = Vec::new(); + for (row_index, row) in rows.into_iter().enumerate() { + if row_index == 1 { + output.push(table_rule(&widths, separator, separator_style)); + } + output.extend(render_table_row(row, &widths, separator, separator_style)); + } + if output.len() == 1 { + output.push(table_rule(&widths, separator, separator_style)); + } + output +} + +fn fit_column_widths(natural: &[usize], available: usize) -> Vec { + if natural.iter().sum::() <= available { + return natural.to_vec(); + } + let mut widths = vec![1; natural.len()]; + for _ in 0..available.saturating_sub(natural.len()) { + let Some((column, _)) = natural.iter().zip(&widths).enumerate().max_by( + |(_, (left_natural, left_width)), (_, (right_natural, right_width))| { + (*left_natural * *right_width).cmp(&(*right_natural * *left_width)) + }, + ) else { + break; + }; + widths[column] += 1; + } + widths +} + +fn render_table_row( + cells: Vec>>, + widths: &[usize], + separator: &str, + separator_style: Style, +) -> Vec> { + let wrapped = cells + .iter() + .zip(widths) + .map(|(cell, width)| wrap_styled_spans(cell, *width)) + .collect::>(); + let height = wrapped.iter().map(Vec::len).max().unwrap_or(1); + (0..height) + .map(|line_index| { + let mut spans = Vec::new(); + for (column, width) in widths.iter().enumerate() { + if column > 0 { + spans.push(Span::styled(separator.to_owned(), separator_style)); + } + let cell_line = wrapped[column].get(line_index).cloned().unwrap_or_default(); + let cell_width = cell_line.iter().map(Span::width).sum::(); + spans.extend(cell_line); + spans.push(Span::raw(" ".repeat(width.saturating_sub(cell_width)))); + } + Line::from(spans) + }) + .collect() +} + +fn table_rule(widths: &[usize], separator: &str, style: Style) -> Line<'static> { + let junction = if separator == " │ " { + "─┼─" + } else { + "┼" + }; + let mut spans = Vec::new(); + for (column, width) in widths.iter().enumerate() { + if column > 0 { + spans.push(Span::styled(junction.to_owned(), style)); + } + spans.push(Span::styled("─".repeat(*width), style)); + } + Line::from(spans) +} + +fn wrap_line(line: Line<'static>, width: u16) -> Vec> { + let width = usize::from(width.max(1)); + if line.width() <= width { + return vec![line]; + } + wrap_styled_spans(&line.spans, width) + .into_iter() + .map(|spans| Line { + style: line.style, + alignment: line.alignment, + spans, + }) + .collect() +} + +fn code_block_lines( + language: &str, + content: &str, + width: u16, + code_style: Style, + language_style: Style, +) -> Vec> { + let width = usize::from(width.max(1)); + let inner_width = width.saturating_sub(2).max(1); + let mut lines = Vec::new(); + if !language.is_empty() { + lines.push(padded_code_line( + &format!("[{language}]"), + width, + language_style, + )); + } + for source_line in content.strip_suffix('\n').unwrap_or(content).split('\n') { + for line in split_display_width(source_line, inner_width) { + lines.push(padded_code_line(&line, width, code_style)); + } + } + if lines.is_empty() { + lines.push(Line::styled(" ".repeat(width), code_style)); + } + lines +} + +fn padded_code_line(content: &str, width: usize, style: Style) -> Line<'static> { + let inner_width = width.saturating_sub(2); + let content_width = Span::raw(content).width().min(inner_width); + Line::styled( + format!( + " {content}{} ", + " ".repeat(inner_width.saturating_sub(content_width)) + ), + style, + ) +} + +fn split_display_width(source: &str, width: usize) -> Vec { + let mut lines = vec![String::new()]; + let mut line_width = 0usize; + for grapheme in source.graphemes(true) { + let grapheme_width = Span::raw(grapheme).width(); + if line_width > 0 && line_width.saturating_add(grapheme_width) > width { + lines.push(String::new()); + line_width = 0; + } + lines + .last_mut() + .expect("display line exists") + .push_str(grapheme); + line_width = line_width.saturating_add(grapheme_width); + } + lines +} + +fn wrap_list_line(line: Line<'static>, width: u16, marker_style: Style) -> Vec> { + let Some(marker) = line + .spans + .first() + .filter(|span| is_list_marker(&span.content)) + else { + return vec![line]; + }; + let leading_spaces = marker + .content + .chars() + .take_while(|character| character.is_whitespace()) + .count(); + let indent = if leading_spaces == 0 { + 2 + } else { + leading_spaces + }; + let marker = format!( + "{}{} ", + " ".repeat(indent), + if marker.content.trim() == "-" { + "•" + } else { + marker.content.trim() + } + ); + let marker_width = Span::raw(&marker).width(); + let content_width = usize::from(width).saturating_sub(marker_width).max(1); + let wrapped = wrap_styled_spans(&line.spans[1..], content_width); + let continuation = " ".repeat(marker_width); + wrapped + .into_iter() + .enumerate() + .map(|(index, mut spans)| { + spans.insert( + 0, + Span::styled( + if index == 0 { + marker.clone() + } else { + continuation.clone() + }, + marker_style, + ), + ); + Line { + style: line.style, + alignment: line.alignment, + spans, + } + }) + .collect() +} + +fn is_list_marker(marker: &str) -> bool { + let marker = marker.trim(); + marker == "-" + || marker == "•" + || marker + .strip_suffix('.') + .is_some_and(|number| !number.is_empty() && number.chars().all(|c| c.is_ascii_digit())) +} + +fn wrap_styled_spans(spans: &[Span<'static>], width: usize) -> Vec>> { + let mut lines = Vec::new(); + let mut line = Vec::new(); + let mut line_width = 0usize; + let mut whitespace = Vec::new(); + let mut whitespace_width = 0usize; + + for span in spans { + for part in span.content.split_word_bounds() { + if part.chars().all(char::is_whitespace) { + push_styled(&mut whitespace, part, span.style); + whitespace_width = whitespace_width.saturating_add(Span::raw(part).width()); + continue; + } + + let part_width = Span::raw(part).width(); + if line_width > 0 + && line_width + .saturating_add(whitespace_width) + .saturating_add(part_width) + > width + { + lines.push(std::mem::take(&mut line)); + line_width = 0; + whitespace.clear(); + whitespace_width = 0; + } else if line_width > 0 { + line.append(&mut whitespace); + line_width = line_width.saturating_add(whitespace_width); + whitespace_width = 0; + } else { + whitespace.clear(); + whitespace_width = 0; + } + + for grapheme in part.graphemes(true) { + let grapheme_width = Span::raw(grapheme).width(); + if line_width > 0 && line_width.saturating_add(grapheme_width) > width { + lines.push(std::mem::take(&mut line)); + line_width = 0; + } + push_styled(&mut line, grapheme, span.style); + line_width = line_width.saturating_add(grapheme_width); + } + } + } + if !line.is_empty() { + lines.push(line); + } + if lines.is_empty() { + lines.push(Vec::new()); + } + lines +} + +fn push_styled(spans: &mut Vec>, content: &str, style: Style) { + if let Some(last) = spans.last_mut() + && last.style == style + { + last.content.to_mut().push_str(content); + } else { + spans.push(Span::styled(content.to_owned(), style)); + } +} + +#[derive(Clone, Copy)] +struct MarkdownStyleSheet; + +impl StyleSheet for MarkdownStyleSheet { + fn heading(&self, _level: u8) -> Style { + Style::default() + .fg(THEME.as_ref().accent()) + .add_modifier(Modifier::BOLD) + } + + fn code(&self) -> Style { + Style::default() + .fg(THEME.as_ref().text()) + .bg(THEME.as_ref().surface()) + } + + fn link(&self) -> Style { + Style::default() + .fg(THEME.as_ref().info()) + .add_modifier(Modifier::UNDERLINED) + } + + fn blockquote(&self) -> Style { + Style::default().fg(THEME.as_ref().text_dim()) + } + + fn heading_marker(&self, _level: u8) -> &str { + "" + } + + fn code_block_fence(&self) -> &str { + "" + } + + fn html(&self) -> Style { + Style::default().fg(THEME.as_ref().text_dim()) + } + + fn table_header(&self) -> Style { + self.heading(1) + } + + fn table_cell(&self) -> Style { + Style::default().fg(THEME.as_ref().text()) + } + + fn table_border(&self) -> Style { + Style::default().fg(THEME.as_ref().border()) + } +} + +impl TextBlock { + fn prepare(&mut self, width: u16) -> &TextRender { + if self + .rendered + .as_ref() + .is_none_or(|rendered| rendered.width != width) + { + let (text, targets) = external_markdown_text(&self.source, width); + let height = text.lines.len().max(1); + let links = layout_links(&text, &targets, width); + self.rendered = Some(TextRender { + width, + text, + height, + links, + }); + } + self.rendered.as_ref().expect("text was prepared") + } +} + +fn layout_links(text: &Text<'static>, targets: &[LinkTarget], width: u16) -> Vec { + let mut links = Vec::::new(); + let mut target = 0usize; + let mut remaining = targets.first().map_or(0, |target| target.width); + for (y, line) in text.lines.iter().enumerate() { + let line_width = line.width().min(usize::from(width)); + let mut x = match line.alignment.unwrap_or(Alignment::Left) { + Alignment::Left => 0, + Alignment::Center => usize::from(width).saturating_sub(line_width) / 2, + Alignment::Right => usize::from(width).saturating_sub(line_width), + }; + for span in &line.spans { + let is_link = span.style.add_modifier.contains(Modifier::UNDERLINED); + for grapheme in span.content.graphemes(true) { + let grapheme_width = Span::raw(grapheme).width(); + if is_link && grapheme_width > 0 { + while remaining == 0 && target + 1 < targets.len() { + target += 1; + remaining = targets[target].width; + } + if let Some(link) = targets.get(target) { + let width = grapheme_width.min(remaining); + if width > 0 { + if let Some(last) = links.last_mut() + && last.y == y + && usize::from(last.x) + usize::from(last.width) == x + && last.url == link.url + { + last.width = last.width.saturating_add(width as u16); + } else { + links.push(TextLink { + x: x.min(usize::from(u16::MAX)) as u16, + y, + width: width.min(usize::from(u16::MAX)) as u16, + url: link.url.clone(), + }); + } + remaining = remaining.saturating_sub(width); + } + } + } + x = x.saturating_add(grapheme_width); + } + } + } + links +} + +pub fn render( + frame: &mut Frame, + area: Rect, + document: &mut Document, + scroll: &mut usize, + picker: &ratatui_image::picker::Picker, +) -> usize { + if area.width == 0 || area.height == 0 { + *scroll = 0; + return 0; + } + let content_width = area.width.min(MAX_DOCUMENT_WIDTH); + let content_area = Rect { + x: area + .x + .saturating_add(area.width.saturating_sub(content_width) / 2), + width: content_width, + ..area + }; + document.drain_prepared_images(); + document.link_hits.clear(); + let pending_preparations = document.prepared_images.clone(); + let (blocks, images, link_hits) = ( + &mut document.blocks, + &mut document.images, + &mut document.link_hits, + ); + let mut heights = Vec::with_capacity(blocks.len()); + let mut image_rows = HashMap::new(); + for (index, block) in blocks.iter_mut().enumerate() { + let height = match block { + DocumentBlock::Text(text) => text.prepare(content_area.width).height, + DocumentBlock::ImageRow { + images: row, + alignment, + } => { + let layout = image_row_layout(row, *alignment, images, content_area, picker); + let height = usize::from(layout.height); + image_rows.insert(index, layout); + height + } + }; + heights.push(height); + } + + let line_count = heights.iter().sum::(); + let max_scroll = line_count.saturating_sub(usize::from(area.height)); + *scroll = (*scroll).min(max_scroll); + let viewport_start = *scroll; + let viewport_end = viewport_start.saturating_add(usize::from(area.height)); + let mut document_y = 0usize; + + for (index, (block, height)) in blocks.iter_mut().zip(heights).enumerate() { + let block_end = document_y.saturating_add(height); + if block_end > viewport_start && document_y < viewport_end { + let hidden_top = viewport_start.saturating_sub(document_y); + let visible_y = document_y.saturating_sub(viewport_start); + let visible_height = height + .saturating_sub(hidden_top) + .min(viewport_end.saturating_sub(document_y.max(viewport_start))); + let block_area = Rect { + x: content_area.x, + y: area.y.saturating_add(visible_y as u16), + width: content_area.width, + height: visible_height as u16, + }; + match block { + DocumentBlock::Text(text) => { + let rendered = text.prepare(content_area.width); + for link in &rendered.links { + let link_y = document_y.saturating_add(link.y); + if link_y < viewport_start || link_y >= viewport_end { + continue; + } + let x = content_area.x.saturating_add(link.x); + let width = link.width.min(content_area.right().saturating_sub(x)); + if width > 0 { + link_hits.push(LinkHit { + area: Rect { + x, + y: area.y.saturating_add( + link_y.saturating_sub(viewport_start) as u16 + ), + width, + height: 1, + }, + url: link.url.clone(), + }); + } + } + let paragraph = Paragraph::new(rendered.text.clone()) + .style(Style::default().fg(THEME.as_ref().text())) + .scroll((hidden_top.min(usize::from(u16::MAX)) as u16, 0)); + frame.render_widget(paragraph, block_area); + } + DocumentBlock::ImageRow { images: row, .. } => { + if let Some(layout) = image_rows.get(&index) { + render_image_row( + frame, + block_area, + row, + layout, + images, + hidden_top, + picker, + &pending_preparations, + link_hits, + ); + } + } + } + } + document_y = block_end; + } + + if max_scroll > 0 { + let scrollbar_position = (*scroll) + .saturating_mul(line_count.saturating_sub(1)) + .checked_div(max_scroll) + .unwrap_or_default(); + let mut scrollbar_state = ScrollbarState::new(line_count) + .position(scrollbar_position) + .viewport_content_length(usize::from(area.height)); + Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(None) + .end_symbol(None) + .track_symbol(None) + .render(area, frame.buffer_mut(), &mut scrollbar_state); + } + max_scroll +} + +struct ImageRowLayout { + height: u16, + width: u16, + alignment: ImageAlignment, + items: Vec<(u16, u16)>, +} + +fn image_row_layout( + row: &[ImageReference], + alignment: ImageAlignment, + images: &HashMap, + area: Rect, + picker: &ratatui_image::picker::Picker, +) -> ImageRowLayout { + let gap_width = row.len().saturating_sub(1).min(usize::from(u16::MAX)) as u16; + let available_width = area.width.saturating_sub(gap_width).max(1); + let mut items = row + .iter() + .map(|reference| { + images + .get(&reference.url) + .map_or((6.min(available_width), 3), |image| { + image_dimensions(image, reference.size, available_width, picker) + }) + }) + .collect::>(); + let total_width = items + .iter() + .map(|(width, _)| u32::from(*width)) + .sum::(); + if total_width > u32::from(available_width) { + let scale = f64::from(available_width) / f64::from(total_width); + for (width, height) in &mut items { + *width = (f64::from(*width) * scale).floor().max(1.0) as u16; + *height = (f64::from(*height) * scale).ceil().max(1.0) as u16; + } + } + let width = items + .iter() + .map(|(width, _)| *width) + .sum::() + .saturating_add(gap_width); + let height = items.iter().map(|(_, height)| *height).max().unwrap_or(1); + ImageRowLayout { + height, + width, + alignment, + items, + } +} + +fn image_dimensions( + image: &DocumentImage, + hint: ImageSizeHint, + available_width: u16, + picker: &ratatui_image::picker::Picker, +) -> (u16, u16) { + let ImageLoad::Ready(decoded) = &image.load else { + return (6.min(available_width), 3); + }; + let font = picker.font_size(); + let font_width = u32::from(font.width.max(1)); + let font_height = u32::from(font.height.max(1)); + let decoded_width = decoded.width().max(1); + let decoded_height = decoded.height().max(1); + let mut pixel_width = hint + .width + .or_else(|| { + hint.height.map(|height| { + height + .saturating_mul(decoded_width) + .div_ceil(decoded_height) + }) + }) + .unwrap_or(decoded_width) + .max(1); + let mut pixel_height = hint + .height + .unwrap_or_else(|| { + pixel_width + .saturating_mul(decoded_height) + .div_ceil(decoded_width) + }) + .max(1); + let available_pixels = u32::from(available_width.max(1)).saturating_mul(font_width); + if pixel_width > available_pixels { + pixel_height = pixel_height + .saturating_mul(available_pixels) + .div_ceil(pixel_width); + pixel_width = available_pixels; + } + let width = pixel_width + .div_ceil(font_width) + .clamp(1, u32::from(available_width.max(1))) as u16; + let height = pixel_height + .div_ceil(font_height) + .clamp(1, u32::from(u16::MAX)) as u16; + (width, height) +} + +#[allow(clippy::too_many_arguments)] +fn render_image_row( + frame: &mut Frame, + area: Rect, + row: &[ImageReference], + layout: &ImageRowLayout, + images: &mut HashMap, + hidden_top: usize, + picker: &ratatui_image::picker::Picker, + pending: &Arc>>, + link_hits: &mut Vec, +) { + let row_start = match layout.alignment { + ImageAlignment::Left => area.x, + ImageAlignment::Center => area.x + area.width.saturating_sub(layout.width) / 2, + }; + let mut x = row_start; + for (reference, (width, height)) in row.iter().zip(&layout.items) { + let item_y = usize::from(layout.height.saturating_sub(*height)); + let item_end = item_y.saturating_add(usize::from(*height)); + let viewport_start = hidden_top; + let viewport_end = hidden_top.saturating_add(usize::from(area.height)); + if item_end > viewport_start && item_y < viewport_end { + let item_hidden_top = viewport_start.saturating_sub(item_y); + let visible_y = item_y.saturating_sub(viewport_start); + let visible_height = item_end + .min(viewport_end) + .saturating_sub(item_y.max(viewport_start)); + let image_area = Rect { + x, + y: area.y.saturating_add(visible_y as u16), + width: *width, + height: visible_height as u16, + }; + if let Some(url) = &reference.link { + link_hits.push(LinkHit { + area: image_area, + url: url.clone(), + }); + } + if let Some(image) = images.get_mut(&reference.url) { + render_image( + frame, + image_area, + image, + reference, + item_hidden_top, + *height, + picker, + pending, + ); + } + } + x = x.saturating_add(*width).saturating_add(1); + } +} + +#[allow(clippy::too_many_arguments)] +fn render_image( + frame: &mut Frame, + area: Rect, + image: &mut DocumentImage, + reference: &ImageReference, + hidden_top: usize, + full_height: u16, + picker: &ratatui_image::picker::Picker, + pending: &Arc>>, +) { + let ImageLoad::Ready(decoded) = &image.load else { + render_fallback(frame, area, hidden_top, &reference.alt); + return; + }; + let key = ImageRenderKey { + width: area.width, + height: full_height, + protocol: picker.protocol_type(), + mode: crate::config::SETTINGS.ui.image_protocol, + }; + if image + .prepared + .as_ref() + .is_none_or(|(prepared_key, _)| *prepared_key != key) + && image.pending != Some(key) + { + image.pending = Some(key); + prepare_image( + reference.url.clone(), + decoded.clone(), + key, + picker.clone(), + pending.clone(), + ); + } + + let Some((prepared_key, prepared)) = image.prepared.as_ref() else { + render_fallback(frame, area, hidden_top, &reference.alt); + return; + }; + if *prepared_key != key { + render_fallback(frame, area, hidden_top, &reference.alt); + return; + } + + if let Some(protocol) = prepared.protocol.as_ref() { + let hidden_top = hidden_top.min(i16::MAX as usize) as i16; + frame.render_widget( + SlicedImage::new(protocol, SignedPosition::from((0, -hidden_top))), + area, + ); + } else { + render_raster(frame, area, &prepared.raster, hidden_top); + } +} + +fn prepare_image( + url: String, + image: DynamicImage, + key: ImageRenderKey, + picker: ratatui_image::picker::Picker, + pending: Arc>>, +) { + std::thread::spawn(move || { + let result = prepare_image_render(image, key, &picker); + if let Ok(mut pending) = pending.lock() { + pending.push(PreparedImageResult { url, key, result }); + } + crate::feedback::request_redraw(); + }); +} + +fn prepare_image_render( + image: DynamicImage, + key: ImageRenderKey, + picker: &ratatui_image::picker::Picker, +) -> Result { + let raster = match key.mode { + ImageProtocol::Quadrants if key.protocol == ProtocolType::Halfblocks => { + make_icon_quadrants_from_image(&image, key.width, key.height) + } + _ => make_icon_pixels_from_image(&image, key.width, key.height), + }; + let protocol = if key.protocol == ProtocolType::Halfblocks { + None + } else { + let image = bottom_align_to_cells(image, key, picker); + Some( + SlicedProtocol::new_with_resize( + picker, + image, + Size::new(key.width, key.height), + Resize::Fit(None), + ) + .map_err(|error| error.to_string())?, + ) + }; + Ok(PreparedImage { protocol, raster }) +} + +fn bottom_align_to_cells( + image: DynamicImage, + key: ImageRenderKey, + picker: &ratatui_image::picker::Picker, +) -> DynamicImage { + let font = picker.font_size(); + let width = u32::from(key.width) * u32::from(font.width.max(1)); + let height = u32::from(key.height) * u32::from(font.height.max(1)); + let fitted = image.resize(width, height, image::imageops::FilterType::Lanczos3); + if fitted.width() == width && fitted.height() == height { + return fitted; + } + + let mut canvas = image::RgbaImage::new(width, height); + image::imageops::overlay( + &mut canvas, + &fitted, + 0, + i64::from(height.saturating_sub(fitted.height())), + ); + DynamicImage::ImageRgba8(canvas) +} + +fn render_fallback(frame: &mut Frame, area: Rect, hidden_top: usize, alt: &str) { + let fallback = fallback_icon(); + render_raster(frame, area, &fallback, hidden_top); + if !alt.is_empty() && area.width > 8 { + let label_area = Rect { + x: area.x.saturating_add(7), + width: area.width.saturating_sub(7), + ..area + }; + frame.render_widget( + Paragraph::new(alt.to_owned()).style(Style::default().fg(THEME.as_ref().text_dim())), + label_area, + ); + } +} + +fn render_raster(frame: &mut Frame, area: Rect, raster: &[Vec], hidden_top: usize) { + for (visible_row, cells) in raster + .iter() + .skip(hidden_top) + .take(usize::from(area.height)) + .enumerate() + { + let line = Line::from( + cells + .iter() + .map(|cell| { + Span::styled( + cell.symbol.to_string(), + Style::default() + .fg(Color::Rgb(cell.fg_r, cell.fg_g, cell.fg_b)) + .bg(Color::Rgb(cell.bg_r, cell.bg_g, cell.bg_b)), + ) + }) + .collect::>(), + ); + frame.render_widget( + line, + Rect { + x: area.x, + y: area.y.saturating_add(visible_row as u16), + width: area.width, + height: 1, + }, + ); + } +} + +#[cfg(test)] +#[path = "../tests/widgets/markdown.rs"] +mod tests; diff --git a/src/tui/widgets/mod.rs b/src/tui/widgets/mod.rs index 80d2694..c456866 100644 --- a/src/tui/widgets/mod.rs +++ b/src/tui/widgets/mod.rs @@ -11,6 +11,7 @@ pub mod account; pub mod content; pub mod instances; pub mod logs_viewer; +pub mod markdown; pub mod popups; pub mod screenshots_grid; pub mod search; diff --git a/src/tui/widgets/popups/base.rs b/src/tui/widgets/popups/base.rs index 6bba33d..4c95563 100644 --- a/src/tui/widgets/popups/base.rs +++ b/src/tui/widgets/popups/base.rs @@ -3,11 +3,11 @@ // so each popup type only worries about its inner area. use ratatui::{ - buffer::Buffer, + buffer::{Buffer, CellDiffOption}, layout::{Alignment, Rect}, style::{Color, Style}, - text::Line, - widgets::{Block, Clear, Widget}, + text::{Line, Span}, + widgets::{Block, Clear, Paragraph, Widget}, }; use crate::config::theme::BORDER_STYLE; @@ -25,6 +25,18 @@ pub struct PopupFrame<'a> { impl<'a> Widget for PopupFrame<'a> { fn render(self, area: Rect, buf: &mut Buffer) { + let covered_image_cells = area + .rows() + .flat_map(|row| row.columns()) + .filter(|position| { + buf.cell(*position).is_some_and(|cell| { + matches!(cell.diff_option, CellDiffOption::Skip) + || matches!(cell.diff_option, CellDiffOption::ForcedWidth(_)) + && cell.symbol().contains('\x1b') + }) + }) + .collect::>(); + // clear first so the popup doesn't layer on top of whatever was underneath Clear.render(area, buf); @@ -48,5 +60,37 @@ impl<'a> Widget for PopupFrame<'a> { let inner = block.inner(area); block.render(area, buf); (self.content)(inner, buf); + + // External image protocols can repaint unchanged terminal cells. Ratatui's + // AlwaysUpdate option keeps only the popup cells covering an image above it. + for position in covered_image_cells { + if let Some(cell) = buf.cell_mut(position) + && !matches!( + cell.diff_option, + CellDiffOption::Skip | CellDiffOption::ForcedWidth(_) + ) + { + cell.set_diff_option(CellDiffOption::AlwaysUpdate); + } + } } } + +pub fn render_summary(rows: &[(&str, &str)], area: Rect, buf: &mut Buffer) { + let theme = crate::config::theme::THEME.as_ref(); + let label_style = Style::default().fg(theme.text_dim()); + let lines = rows + .iter() + .map(|(label, value)| { + Line::from(vec![ + Span::styled(format!("{label}: "), label_style), + Span::styled(*value, Style::default().fg(theme.text())), + ]) + }) + .collect::>(); + Paragraph::new(lines).render(area, buf); +} + +#[cfg(test)] +#[path = "../../tests/widgets/popups/base.rs"] +mod tests; diff --git a/src/tui/widgets/popups/confirm.rs b/src/tui/widgets/popups/confirm.rs index ba6255a..bf08241 100644 --- a/src/tui/widgets/popups/confirm.rs +++ b/src/tui/widgets/popups/confirm.rs @@ -9,7 +9,7 @@ use ratatui::{ layout::Rect, style::{Modifier, Style}, text::{Line, Span}, - widgets::{Paragraph, Widget}, + widgets::{Paragraph, Widget, Wrap}, }; use crate::config::theme::THEME; @@ -37,22 +37,55 @@ pub enum ConfirmTarget { Content { name: String, path: std::path::PathBuf, + dependents: Vec, + }, + OrphanDependencies { + paths: Vec, }, } impl ConfirmTarget { fn title(&self) -> String { - format!(" Delete '{}' ", self.name()) + match self { + Self::OrphanDependencies { .. } => " Remove unused dependencies ".to_owned(), + _ => format!(" Delete '{}' ", self.name()), + } } - fn body(&self) -> &'static str { + fn body(&self) -> String { match self { - ConfirmTarget::Instance { .. } => "This will permanently remove the instance", - ConfirmTarget::Account { .. } => "This will permanently remove this account", + ConfirmTarget::Instance { .. } => { + "This will permanently remove the instance".to_owned() + } + ConfirmTarget::Account { .. } => "This will permanently remove this account".to_owned(), ConfirmTarget::ConfigProfile { .. } => { - "This will permanently remove this config profile" + "This will permanently remove this config profile".to_owned() + } + ConfirmTarget::Content { dependents, .. } if !dependents.is_empty() => { + format!( + "Still required by installed mods:\n{}\n! Deleting this dependency may break those mods.", + dependents + .iter() + .map(|name| format!("• {name}")) + .collect::>() + .join("\n") + ) } - ConfirmTarget::Content { .. } => "This will permanently remove the selected item", + ConfirmTarget::Content { .. } => { + "This will permanently remove the selected item".to_owned() + } + ConfirmTarget::OrphanDependencies { paths } => paths + .iter() + .map(|path| { + format!( + "• {}", + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("dependency") + ) + }) + .collect::>() + .join("\n"), } } @@ -62,6 +95,15 @@ impl ConfirmTarget { ConfirmTarget::Account { username, .. } => username, ConfirmTarget::ConfigProfile { profile } => profile, ConfirmTarget::Content { name, .. } => name, + ConfirmTarget::OrphanDependencies { .. } => "unused dependencies", + } + } + + fn confirm_label(&self) -> &'static str { + match self { + Self::Content { dependents, .. } if !dependents.is_empty() => " delete anyway", + Self::OrphanDependencies { .. } => " remove all", + _ => " confirm", } } } @@ -86,12 +128,25 @@ pub fn set_pending_instance_delete(name: impl Into) { } pub fn set_pending_content_delete(name: impl Into, path: impl Into) { + set_pending_managed_content_delete(name, path, Vec::new()); +} + +pub fn set_pending_managed_content_delete( + name: impl Into, + path: impl Into, + dependents: Vec, +) { set_pending(ConfirmTarget::Content { name: name.into(), path: path.into(), + dependents, }); } +pub fn set_pending_orphan_dependencies(paths: Vec) { + set_pending(ConfirmTarget::OrphanDependencies { paths }); +} + pub fn pending_target() -> Option { match CONFIRM_STATE.lock() { Ok(s) => s.target.clone(), @@ -112,20 +167,18 @@ pub fn clear_pending() { pub struct ConfirmPopup { title: String, - body: &'static str, + body: String, + confirm_label: &'static str, } impl ConfirmPopup { - pub fn new(title: impl Into, body: &'static str) -> Self { + pub fn for_target(target: &ConfirmTarget) -> Self { Self { - title: title.into(), - body, + title: target.title(), + body: target.body(), + confirm_label: target.confirm_label(), } } - - pub fn for_target(target: &ConfirmTarget) -> Self { - Self::new(target.title(), target.body()) - } } impl Widget for ConfirmPopup { @@ -136,14 +189,19 @@ impl Widget for ConfirmPopup { let title = Line::from(vec![Span::styled( self.title, Style::default() - .fg(theme.text_dim()) + .fg(theme.accent()) .add_modifier(Modifier::BOLD), )]); - let kb = keybind_line(&[("Enter", " confirm")]); + let kb = keybind_line(&[("Esc", " cancel"), ("Enter", self.confirm_label)]); let border_color = theme.text_dim(); let bg_color = theme.surface(); - let text_color = theme.text(); + let accent = theme.accent(); + let text = theme.text(); + let text_dim = theme.text_dim(); + let error = theme.error(); + let body = self.body; + let styled_list = body.contains("• "); let popup = PopupFrame { title, border_color, @@ -151,8 +209,26 @@ impl Widget for ConfirmPopup { keybinds: Some(kb), search_line: None, content: Box::new(move |inner, buf| { - Paragraph::new(self.body) - .style(Style::default().fg(text_color)) + let lines = body + .lines() + .map(|line| { + if let Some(value) = line.strip_prefix("• ") { + Line::from(vec![ + Span::styled("• ", Style::default().fg(accent)), + Span::styled(value.to_owned(), Style::default().fg(text)), + ]) + } else if line.starts_with('!') { + Line::styled(line.to_owned(), Style::default().fg(error)) + } else { + Line::styled( + line.to_owned(), + Style::default().fg(if styled_list { text_dim } else { text }), + ) + } + }) + .collect::>(); + Paragraph::new(lines) + .wrap(Wrap { trim: true }) .render(inner, buf); }), }; @@ -165,10 +241,12 @@ pub fn confirm_popup_area(frame_area: Rect, target: &ConfirmTarget) -> Rect { use super::word_wrap_size; use ratatui::layout::Constraint; const MAX_W: usize = 48; - let title_w = target.name().len() + 12; - let (body_w, _) = word_wrap_size(target.body(), MAX_W); - let inner_w = title_w.max(body_w).min(MAX_W); - let (_, lines) = word_wrap_size(target.body(), inner_w); + let body = target.body(); + let title_w = Span::raw(target.name()).width() + 12; + let footer_w = Span::raw(format!("[Esc] cancel [Enter]{}", target.confirm_label())).width(); + let (body_w, _) = word_wrap_size(&body, MAX_W); + let inner_w = title_w.max(body_w).max(footer_w).min(MAX_W); + let (_, lines) = word_wrap_size(&body, inner_w); let popup_w = ((inner_w + 2) as u16).min(frame_area.width.saturating_sub(4)); let popup_h = ((lines + 2) as u16).min(frame_area.height.saturating_sub(4)); frame_area.centered(Constraint::Length(popup_w), Constraint::Length(popup_h)) diff --git a/src/tui/widgets/popups/error.rs b/src/tui/widgets/popups/error.rs index 4ca8511..b38e9c2 100644 --- a/src/tui/widgets/popups/error.rs +++ b/src/tui/widgets/popups/error.rs @@ -13,7 +13,7 @@ use tracing::Level; use super::base::PopupFrame; use crate::config::SETTINGS; use crate::config::theme::THEME; -use crate::tui::error_buffer::ErrorEvent; +use crate::feedback::errors::ErrorEvent; pub struct ErrorPopup { pub event: ErrorEvent, @@ -96,112 +96,9 @@ pub fn popup_area(frame_area: Rect, message: &str, base_y: u16, elapsed_ms: u128 } #[cfg(test)] -mod render_tests { - use super::*; - use ratatui::Terminal; - use ratatui::backend::TestBackend; - use std::time::Instant; - use tracing::Level; - - use crate::tui::error_buffer::ErrorEvent; - - fn render(event: ErrorEvent, width: u16, height: u16) -> Terminal { - let backend = TestBackend::new(width, height); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|f| { - let popup = ErrorPopup::new(event); - f.render_widget(popup, f.area()); - }) - .unwrap(); - terminal - } - - fn event(level: Level, message: &str) -> ErrorEvent { - ErrorEvent { - id: 1, - level, - message: message.to_string(), - pushed_at: Instant::now(), - } - } - - #[test] - fn warn_level_renders() { - let term = render(event(Level::WARN, "Disk space low"), 40, 5); - insta::assert_snapshot!(term.backend()); - } - - #[test] - fn error_level_renders() { - let term = render(event(Level::ERROR, "Connection refused"), 40, 5); - insta::assert_snapshot!(term.backend()); - } - - // info-level events hit the catch-all `_` arm in the label match; previously - // there was no test covering it. - #[test] - fn info_level_renders() { - let term = render(event(Level::INFO, "Reloaded config"), 40, 5); - insta::assert_snapshot!(term.backend()); - } - - #[test] - fn long_message_wraps() { - let msg = "The Minecraft launcher could not reach the Mojang version manifest \ - after three retries. Check your network connection or proxy settings."; - let term = render(event(Level::ERROR, msg), 40, 10); - insta::assert_snapshot!(term.backend()); - } - - #[test] - fn narrow_frame_renders() { - let term = render(event(Level::WARN, "Short message"), 18, 5); - insta::assert_snapshot!(term.backend()); - } -} +#[path = "../../tests/widgets/popups/error/render.rs"] +mod render_tests; #[cfg(test)] -mod area_tests { - use super::popup_area; - use crate::config::SETTINGS; - use ratatui::layout::Rect; - - fn frame() -> Rect { - Rect::new(0, 0, 80, 24) - } - - #[test] - fn returns_none_after_dismiss_timeout() { - let past_dismiss = SETTINGS.ui.error_auto_dismiss_ms as u128 + 1; - assert!(popup_area(frame(), "msg", 0, past_dismiss).is_none()); - } - - #[test] - fn returns_some_inside_dismiss_window() { - assert!(popup_area(frame(), "msg", 0, 0).is_some()); - } - - #[test] - fn returns_none_when_vertical_room_too_small() { - // base_y = 22 leaves only height 24 - 22 - 1 = 1 row of usable space, - // less than the minimum 3 needed for border + content + border. - assert!(popup_area(frame(), "msg", 22, 0).is_none()); - } - - #[test] - fn clamps_popup_width_to_frame() { - // a wider-than-the-frame message gets clamped so the popup fits - // inside frame.width minus the right-edge padding (saturating_sub(4)). - let huge = "x".repeat(200); - let area = popup_area(frame(), &huge, 0, 0).unwrap(); - assert!(area.width <= frame().width.saturating_sub(4)); - } - - #[test] - fn anchors_popup_to_right_edge() { - let area = popup_area(frame(), "msg", 0, 0).unwrap(); - // popup_w is added to base_x to reach frame.width - 2 (right-edge gutter) - assert_eq!(area.x + area.width + 2, frame().width); - } -} +#[path = "../../tests/widgets/popups/error/area.rs"] +mod area_tests; diff --git a/src/tui/widgets/popups/import_modpack/mod.rs b/src/tui/widgets/popups/import_modpack/mod.rs index cbb14d4..7600c23 100644 --- a/src/tui/widgets/popups/import_modpack/mod.rs +++ b/src/tui/widgets/popups/import_modpack/mod.rs @@ -1,5 +1,16 @@ mod render; mod state; -pub use render::{popup_rect, render}; -pub use state::{ImportResult, ImportStep, ImportWizardState, handle_key, take_result}; +#[cfg(test)] +pub use render::render; +pub use render::{popup_rect, render_with_picker}; +pub use state::{ + ImportResult, ImportStep, ImportWizardState, drain, handle_discovery_click, handle_key, + has_version_popup, open, take_result, +}; + +#[cfg(test)] +#[path = "../../../tests/widgets/popups/import_modpack/support.rs"] +mod test_support; +#[cfg(test)] +pub(crate) use test_support::reset as reset_for_test; diff --git a/src/tui/widgets/popups/import_modpack/render.rs b/src/tui/widgets/popups/import_modpack/render.rs index 5508af5..1726ce7 100644 --- a/src/tui/widgets/popups/import_modpack/render.rs +++ b/src/tui/widgets/popups/import_modpack/render.rs @@ -1,20 +1,41 @@ // rendering for the modpack import wizard. same pattern as new_instance: // snapshot the state, pick the right step renderer, done. +use super::super::LoadState; use super::super::base::PopupFrame; -use super::super::new_instance::LoadState; -use super::state::{IMPORT_STATE, ImportStep, ImportWizardState}; -use crate::config::theme::THEME; +use super::state::{DISCOVERY_STATE, IMPORT_STATE, ImportStep, ImportWizardState}; +use crate::config::theme::{BORDER_STYLE, THEME}; use crate::tui::app::FocusedArea; use ratatui::{ Frame, layout::{Constraint, Direction, Layout, Rect}, style::{Modifier, Style}, text::{Line, Span}, - widgets::{List, ListItem, ListState, Paragraph, StatefulWidget, Widget, Wrap}, + widgets::{ + Block, Borders, Clear, List, ListItem, ListState, Paragraph, StatefulWidget, Widget, Wrap, + }, }; -pub fn render(frame: &mut Frame, area: Rect, _focused: FocusedArea) { +#[cfg(test)] +pub fn render(frame: &mut Frame, area: Rect, focused: FocusedArea) { + render_inner(frame, area, focused, None); +} + +pub fn render_with_picker( + frame: &mut Frame, + area: Rect, + focused: FocusedArea, + picker: &ratatui_image::picker::Picker, +) { + render_inner(frame, area, focused, Some(picker)); +} + +fn render_inner( + frame: &mut Frame, + area: Rect, + _focused: FocusedArea, + picker: Option<&ratatui_image::picker::Picker>, +) { let snapshot = match IMPORT_STATE.lock() { Ok(state) => state.clone(), Err(e) => { @@ -22,6 +43,12 @@ pub fn render(frame: &mut Frame, area: Rect, _focused: FocusedArea) { ImportWizardState::default() } }; + if snapshot.step == ImportStep::Discover { + if let Some(picker) = picker { + render_discovery(frame, area, picker); + } + return; + } let keybinds = step_keybinds(&snapshot); @@ -45,6 +72,7 @@ pub fn render(frame: &mut Frame, area: Rect, _focused: FocusedArea) { .split(popup_area); match snapshot.step { + ImportStep::Discover => {} ImportStep::Input => render_input_step(&snapshot, chunks[0], buf), ImportStep::Fetching => render_fetching_step(chunks[0], buf), ImportStep::Version => render_version_step(&snapshot, chunks[0], buf), @@ -64,6 +92,9 @@ pub fn popup_rect(frame_area: Rect) -> Rect { }; match step { + ImportStep::Discover => { + frame_area.centered(Constraint::Percentage(80), Constraint::Percentage(80)) + } ImportStep::Input => { let h = 8u16.min(frame_area.height.saturating_sub(4)); frame_area.centered(w, Constraint::Length(h)) @@ -79,12 +110,57 @@ pub fn popup_rect(frame_area: Rect) -> Rect { frame_area.centered(w, Constraint::Length(h)) } ImportStep::Confirm => { - let h = 10u16.min(frame_area.height.saturating_sub(4)); + let h = 8u16.min(frame_area.height.saturating_sub(4)); frame_area.centered(w, Constraint::Length(h)) } } } +fn render_discovery(frame: &mut Frame, area: Rect, picker: &ratatui_image::picker::Picker) { + let theme = THEME.as_ref(); + Clear.render(area, frame.buffer_mut()); + let Ok(mut state) = DISCOVERY_STATE.lock() else { + return; + }; + let keybinds = discovery_keybinds(state.project_page_open()); + let mut block = Block::default() + .title(crate::tui::widgets::styled_title("Browse Modpacks", false)) + .title_bottom( + super::super::keybind_line(keybinds).alignment(ratatui::layout::Alignment::Right), + ) + .borders(Borders::ALL) + .border_type(BORDER_STYLE.to_border_type()) + .border_style(Style::default().fg(theme.accent())) + .style(Style::default().bg(theme.surface())); + if let Some(search_line) = state.search.title_line() { + block = block.title_top(search_line); + } + let inner = block.inner(area); + frame.render_widget(block, area); + crate::tui::widgets::content::tabs::render_discovery_popup(frame, inner, &mut state, picker); +} + +fn discovery_keybinds(project_page_open: bool) -> &'static [(&'static str, &'static str)] { + if project_page_open { + &[ + ("j/k", " scroll"), + ("g/G", " top/bottom"), + ("v", " versions"), + ("h", " back"), + ] + } else { + &[ + ("j/k", " navigate"), + (" [/] ", " pages"), + ("Enter", " view"), + ("v", " versions"), + ("/", " search"), + ("i", " import"), + ("Esc", " close"), + ] + } +} + fn render_input_step(state: &ImportWizardState, area: Rect, buf: &mut ratatui::buffer::Buffer) { let theme = THEME.as_ref(); let chunks = Layout::default() @@ -150,10 +226,10 @@ fn render_version_step(state: &ImportWizardState, area: Rect, buf: &mut ratatui: .map(|version| { let game_ver = version.game_versions.first().cloned().unwrap_or_default(); let loader = version.loaders.first().cloned().unwrap_or_default(); - ListItem::new(Line::from(Span::styled( - format!("{} {} {}", version.version_number, game_ver, loader), + ListItem::new(state.version_search.highlight_line( + &format!("{} {} {}", version.version_number, game_ver, loader), Style::default().fg(theme.text()), - ))) + )) }) .collect(); @@ -222,172 +298,31 @@ fn render_confirm_step(state: &ImportWizardState, area: Rect, buf: &mut ratatui: .render(area, buf); } -fn wizard_title(_state: &ImportWizardState) -> Line<'static> { +fn wizard_title(state: &ImportWizardState) -> Line<'static> { use crate::tui::widgets::styled_title; - styled_title("Import Modpack", false) + styled_title( + if state.step == ImportStep::Confirm { + "Install Modpack" + } else { + "Import Modpack" + }, + false, + ) } fn step_keybinds(state: &ImportWizardState) -> Line<'static> { use super::super::keybind_line; match state.step { - ImportStep::Input => keybind_line(&[("Enter", " fetch")]), + ImportStep::Discover => Line::default(), + ImportStep::Input => keybind_line(&[("Enter", " fetch"), ("Esc", " back")]), ImportStep::Fetching => keybind_line(&[("Esc", " cancel")]), ImportStep::Version => { keybind_line(&[("/", " search"), ("h", " back"), ("Enter", " select")]) } - ImportStep::Confirm => keybind_line(&[("h", " back"), ("Enter", " import")]), + ImportStep::Confirm => keybind_line(&[("h", " back"), ("Enter", " install")]), } } #[cfg(test)] -mod tests { - use super::*; - use ratatui::Terminal; - use ratatui::backend::TestBackend; - - // serialise against parallel tests of the same global IMPORT_STATE. - static TEST_SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); - - fn reset_import_state(step: ImportStep) { - let mut guard = IMPORT_STATE.lock().expect("IMPORT_STATE lock"); - *guard = ImportWizardState::default(); - guard.step = step; - } - - #[test] - fn import_modpack_renders_input_step() { - let _serial = TEST_SERIAL.lock().unwrap_or_else(|e| e.into_inner()); - reset_import_state(ImportStep::Input); - - let backend = TestBackend::new(60, 12); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|f| render(f, f.area(), FocusedArea::ImportPopup)) - .unwrap(); - insta::assert_snapshot!(terminal.backend()); - } - - #[test] - fn import_modpack_renders_fetching_step() { - let _serial = TEST_SERIAL.lock().unwrap_or_else(|e| e.into_inner()); - reset_import_state(ImportStep::Fetching); - - let backend = TestBackend::new(60, 12); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|f| render(f, f.area(), FocusedArea::ImportPopup)) - .unwrap(); - insta::assert_snapshot!(terminal.backend()); - } - - // Version step: pre-populate versions as LoadState::Loaded with synthetic - // VersionInfo entries so render walks the list path without triggering - // any network helpers. - #[test] - fn import_modpack_renders_version_step() { - use crate::net::modrinth::VersionInfo; - - let _serial = TEST_SERIAL.lock().unwrap_or_else(|e| e.into_inner()); - { - let mut guard = IMPORT_STATE.lock().expect("IMPORT_STATE lock"); - *guard = ImportWizardState::default(); - guard.step = ImportStep::Version; - guard.project_title = Some("Synthetic Pack".into()); - guard.versions = LoadState::Loaded(vec![ - VersionInfo { - id: "v1".into(), - name: "1.0.0".into(), - version_number: "1.0.0".into(), - game_versions: vec!["1.20.1".into()], - loaders: vec!["fabric".into()], - files: vec![], - }, - VersionInfo { - id: "v2".into(), - name: "0.9.0".into(), - version_number: "0.9.0".into(), - game_versions: vec!["1.20.1".into()], - loaders: vec!["fabric".into()], - files: vec![], - }, - ]); - } - - let backend = TestBackend::new(60, 14); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|f| render(f, f.area(), FocusedArea::ImportPopup)) - .unwrap(); - insta::assert_snapshot!(terminal.backend()); - } - - // Confirm step: needs a populated ImportSummary so the render path - // doesn't bail. ImportSummary is constructed manually with synthetic - // values; archive_path is a fake tempdir-ish path that never gets read. - #[test] - fn import_modpack_renders_confirm_step() { - use crate::instance::import::{ImportSummary, PackFormat}; - use crate::instance::models::ModLoader; - use std::path::PathBuf; - - let _serial = TEST_SERIAL.lock().unwrap_or_else(|e| e.into_inner()); - { - let mut guard = IMPORT_STATE.lock().expect("IMPORT_STATE lock"); - *guard = ImportWizardState::default(); - guard.step = ImportStep::Confirm; - guard.summary = Some(ImportSummary { - name: "Synthetic Pack".into(), - pack_version: "1.0.0".into(), - game_version: "1.20.1".into(), - loader: ModLoader::Fabric, - loader_version: Some("0.15.0".into()), - mod_count: 42, - override_count: 3, - format: PackFormat::Mrpack, - archive_path: PathBuf::from("/tmp/synthetic.mrpack"), - }); - } - - let backend = TestBackend::new(60, 14); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|f| render(f, f.area(), FocusedArea::ImportPopup)) - .unwrap(); - insta::assert_snapshot!(terminal.backend()); - } - - // Confirm step with loader_version=None: covers the branch where the - // pack didn't declare a loader version (rare upstream, but happens for - // older mmc packs). render_confirm_step has to handle the Option. - #[test] - fn import_modpack_renders_confirm_step_without_loader_version() { - use crate::instance::import::{ImportSummary, PackFormat}; - use crate::instance::models::ModLoader; - use std::path::PathBuf; - - let _serial = TEST_SERIAL.lock().unwrap_or_else(|e| e.into_inner()); - { - let mut guard = IMPORT_STATE.lock().expect("IMPORT_STATE lock"); - *guard = ImportWizardState::default(); - guard.step = ImportStep::Confirm; - guard.summary = Some(ImportSummary { - name: "Vanilla Pack".into(), - pack_version: "2.0".into(), - game_version: "1.20.1".into(), - loader: ModLoader::Vanilla, - loader_version: None, - mod_count: 0, - override_count: 12, - format: PackFormat::Mmc, - archive_path: PathBuf::from("/tmp/vanilla.zip"), - }); - } - - let backend = TestBackend::new(60, 14); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|f| render(f, f.area(), FocusedArea::ImportPopup)) - .unwrap(); - insta::assert_snapshot!(terminal.backend()); - } -} +#[path = "../../../tests/widgets/popups/import_modpack/render.rs"] +mod tests; diff --git a/src/tui/widgets/popups/import_modpack/state.rs b/src/tui/widgets/popups/import_modpack/state.rs index 9398ec9..86de361 100644 --- a/src/tui/widgets/popups/import_modpack/state.rs +++ b/src/tui/widgets/popups/import_modpack/state.rs @@ -1,15 +1,15 @@ // state machine for the modpack import wizard. -// accepts modrinth URLs, project slugs, version IDs, or local pack archives -// (.mrpack, mmc/prism zips). remote packs go through version selection, -// local files skip straight to the confirm step. +// browses modpacks from configured providers and keeps the archive import flow +// for modrinth URLs, project slugs, version IDs, and local pack archives. -use super::super::new_instance::LoadState; +use super::super::LoadState; use crate::instance::import::{ImportInput, ImportSummary, parse_import_input}; use crate::net::modrinth::{self, VersionInfo}; use crate::tui::widgets::instances; use crate::tui::widgets::search::SearchState; use crossterm::event::{KeyCode, KeyEvent}; use std::sync::LazyLock; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Instant; use tracing::Level; @@ -18,6 +18,9 @@ pub(super) static IMPORT_STATE: LazyLock>> = LazyLock::new(|| Arc::new(Mutex::new(ImportWizardState::default()))); pub(super) static IMPORT_RESULT: LazyLock>>> = LazyLock::new(|| Arc::new(Mutex::new(None))); +pub(super) static DISCOVERY_STATE: LazyLock> = + LazyLock::new(|| Mutex::new(crate::tui::widgets::content::DiscoveryState::new_modpacks())); +static NEXT_IMPORT_REQUEST_ID: AtomicU64 = AtomicU64::new(1); #[derive(Debug, Clone)] pub struct ImportResult { @@ -27,6 +30,7 @@ pub struct ImportResult { #[derive(Debug, Default, Clone, PartialEq)] pub enum ImportStep { #[default] + Discover, Input, Fetching, Version, @@ -42,18 +46,22 @@ pub struct ImportWizardState { pub version_idx: usize, pub version_search: SearchState, pub summary: Option, + pub from_discovery: bool, + request_id: u64, } impl Default for ImportWizardState { fn default() -> Self { Self { - step: ImportStep::Input, + step: ImportStep::Discover, input: String::new(), project_title: None, versions: LoadState::Idle, version_idx: 0, version_search: SearchState::default(), summary: None, + from_discovery: false, + request_id: 0, } } } @@ -73,8 +81,14 @@ pub fn handle_key(key_event: &KeyEvent, instances_state: &mut instances::State) return; } }; + if state.step == ImportStep::Discover { + drop(state); + handle_discovery_key(key_event, instances_state); + return; + } match state.step { + ImportStep::Discover => unreachable!(), ImportStep::Input => handle_input_key(&mut state, key_event, instances_state), ImportStep::Fetching => handle_fetching_key(&mut state, key_event, instances_state), ImportStep::Version => handle_version_key(&mut state, key_event, instances_state), @@ -82,6 +96,137 @@ pub fn handle_key(key_event: &KeyEvent, instances_state: &mut instances::State) } } +pub fn open() { + if let Ok(mut wizard) = IMPORT_STATE.lock() { + wizard.reset(); + } + #[cfg(not(test))] + start_discovery_search(); +} + +pub fn drain(picker: &ratatui_image::picker::Picker) { + if let Ok(mut state) = DISCOVERY_STATE.lock() { + state.drain_pending(); + state.list.drain_pending(); + state.list.request_image_loads(picker); + state.list.drain_image_loads(picker); + if state.search_due() { + drop(state); + start_discovery_search(); + } + } +} + +pub fn has_version_popup() -> bool { + DISCOVERY_STATE + .lock() + .is_ok_and(|state| state.version_popup.is_some()) +} + +pub fn handle_discovery_click(x: u16, y: u16) -> bool { + if !IMPORT_STATE + .lock() + .is_ok_and(|state| state.step == ImportStep::Discover) + { + return false; + } + let (handled, request) = match DISCOVERY_STATE.lock() { + Ok(mut discovery) => { + if discovery.search.active + || discovery.project_page_open() + || discovery.version_popup.is_some() + { + return false; + } + let handled = discovery.list.click_page(x, y); + let request = handled.then(|| discovery.begin_next_page()).flatten(); + (handled, request) + } + Err(_) => return false, + }; + if let Some(request) = request { + spawn_discovery_request(request); + } + handled +} + +fn handle_discovery_key(key_event: &KeyEvent, instances_state: &mut instances::State) { + let mut discovery = match DISCOVERY_STATE.lock() { + Ok(state) => state, + Err(_) => return, + }; + let search_active = discovery.search.active; + let popup_open = discovery.version_popup.is_some(); + let project_page_open = discovery.project_page_open(); + match key_event.code { + KeyCode::Esc if !search_active && !popup_open && !project_page_open => { + drop(discovery); + if let Ok(mut state) = IMPORT_STATE.lock() { + close_popup(&mut state, instances_state); + } + } + KeyCode::Char('i') if !search_active && !popup_open && !project_page_open => { + drop(discovery); + if let Ok(mut state) = IMPORT_STATE.lock() { + state.step = ImportStep::Input; + } + } + KeyCode::Enter if !search_active && !popup_open && !project_page_open => { + let request = discovery.begin_project_page(); + drop(discovery); + if let Some(request) = request { + crate::tui::widgets::content::discovery::spawn_project_page(request); + } + } + KeyCode::Char('v') if !search_active && !popup_open => { + let request = discovery.begin_versions(); + drop(discovery); + if let Some(request) = request { + spawn_versions(request); + } + } + KeyCode::Tab if popup_open => { + let request = discovery.switch_version_source(); + drop(discovery); + if let Some(request) = request { + spawn_versions(request); + } + } + KeyCode::Enter if popup_open => { + if discovery.select_minecraft_version() { + return; + } + let request = take_discovered_version(&mut discovery); + drop(discovery); + if let Some(request) = request { + start_discovered_download(request); + } + } + _ => { + let navigate = + matches!( + key_event.code, + KeyCode::Char('j') | KeyCode::Char('k') | KeyCode::Down | KeyCode::Up + ) || crate::tui::widgets::content::discovery::page_key_direction(key_event) + .is_some(); + crate::tui::widgets::content::discovery::handle_key(key_event, &mut discovery); + let request = navigate.then(|| discovery.begin_next_page()).flatten(); + drop(discovery); + if let Some(request) = request { + spawn_discovery_request(request); + } + } + } +} + +fn take_discovered_version( + discovery: &mut crate::tui::widgets::content::DiscoveryState, +) -> Option { + discovery + .begin_confirmation() + .then(|| discovery.begin_install())? +} + pub fn take_result() -> Option { match IMPORT_RESULT.lock() { Ok(mut r) => r.take(), @@ -92,12 +237,12 @@ pub fn take_result() -> Option { fn handle_input_key( state: &mut ImportWizardState, key_event: &KeyEvent, - instances_state: &mut instances::State, + _instances_state: &mut instances::State, ) { match key_event.code { - KeyCode::Esc => close_popup(state, instances_state), + KeyCode::Esc => state.step = ImportStep::Discover, KeyCode::Backspace => { - state.input.pop(); + crate::tui::widgets::search::backspace(&mut state.input, key_event.modifiers); } KeyCode::Enter => { if state.input.trim().is_empty() { @@ -135,7 +280,7 @@ fn handle_version_key( return; } KeyCode::Backspace => { - state.version_search.pop(); + state.version_search.backspace(key_event.modifiers); clamp_version_index(state); return; } @@ -196,6 +341,8 @@ fn handle_confirm_key( KeyCode::Left | KeyCode::Char('h') => { if matches!(state.versions, LoadState::Loaded(_)) { state.step = ImportStep::Version; + } else if state.from_discovery { + state.step = ImportStep::Discover; } else { state.step = ImportStep::Input; } @@ -222,18 +369,49 @@ fn handle_confirm_key( } // pushes an error toast and rewinds the wizard to a previous step -fn set_error_and_back(state_arc: &Arc>, msg: String, step: ImportStep) { - push_import_error(msg); - if let Ok(mut s) = state_arc.lock() { - s.step = step; +fn update_current_request( + state_arc: &Arc>, + request_id: u64, + update: impl FnOnce(&mut ImportWizardState), +) -> bool { + if let Ok(mut state) = state_arc.lock() + && state.request_id == request_id + { + update(&mut state); + return true; + } + false +} + +fn set_error_and_back( + state_arc: &Arc>, + request_id: u64, + msg: String, + step: ImportStep, +) { + if update_current_request(state_arc, request_id, |state| state.step = step) { + push_import_error(msg); } } +fn begin_import_request(state: &mut ImportWizardState) -> u64 { + let request_id = NEXT_IMPORT_REQUEST_ID.fetch_add(1, Ordering::Relaxed); + state.request_id = request_id; + request_id +} + // parses user input to figure out what they gave us, then dispatches // to the appropriate resolve path (slug lookup, direct version, or local file) fn start_resolve(state: &mut ImportWizardState) { let input_text = state.input.clone(); state.step = ImportStep::Fetching; + state.from_discovery = false; + state.project_title = None; + state.versions = LoadState::Idle; + state.version_idx = 0; + state.version_search.deactivate(); + state.summary = None; + let request_id = begin_import_request(state); let state_arc = IMPORT_STATE.clone(); @@ -243,16 +421,16 @@ fn start_resolve(state: &mut ImportWizardState) { match parsed { ImportInput::ProjectSlug(slug) => { - resolve_project_slug(state_arc, &client, &slug).await; + resolve_project_slug(state_arc, request_id, &client, &slug).await; } ImportInput::VersionId { slug: _, version_id, } => { - resolve_version_id(state_arc, &client, &version_id).await; + resolve_version_id(state_arc, request_id, &client, &version_id).await; } ImportInput::LocalFile(path) => { - resolve_local_file(state_arc, &path); + resolve_local_file(state_arc, request_id, &path); } } }); @@ -260,28 +438,31 @@ fn start_resolve(state: &mut ImportWizardState) { async fn resolve_project_slug( state_arc: Arc>, + request_id: u64, client: &crate::net::HttpClient, slug: &str, ) { match modrinth::fetch_project(client, slug).await { Ok(project) => match modrinth::fetch_versions(client, slug).await { Ok(versions) => { - if let Ok(mut s) = state_arc.lock() { - s.project_title = Some(project.title); - s.versions = LoadState::Loaded(versions); - s.version_idx = 0; - s.version_search.deactivate(); - s.step = ImportStep::Version; - } + update_current_request(&state_arc, request_id, |state| { + state.project_title = Some(project.title); + state.versions = LoadState::Loaded(versions); + state.version_idx = 0; + state.version_search.deactivate(); + state.step = ImportStep::Version; + }); } Err(e) => set_error_and_back( &state_arc, + request_id, format!("Failed to fetch versions: {}", e), ImportStep::Input, ), }, Err(e) => set_error_and_back( &state_arc, + request_id, format!("Failed to fetch project: {}", e), ImportStep::Input, ), @@ -290,16 +471,18 @@ async fn resolve_project_slug( async fn resolve_version_id( state_arc: Arc>, + request_id: u64, client: &crate::net::HttpClient, version_id: &str, ) { match modrinth::fetch_version(client, version_id).await { Ok(version) => { let meta_dir = crate::config::SETTINGS.paths.resolve_meta_dir(); - let tmp_dir = meta_dir.join("tmp"); + let tmp_dir = crate::storage::MetadataPaths::new(&meta_dir).temporary(); if let Err(e) = tokio::fs::create_dir_all(&tmp_dir).await { set_error_and_back( &state_arc, + request_id, format!("Failed to create tmp dir: {}", e), ImportStep::Input, ); @@ -309,19 +492,21 @@ async fn resolve_version_id( match modrinth::download_mrpack(client, &version, &tmp_dir).await { Ok(mrpack_path) => match crate::instance::import::build_summary(&mrpack_path) { Ok(summary) => { - if let Ok(mut s) = state_arc.lock() { - s.summary = Some(summary); - s.step = ImportStep::Confirm; - } + update_current_request(&state_arc, request_id, |state| { + state.summary = Some(summary); + state.step = ImportStep::Confirm; + }); } Err(e) => set_error_and_back( &state_arc, + request_id, format!("Failed to build summary: {}", e), ImportStep::Input, ), }, Err(e) => set_error_and_back( &state_arc, + request_id, format!("Failed to download mrpack: {}", e), ImportStep::Input, ), @@ -329,24 +514,26 @@ async fn resolve_version_id( } Err(e) => set_error_and_back( &state_arc, + request_id, format!("Failed to fetch version: {}", e), ImportStep::Input, ), } } -fn resolve_local_file(state_arc: Arc>, path: &str) { +fn resolve_local_file(state_arc: Arc>, request_id: u64, path: &str) { let resolved = crate::config::settings::resolve_path(path); match crate::instance::import::build_summary(&resolved) { Ok(summary) => { - if let Ok(mut s) = state_arc.lock() { - s.summary = Some(summary); - s.step = ImportStep::Confirm; - } + update_current_request(&state_arc, request_id, |state| { + state.summary = Some(summary); + state.step = ImportStep::Confirm; + }); } Err(e) => set_error_and_back( &state_arc, + request_id, format!("Failed to parse pack: {}", e), ImportStep::Input, ), @@ -362,16 +549,18 @@ fn start_version_download(state: &mut ImportWizardState) { }; state.step = ImportStep::Fetching; + let request_id = begin_import_request(state); let state_arc = IMPORT_STATE.clone(); tokio::spawn(async move { let client = crate::net::HttpClient::new(); let meta_dir = crate::config::SETTINGS.paths.resolve_meta_dir(); - let tmp_dir = meta_dir.join("tmp"); + let tmp_dir = crate::storage::MetadataPaths::new(&meta_dir).temporary(); if let Err(e) = tokio::fs::create_dir_all(&tmp_dir).await { set_error_and_back( &state_arc, + request_id, format!("Failed to create tmp dir: {}", e), ImportStep::Version, ); @@ -381,19 +570,21 @@ fn start_version_download(state: &mut ImportWizardState) { match modrinth::download_mrpack(&client, &version, &tmp_dir).await { Ok(mrpack_path) => match crate::instance::import::build_summary(&mrpack_path) { Ok(summary) => { - if let Ok(mut s) = state_arc.lock() { - s.summary = Some(summary); - s.step = ImportStep::Confirm; - } + update_current_request(&state_arc, request_id, |state| { + state.summary = Some(summary); + state.step = ImportStep::Confirm; + }); } Err(e) => set_error_and_back( &state_arc, + request_id, format!("Failed to build summary: {}", e), ImportStep::Version, ), }, Err(e) => set_error_and_back( &state_arc, + request_id, format!("Failed to download mrpack: {}", e), ImportStep::Version, ), @@ -401,13 +592,133 @@ fn start_version_download(state: &mut ImportWizardState) { }); } +fn start_discovery_search() { + let (query, request) = match DISCOVERY_STATE.lock() { + Ok(mut state) => (state.search.query.clone(), state.begin_modpack_search()), + Err(_) => return, + }; + spawn_discovery_request_with_query(query, request); +} + +fn spawn_discovery_request(request: crate::tui::widgets::content::discovery::DiscoveryRequest) { + let query = DISCOVERY_STATE + .lock() + .map(|state| state.search.query.clone()) + .unwrap_or_default(); + spawn_discovery_request_with_query(query, request); +} + +fn spawn_discovery_request_with_query( + query: String, + request: crate::tui::widgets::content::discovery::DiscoveryRequest, +) { + crate::tui::widgets::content::discovery::spawn_provider_search( + query, + crate::tui::widgets::content::discovery::DiscoveryTarget::Modpacks, + crate::config::SETTINGS.paths.resolve_meta_dir(), + request, + ); +} + +fn spawn_versions(request: crate::tui::widgets::content::discovery::VersionsRequest) { + tokio::spawn(async move { + let client = crate::net::HttpClient::new(); + let result = match request.provider.as_str() { + "curseforge" => match crate::net::curseforge::api_key() { + Some(api_key) => { + crate::net::curseforge::fetch_versions( + &client, + api_key, + &request.project_id, + "", + None, + ) + .await + } + None => Err(crate::net::NetError::Parse( + "CurseForge API key is not configured".to_owned(), + )), + }, + _ => crate::net::modrinth::fetch_versions(&client, &request.project_id).await, + } + .map_err(|error| error.to_string()); + crate::tui::widgets::content::DiscoveryState::push_action_result( + &request.pending, + crate::tui::widgets::content::discovery::DiscoveryActionResult::Versions { + request_id: request.request_id, + project_id: request.project_id, + result, + }, + ); + }); +} + +fn start_discovered_download(request: crate::tui::widgets::content::discovery::InstallRequest) { + let request_id = if let Ok(mut state) = IMPORT_STATE.lock() { + state.step = ImportStep::Fetching; + state.from_discovery = true; + begin_import_request(&mut state) + } else { + return; + }; + tokio::spawn(async move { + let client = crate::net::HttpClient::new(); + let registry = crate::instance::content::provider::ProviderRegistry::configured(client); + let tmp_dir = + crate::storage::MetadataPaths::new(crate::config::SETTINGS.paths.resolve_meta_dir()) + .temporary(); + let result: Result = async { + let source = crate::instance::ProviderProject { + provider: request.provider.clone(), + project_id: request.project_id.clone(), + version_id: request.version.id.clone(), + }; + tokio::fs::create_dir_all(&tmp_dir).await?; + let provider = registry.get(&request.provider).ok_or_else(|| { + crate::net::NetError::Parse(format!( + "{} content provider is unavailable", + request.provider + )) + })?; + let outcome = provider + .download_version(&request.version, &tmp_dir, None) + .await?; + let path = match outcome { + crate::net::modrinth::DownloadOutcome::Downloaded(path) + | crate::net::modrinth::DownloadOutcome::SkippedExisting(path) => path, + }; + let mut summary = crate::instance::import::build_summary(&path) + .map_err(crate::net::NetError::Parse)?; + summary.source = Some(source); + Ok(summary) + } + .await; + match result { + Ok(summary) => { + update_current_request(&IMPORT_STATE, request_id, |state| { + state.summary = Some(summary); + state.step = ImportStep::Confirm; + }); + } + Err(error) => { + if update_current_request(&IMPORT_STATE, request_id, |state| { + state.step = ImportStep::Discover; + }) { + push_import_error(format!("Failed to prepare modpack: {error}")); + } + } + } + crate::feedback::request_redraw(); + }); +} + fn close_popup(state: &mut ImportWizardState, instances_state: &mut instances::State) { state.reset(); instances_state.show_import_popup = false; } fn push_import_error(msg: String) { - crate::tui::error_buffer::push_error(crate::tui::error_buffer::ErrorEvent { + crate::feedback::errors::push_error(crate::feedback::errors::ErrorEvent { id: 0, level: Level::ERROR, message: msg, @@ -465,3 +776,7 @@ fn clamp_version_index(state: &mut ImportWizardState) { state.version_idx = count.saturating_sub(1); } } + +#[cfg(test)] +#[path = "../../../tests/widgets/popups/import_modpack/state.rs"] +mod tests; diff --git a/src/tui/widgets/popups/load_state.rs b/src/tui/widgets/popups/load_state.rs new file mode 100644 index 0000000..66179fc --- /dev/null +++ b/src/tui/widgets/popups/load_state.rs @@ -0,0 +1,8 @@ +#[derive(Debug, Clone, Default)] +pub enum LoadState { + #[default] + Idle, + Loading, + Loaded(T), + Error(String), +} diff --git a/src/tui/widgets/popups/mod.rs b/src/tui/widgets/popups/mod.rs index 857ed67..b3c8836 100644 --- a/src/tui/widgets/popups/mod.rs +++ b/src/tui/widgets/popups/mod.rs @@ -5,9 +5,31 @@ pub mod base; pub mod confirm; pub mod error; pub mod import_modpack; +mod load_state; +pub mod modpack_update; pub mod new_instance; -use ratatui::layout::Rect; +pub use load_state::LoadState; + +use ratatui::{layout::Rect, text::Span}; + +pub(crate) fn compare_game_versions(a: &str, b: &str) -> std::cmp::Ordering { + let parse_parts = |version: &str| { + version + .split('.') + .map(|part| part.parse::().unwrap_or(0)) + .collect::>() + }; + let a_parts = parse_parts(a); + let b_parts = parse_parts(b); + for (a, b) in a_parts.iter().zip(&b_parts) { + match a.cmp(b) { + std::cmp::Ordering::Equal => {} + ordering => return ordering, + } + } + a_parts.len().cmp(&b_parts.len()) +} // figures out the (width, height) a text block will need after word wrapping. // used to size popups before rendering so they fit their content snugly. @@ -16,23 +38,25 @@ pub fn word_wrap_size(text: &str, max_inner_width: usize) -> (usize, usize) { return (0, 1); } - let mut lines: usize = 1; - let mut current_line_len: usize = 0; let mut widest_line: usize = 0; - - for word in text.split_whitespace() { - let word_len = word.len().min(max_inner_width); - if current_line_len == 0 { - current_line_len = word_len; - } else if current_line_len + 1 + word_len <= max_inner_width { - current_line_len += 1 + word_len; - } else { - widest_line = widest_line.max(current_line_len); - lines += 1; - current_line_len = word_len; + let mut lines = 0; + for logical_line in text.split('\n') { + let mut current_line_len = 0; + lines += 1; + for word in logical_line.split_whitespace() { + let word_len = Span::raw(word).width().min(max_inner_width); + if current_line_len == 0 { + current_line_len = word_len; + } else if current_line_len + 1 + word_len <= max_inner_width { + current_line_len += 1 + word_len; + } else { + widest_line = widest_line.max(current_line_len); + lines += 1; + current_line_len = word_len; + } } + widest_line = widest_line.max(current_line_len); } - widest_line = widest_line.max(current_line_len); (widest_line, lines) } @@ -77,57 +101,21 @@ pub fn keybind_line(binds: &[(&str, &str)]) -> ratatui::text::Line<'static> { Line::from(spans) } -// same as keybind_line but wraps to multiple rows when the popup is too narrow -// to fit everything on one line -pub fn keybind_lines_wrapped( - binds: &[(&str, &str)], - max_width: u16, -) -> Vec> { - use crate::config::theme::THEME; - use ratatui::{ - style::{Modifier, Style}, - text::{Line, Span}, - }; - let theme = THEME.as_ref(); - let key_style = Style::default() - .fg(theme.accent()) - .add_modifier(Modifier::BOLD); - let label_style = Style::default().fg(theme.text()); - - let mut rows: Vec> = Vec::new(); - let mut current_spans: Vec> = Vec::new(); - let mut current_width: usize = 0; - - for (i, (key, label)) in binds.iter().enumerate() { - let sep_w = if i > 0 && !current_spans.is_empty() { - 2 - } else { - 0 - }; - let item_w = key.len() + 2 + label.len(); - let needed = sep_w + item_w; - - if !current_spans.is_empty() && current_width + needed > max_width as usize { - rows.push(Line::from(current_spans).right_aligned()); - current_spans = Vec::new(); - current_width = 0; - } - - if !current_spans.is_empty() { - current_spans.push(Span::styled(" ", label_style)); - current_width += 2; - } - - current_spans.push(Span::styled(format!("[{}]", key), key_style)); - if !label.is_empty() { - current_spans.push(Span::styled(label.to_string(), label_style)); +pub fn keybind_line_fitted(binds: &[(&str, &str)], max_width: u16) -> ratatui::text::Line<'static> { + let mut width = 0; + let mut count = 0; + for (key, label) in binds { + let item_width = Span::raw(format!("[{key}]{label}")).width() + usize::from(count > 0) * 2; + if width + item_width > max_width as usize { + break; } - current_width += item_w; + width += item_width; + count += 1; } - if !current_spans.is_empty() { - rows.push(Line::from(current_spans).right_aligned()); - } - - rows + keybind_line(&binds[..count]).right_aligned() } + +#[cfg(test)] +#[path = "../../tests/widgets/popups/mod.rs"] +mod tests; diff --git a/src/tui/widgets/popups/modpack_update.rs b/src/tui/widgets/popups/modpack_update.rs new file mode 100644 index 0000000..29e1f8f --- /dev/null +++ b/src/tui/widgets/popups/modpack_update.rs @@ -0,0 +1,219 @@ +use std::collections::HashSet; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use ratatui::{ + Frame, + layout::{Constraint, Flex, Layout}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}, +}; + +use crate::config::theme::{BORDER_STYLE, THEME}; +use crate::instance::import::refresh::RefreshPlan; + +pub enum PendingResult { + Prepared(Box>), + Applied(Box>), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Phase { + Preparing, + Conflicts, + Review, + Applying, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Action { + Update, + Change, + Reinstall, +} + +pub struct State { + pub phase: Phase, + pub action: Action, + pub plan: Option, + pub selected: usize, + pub replace: Vec, + pub error: Option, + pub pending: Arc>>, + pub completed: Option, +} + +impl State { + pub fn preparing(action: Action) -> Self { + Self { + phase: Phase::Preparing, + action, + plan: None, + selected: 0, + replace: Vec::new(), + error: None, + pending: Arc::new(Mutex::new(Vec::new())), + completed: None, + } + } + + pub fn drain(&mut self) { + let pending = match self.pending.lock() { + Ok(mut pending) => pending.drain(..).collect::>(), + Err(_) => return, + }; + for result in pending { + match result { + PendingResult::Prepared(result) => match *result { + Ok(plan) => { + self.replace = vec![false; plan.conflicts.len()]; + self.phase = if plan.conflicts.is_empty() { + Phase::Review + } else { + Phase::Conflicts + }; + self.plan = Some(plan); + self.error = None; + } + Err(error) => { + self.phase = Phase::Review; + self.error = Some(error); + } + }, + PendingResult::Applied(result) => match *result { + Ok(instance) => self.completed = Some(instance), + Err(error) => { + self.phase = Phase::Review; + self.error = Some(error); + } + }, + } + } + } + + pub fn replacements(&self) -> HashSet { + self.plan + .as_ref() + .into_iter() + .flat_map(|plan| plan.conflicts.iter()) + .enumerate() + .filter(|(index, _)| self.replace.get(*index) == Some(&true)) + .map(|(_, path)| path.clone()) + .collect() + } +} + +pub fn render(frame: &mut Frame, state: &State) { + let theme = THEME.as_ref(); + let count = state + .plan + .as_ref() + .map_or(1, |plan| plan.conflicts.len().max(5)); + let [area] = Layout::vertical([Constraint::Length((count as u16 + 5).clamp(8, 20))]) + .flex(Flex::Center) + .areas(frame.area()); + let [area] = Layout::horizontal([Constraint::Percentage(62)]) + .flex(Flex::Center) + .areas(area); + frame.render_widget(Clear, area); + let action = match state.action { + Action::Update => "update", + Action::Change => "change", + Action::Reinstall => "reinstall", + }; + let (title, footer) = match state.phase { + Phase::Preparing => match state.action { + Action::Update => (" Prepare modpack update ", " [Esc] cancel "), + Action::Change => (" Prepare modpack change ", " [Esc] cancel "), + Action::Reinstall => (" Prepare modpack reinstall ", " [Esc] cancel "), + }, + Phase::Conflicts => ( + " Resolve modpack conflicts ", + " [j/k] select [Space] keep/replace [Enter] continue [Esc] cancel ", + ), + Phase::Review => match state.action { + Action::Update => (" Update modpack ", " [Enter] update [Esc] cancel "), + Action::Change => (" Change modpack ", " [Enter] change [Esc] cancel "), + Action::Reinstall => (" Reinstall modpack ", " [Enter] reinstall [Esc] cancel "), + }, + Phase::Applying => match state.action { + Action::Update => (" Updating modpack ", " Please wait "), + Action::Change => (" Changing modpack ", " Please wait "), + Action::Reinstall => (" Reinstalling modpack ", " Please wait "), + }, + }; + let block = Block::default() + .title(title) + .title_bottom(Line::from(footer).centered()) + .borders(Borders::ALL) + .border_type(BORDER_STYLE.to_border_type()) + .border_style(Style::default().fg(theme.accent())) + .style(Style::default().fg(theme.text()).bg(theme.surface())); + match state.phase { + Phase::Preparing => frame.render_widget( + Paragraph::new(format!("Preparing the selected modpack {action}…")).block(block), + area, + ), + Phase::Applying => frame.render_widget( + Paragraph::new("Preserving user files and activating the staged pack…").block(block), + area, + ), + Phase::Conflicts => { + let items = state + .plan + .as_ref() + .into_iter() + .flat_map(|plan| plan.conflicts.iter()) + .enumerate() + .map(|(index, path)| { + let action = if state.replace.get(index) == Some(&true) { + "Replace" + } else { + "Keep" + }; + ListItem::new(Line::from(vec![ + Span::raw(format!("• {} ", path.display())), + Span::styled(action, Style::default().fg(theme.accent())), + ])) + }); + let mut selected = ListState::default().with_selected(Some(state.selected)); + frame.render_stateful_widget( + List::new(items) + .block(block) + .highlight_symbol("▌") + .highlight_style(Style::default().bg(theme.stripe())), + area, + &mut selected, + ); + } + Phase::Review => { + let mut lines = state.plan.as_ref().map_or_else(Vec::new, |plan| { + vec![ + Line::from(vec![ + Span::styled( + plan.summary.name.clone(), + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(format!( + " {} → {}", + plan.current_version, plan.target_version + )), + ]), + Line::from(format!("Minecraft: {}", plan.summary.game_version)), + Line::from(format!( + "Loader: {} {}", + plan.summary.loader, + plan.summary.loader_version.as_deref().unwrap_or("") + )), + Line::from(format!("Pack files: {}", plan.summary.mod_count)), + Line::from(format!("Overrides: {}", plan.summary.override_count)), + ] + }); + if let Some(error) = &state.error { + lines.push(Line::from(error.clone()).style(Style::default().fg(theme.error()))); + } + frame.render_widget(Paragraph::new(lines).block(block), area); + } + } +} diff --git a/src/tui/widgets/popups/new_instance/mod.rs b/src/tui/widgets/popups/new_instance/mod.rs index 3d8b9b9..a3ac1fa 100644 --- a/src/tui/widgets/popups/new_instance/mod.rs +++ b/src/tui/widgets/popups/new_instance/mod.rs @@ -1,5 +1,13 @@ mod render; mod state; +pub use super::LoadState; +pub(crate) use render::render_select_list; pub use render::{popup_rect, render}; -pub use state::{LoadState, WizardParams, WizardState, WizardStep, handle_key, take_result}; +pub use state::{WizardParams, WizardState, WizardStep, handle_key, take_result}; + +#[cfg(test)] +#[path = "../../../tests/widgets/popups/new_instance/support.rs"] +mod test_support; +#[cfg(test)] +pub(crate) use test_support::reset as reset_for_test; diff --git a/src/tui/widgets/popups/new_instance/render.rs b/src/tui/widgets/popups/new_instance/render.rs index 5098bfc..16de9bc 100644 --- a/src/tui/widgets/popups/new_instance/render.rs +++ b/src/tui/widgets/popups/new_instance/render.rs @@ -1,14 +1,15 @@ // rendering for the new instance wizard. each step gets its own render fn // and the popup resizes itself based on which step is active. +use super::super::LoadState; use super::state::{ - LoadState, WIZARD_STATE, WizardState, WizardStep, clamp_loader_version_index, - clamp_version_index, ensure_loader_versions_loaded, ensure_versions_loaded, visible_versions, + WIZARD_STATE, WizardState, WizardStep, clamp_loader_version_index, clamp_version_index, + ensure_loader_versions_loaded, ensure_versions_loaded, visible_versions, }; use crate::config::theme::THEME; use crate::instance::models::ModLoader; use crate::tui::app::FocusedArea; -use crate::tui::widgets::popups::base::PopupFrame; +use crate::tui::widgets::popups::base::{PopupFrame, render_summary}; use ratatui::{ Frame, layout::{Constraint, Direction, Layout, Rect}, @@ -107,7 +108,7 @@ pub fn popup_rect(frame_area: Rect) -> Rect { frame_area.centered(w, Constraint::Length(h)) } WizardStep::Confirm => { - let h = 8u16.min(frame_area.height.saturating_sub(4)); + let h = 6u16.min(frame_area.height.saturating_sub(4)); frame_area.centered(w, Constraint::Length(h)) } } @@ -180,33 +181,47 @@ fn render_version_step(state: &WizardState, area: Rect, buf: &mut ratatui::buffe LoadState::Loaded(_) => { let items: Vec = visible_versions(state) .into_iter() - .map(|version| { + .enumerate() + .map(|(index, version)| { let suffix = if version.stable { String::new() } else { " (snapshot)".to_string() }; - ListItem::new(Line::from(Span::styled( - format!("{}{}", version.id, suffix), - Style::default().fg(theme.text()), - ))) + ListItem::new(state.version_search.highlight_line( + &format!("{}{}", version.id, suffix), + Style::default().fg(if index == state.version_idx { + theme.accent() + } else { + theme.text() + }), + )) }) .collect(); - let list = List::new(items) - .highlight_style( - Style::default() - .fg(theme.accent()) - .add_modifier(Modifier::BOLD), - ) - .highlight_symbol("▶ "); - - let mut list_state = ListState::default().with_selected(Some(state.version_idx)); - StatefulWidget::render(list, area, buf, &mut list_state); + render_select_list(items, state.version_idx, area, buf); } } } +pub(crate) fn render_select_list( + items: Vec>, + selected: usize, + area: Rect, + buffer: &mut ratatui::buffer::Buffer, +) { + let list = List::new(items) + .highlight_style(Style::default().add_modifier(Modifier::BOLD)) + .highlight_symbol(Span::styled( + "▶ ", + Style::default() + .fg(THEME.as_ref().accent()) + .add_modifier(Modifier::BOLD), + )); + let mut state = ListState::default().with_selected(Some(selected)); + StatefulWidget::render(list, area, buffer, &mut state); +} + fn render_loader_step(state: &WizardState, area: Rect, buf: &mut ratatui::buffer::Buffer) { let theme = THEME.as_ref(); let loaders = [ @@ -286,7 +301,6 @@ fn render_loader_version_step(state: &WizardState, area: Rect, buf: &mut ratatui } fn render_confirm_step(state: &WizardState, area: Rect, buf: &mut ratatui::buffer::Buffer) { - let theme = THEME.as_ref(); let game_version = state .selected_version() .map(|version| version.id.as_str()) @@ -299,173 +313,19 @@ fn render_confirm_step(state: &WizardState, area: Rect, buf: &mut ratatui::buffe .selected_loader_version() .unwrap_or_else(|| "".to_string()) }; - - let label_style = Style::default().fg(theme.text_dim()); - - Paragraph::new(vec![ - Line::from(vec![ - Span::styled("Name: ", label_style), - Span::raw(state.name_state.value()), - ]), - Line::from(vec![ - Span::styled("MC: ", label_style), - Span::raw(game_version), - ]), - Line::from(vec![ - Span::styled("Loader: ", label_style), - Span::raw(loader.to_string()), - ]), - Line::from(vec![ - Span::styled("Loader version: ", label_style), - Span::raw(loader_version), - ]), - ]) - .style(Style::default().fg(theme.text())) - .wrap(Wrap { trim: true }) - .render(area, buf); + let loader = loader.to_string(); + render_summary( + &[ + ("Name", state.name_state.value()), + ("MC", game_version), + ("Loader", &loader), + ("Loader version", &loader_version), + ], + area, + buf, + ); } #[cfg(test)] -mod tests { - use super::*; - use ratatui::Terminal; - use ratatui::backend::TestBackend; - - // WIZARD_STATE is a process-global static; without serialisation, parallel - // tests would race when each test sets the step and then renders, since - // render re-acquires the WIZARD_STATE mutex internally. this guard mutex - // ensures only one wizard snapshot test runs at a time. - static TEST_SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); - - fn reset_wizard_state(step: WizardStep) { - let mut guard = WIZARD_STATE.lock().expect("WIZARD_STATE lock"); - *guard = WizardState::default(); - guard.step = step; - } - - #[test] - fn new_instance_renders_name_step() { - let _serial = TEST_SERIAL.lock().unwrap_or_else(|e| e.into_inner()); - // Name is the default step; render touches no network helpers. - reset_wizard_state(WizardStep::Name); - - let backend = TestBackend::new(60, 12); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|f| render(f, f.area(), FocusedArea::Popup)) - .unwrap(); - insta::assert_snapshot!(terminal.backend()); - } - - #[test] - fn new_instance_renders_loader_step() { - let _serial = TEST_SERIAL.lock().unwrap_or_else(|e| e.into_inner()); - // Loader step is reached after Name; render just paints the hardcoded - // loader list, no network. - reset_wizard_state(WizardStep::Loader); - - let backend = TestBackend::new(60, 12); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|f| render(f, f.area(), FocusedArea::Popup)) - .unwrap(); - insta::assert_snapshot!(terminal.backend()); - } - - // Version step: pre-populate versions as LoadState::Loaded so - // ensure_versions_loaded short-circuits and never spawns a network task. - // the three synthetic versions are marked stable=true so they show with - // show_snapshots=false (the default). - #[test] - fn new_instance_renders_version_step() { - use crate::instance::loader::GameVersion; - - let _serial = TEST_SERIAL.lock().unwrap_or_else(|e| e.into_inner()); - { - let mut guard = WIZARD_STATE.lock().expect("WIZARD_STATE lock"); - *guard = WizardState::default(); - guard.step = WizardStep::Version; - guard.versions = LoadState::Loaded(vec![ - GameVersion { - id: "1.20.1".into(), - stable: true, - }, - GameVersion { - id: "1.19.4".into(), - stable: true, - }, - GameVersion { - id: "1.18.2".into(), - stable: true, - }, - ]); - } - - let backend = TestBackend::new(60, 14); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|f| render(f, f.area(), FocusedArea::Popup)) - .unwrap(); - insta::assert_snapshot!(terminal.backend()); - } - - // LoaderVersion step: needs both versions and loader_versions pre-loaded. - // pick a non-Vanilla loader (loader_idx=2 = Forge) so the step doesn't - // skip itself to Confirm. - #[test] - fn new_instance_renders_loader_version_step() { - use crate::instance::loader::GameVersion; - - let _serial = TEST_SERIAL.lock().unwrap_or_else(|e| e.into_inner()); - { - let mut guard = WIZARD_STATE.lock().expect("WIZARD_STATE lock"); - *guard = WizardState::default(); - guard.step = WizardStep::LoaderVersion; - guard.loader_idx = 2; // Forge - guard.versions = LoadState::Loaded(vec![GameVersion { - id: "1.20.1".into(), - stable: true, - }]); - guard.loader_versions = - LoadState::Loaded(vec!["47.2.0".into(), "47.1.0".into(), "47.0.50".into()]); - } - - let backend = TestBackend::new(60, 14); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|f| render(f, f.area(), FocusedArea::Popup)) - .unwrap(); - insta::assert_snapshot!(terminal.backend()); - } - - // Confirm step: paints a summary, no network, no list. requires - // versions + loader_versions Loaded so selected_*() return Some. - #[test] - fn new_instance_renders_confirm_step() { - use crate::instance::loader::GameVersion; - use tui_prompts::TextState; - - let _serial = TEST_SERIAL.lock().unwrap_or_else(|e| e.into_inner()); - { - let mut guard = WIZARD_STATE.lock().expect("WIZARD_STATE lock"); - *guard = WizardState::default(); - guard.step = WizardStep::Confirm; - guard.loader_idx = 1; // Fabric - guard.versions = LoadState::Loaded(vec![GameVersion { - id: "1.20.1".into(), - stable: true, - }]); - guard.loader_versions = LoadState::Loaded(vec!["0.15.0".into()]); - // TextState exposes only constructors; rebuilding with the - // desired initial value is the supported path. - guard.name_state = TextState::new().with_value("MyPack"); - } - - let backend = TestBackend::new(60, 12); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|f| render(f, f.area(), FocusedArea::Popup)) - .unwrap(); - insta::assert_snapshot!(terminal.backend()); - } -} +#[path = "../../../tests/widgets/popups/new_instance/render.rs"] +mod tests; diff --git a/src/tui/widgets/popups/new_instance/state.rs b/src/tui/widgets/popups/new_instance/state.rs index bcb0915..a44ba9e 100644 --- a/src/tui/widgets/popups/new_instance/state.rs +++ b/src/tui/widgets/popups/new_instance/state.rs @@ -12,6 +12,8 @@ use std::sync::LazyLock; use std::sync::{Arc, Mutex}; use tui_prompts::{FocusState, State as PromptState, TextState}; +use super::super::LoadState; + pub(crate) static WIZARD_STATE: LazyLock>> = LazyLock::new(|| Arc::new(Mutex::new(WizardState::default()))); // populated on confirm, consumed by the main event loop to actually create the instance @@ -36,15 +38,6 @@ pub enum WizardStep { Confirm, } -#[derive(Debug, Clone, Default)] -pub enum LoadState { - #[default] - Idle, - Loading, - Loaded(T), - Error(String), -} - #[derive(Debug, Clone)] pub struct WizardState { pub step: WizardStep, @@ -154,6 +147,18 @@ fn handle_name_key( } state.step = WizardStep::Loader; } + KeyCode::Backspace + if key_event + .modifiers + .contains(crossterm::event::KeyModifiers::CONTROL) => + { + let position = state.name_state.position(); + let position = crate::tui::widgets::search::delete_previous_word( + state.name_state.value_mut(), + position, + ); + *state.name_state.position_mut() = position; + } _ => { state.name_state.handle_key_event(*key_event); } @@ -174,7 +179,7 @@ fn handle_version_key( return; } KeyCode::Backspace => { - state.version_search.pop(); + state.version_search.backspace(key_event.modifiers); clamp_version_index(state); return; } @@ -458,25 +463,10 @@ pub(crate) fn ensure_loader_versions_loaded( }); } -// quick and dirty semver compare. doesn't handle pre-release tags or anything -// fancy, just splits on dots and compares numerically. good enough for mc versions. -fn compare_semver(a: &str, b: &str) -> std::cmp::Ordering { - let parse_parts = |s: &str| -> Vec { - s.split('.') - .map(|p| p.parse::().unwrap_or(0)) - .collect() - }; - let a_parts = parse_parts(a); - let b_parts = parse_parts(b); - for (ap, bp) in a_parts.iter().zip(b_parts.iter()) { - match ap.cmp(bp) { - std::cmp::Ordering::Equal => continue, - other => return other, - } - } - a_parts.len().cmp(&b_parts.len()) -} - fn sort_versions_semver(versions: &mut [GameVersion]) { - versions.sort_by(|a, b| compare_semver(&b.id, &a.id)); + versions.sort_by(|a, b| super::super::compare_game_versions(&b.id, &a.id)); } + +#[cfg(test)] +#[path = "../../../tests/widgets/popups/new_instance/state.rs"] +mod tests; diff --git a/src/tui/widgets/screenshots_grid.rs b/src/tui/widgets/screenshots_grid.rs index 0f575bc..4af8879 100644 --- a/src/tui/widgets/screenshots_grid.rs +++ b/src/tui/widgets/screenshots_grid.rs @@ -15,6 +15,7 @@ use ratatui::{ widgets::{Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState}, }; use ratatui_image::{Resize, StatefulImage, protocol::StatefulProtocol}; +use unicode_segmentation::UnicodeSegmentation; use crate::config::theme::THEME; use crate::instance::screenshots::ScreenshotEntry; @@ -29,6 +30,24 @@ const GAP: u16 = 1; type PendingScreenshots = Arc)>>>; +fn truncate_to_width(text: &str, max_width: usize) -> &str { + if Span::raw(text).width() <= max_width { + return text; + } + + let mut end = 0; + let mut width = 0; + for (offset, grapheme) in text.grapheme_indices(true) { + let grapheme_width = Span::raw(grapheme).width(); + if width + grapheme_width > max_width { + break; + } + width += grapheme_width; + end = offset + grapheme.len(); + } + &text[..end] +} + pub struct ScreenshotsState { pub entries: Vec, protocols: HashMap, @@ -92,7 +111,7 @@ impl ScreenshotsState { if let Ok(mut slot) = pending.lock() { *slot = Some((tag, entries)); - crate::tui::request_redraw(); + crate::feedback::request_redraw(); } }); } @@ -127,14 +146,15 @@ impl ScreenshotsState { // only load images that are currently visible (or about to be). // no point decoding a 4K screenshot the user can't even see yet pub fn request_visible_loads(&mut self) { - if self.entries.is_empty() { + let filtered = self.filtered_indices(); + if filtered.is_empty() { return; } let first = self.scroll_row * self.cols; - let last = ((self.scroll_row + self.visible_rows + 1) * self.cols).min(self.entries.len()); + let last = ((self.scroll_row + self.visible_rows + 1) * self.cols).min(filtered.len()); - for idx in first..last { + for &idx in filtered.iter().take(last).skip(first) { if !self.protocols.contains_key(&idx) && self.requested.insert(idx) { let path = self.entries[idx].path.clone(); let pending = self.pending_images.clone(); @@ -149,7 +169,7 @@ impl ScreenshotsState { && let Ok(mut slot) = pending.lock() { slot.push((idx, img)); - crate::tui::request_redraw(); + crate::feedback::request_redraw(); } }); } @@ -173,13 +193,25 @@ impl ScreenshotsState { if self.cols == 0 { return 0; } - self.entries.len().div_ceil(self.cols) + self.filtered_indices().len().div_ceil(self.cols) + } + + fn filtered_indices(&self) -> Vec { + self.entries + .iter() + .enumerate() + .filter(|(_, entry)| self.search.matches(&entry.name)) + .map(|(index, _)| index) + .collect() } pub fn pending_delete( &self, ) -> Option { - let entry = self.entries.get(self.selected)?; + let entry = self + .filtered_indices() + .get(self.selected) + .and_then(|index| self.entries.get(*index))?; Some(crate::tui::widgets::content::list::PendingContentDelete { name: entry.name.clone(), path: entry.path.clone(), @@ -191,11 +223,12 @@ impl ScreenshotsState { self.protocols.clear(); self.requested.clear(); - if self.entries.is_empty() { + let visible_count = self.filtered_indices().len(); + if visible_count == 0 { self.selected = 0; self.scroll_row = 0; } else { - self.selected = self.selected.min(self.entries.len().saturating_sub(1)); + self.selected = self.selected.min(visible_count.saturating_sub(1)); self.ensure_visible(); } } @@ -207,18 +240,22 @@ pub fn handle_key(key_event: &KeyEvent, state: &mut ScreenshotsState) -> bool { KeyCode::Enter => { state.search.confirm(); state.selected = 0; + state.scroll_row = 0; } KeyCode::Esc => { state.search.deactivate(); state.selected = 0; + state.scroll_row = 0; } KeyCode::Backspace => { - state.search.pop(); + state.search.backspace(key_event.modifiers); state.selected = 0; + state.scroll_row = 0; } KeyCode::Char(c) => { state.search.push(c); state.selected = 0; + state.scroll_row = 0; } _ => {} } @@ -249,7 +286,9 @@ pub fn handle_key(key_event: &KeyEvent, state: &mut ScreenshotsState) -> bool { true } KeyCode::Enter if key_event.modifiers.contains(KeyModifiers::SHIFT) => { - if let Some(entry) = state.entries.get(state.selected) + if let Some(entry) = filtered + .get(state.selected) + .and_then(|index| state.entries.get(*index)) && let Some(dir) = entry.path.parent() && let Err(e) = open::that_detached(dir) { @@ -258,7 +297,9 @@ pub fn handle_key(key_event: &KeyEvent, state: &mut ScreenshotsState) -> bool { true } KeyCode::Enter => { - if let Some(entry) = state.entries.get(state.selected) + if let Some(entry) = filtered + .get(state.selected) + .and_then(|index| state.entries.get(*index)) && let Err(e) = open::that_detached(&entry.path) { tracing::error!("Failed to open file: {}", e); @@ -314,6 +355,15 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut ScreenshotsState, is_fo return; } + let filtered = state.filtered_indices(); + if filtered.is_empty() { + frame.render_widget( + Paragraph::new("No matching screenshots.").style(Style::default().fg(theme.text_dim())), + area, + ); + return; + } + let min_cols = (area.width / MAX_CELL_WIDTH).max(1) as usize; let max_cols = (area.width / MIN_CELL_WIDTH).max(1) as usize; let target_cols = (area.width / TARGET_CELL_WIDTH).max(1) as usize; @@ -342,10 +392,11 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut ScreenshotsState, is_fo for vr in 0..visible_rows { for vc in 0..cols { - let idx = (state.scroll_row + vr) * cols + vc; - if idx >= state.entries.len() { + let display_index = (state.scroll_row + vr) * cols + vc; + if display_index >= filtered.len() { break; } + let idx = filtered[display_index]; let raw_x = area.x + vc as u16 * cell_width; let raw_y = area.y + vr as u16 * cell_height; @@ -363,7 +414,7 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut ScreenshotsState, is_fo continue; } - let is_selected = is_focused && idx == state.selected; + let is_selected = is_focused && display_index == state.selected; let [img_area, name_area] = Layout::vertical([Constraint::Min(0), Constraint::Length(NAME_ROW_HEIGHT)]) @@ -384,13 +435,9 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut ScreenshotsState, is_fo Style::default().fg(theme.text_dim()) }; - let truncated = if name.len() > cell_width as usize { - &name[..cell_width as usize] - } else { - name - }; + let truncated = truncate_to_width(name, name_area.width as usize); frame.render_widget( - Paragraph::new(Span::styled(truncated, name_style)), + Paragraph::new(state.search.highlight_line(truncated, name_style)), name_area, ); } @@ -418,3 +465,7 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut ScreenshotsState, is_fo &mut state.scrollbar_state, ); } + +#[cfg(test)] +#[path = "../tests/widgets/screenshots_grid.rs"] +mod tests; diff --git a/src/tui/widgets/search.rs b/src/tui/widgets/search.rs index fd06e70..52a750d 100644 --- a/src/tui/widgets/search.rs +++ b/src/tui/widgets/search.rs @@ -1,6 +1,7 @@ // reusable incremental search state used across multiple widgets. // handles case-insensitive filtering and inline match highlighting. +use crossterm::event::KeyModifiers; use ratatui::{ style::{Modifier, Style}, text::{Line, Span}, @@ -34,8 +35,8 @@ impl SearchState { self.query.push(c); } - pub fn pop(&mut self) { - self.query.pop(); + pub fn backspace(&mut self, modifiers: KeyModifiers) { + backspace(&mut self.query, modifiers); } pub fn is_empty(&self) -> bool { @@ -49,11 +50,11 @@ impl SearchState { text.to_lowercase().contains(&self.query.to_lowercase()) } - // splits a line into spans, bolding+underlining the parts that match - // the query so they pop out visually - pub fn highlight_line<'a>(&self, text: &'a str, base_style: Style) -> Line<'a> { + // splits text into spans, bolding+underlining the parts that match + // the query so every searchable widget can use the same styling + pub fn highlight_spans(&self, text: &str, base_style: Style) -> Vec> { if self.query.is_empty() { - return Line::from(Span::styled(text, base_style)); + return vec![Span::styled(text.to_owned(), base_style)]; } let query_lower = self.query.to_lowercase(); @@ -62,25 +63,32 @@ impl SearchState { let mut last = 0; for (start, _) in text_lower.match_indices(&query_lower) { + let end = start + query_lower.len(); + if !text.is_char_boundary(start) || !text.is_char_boundary(end) { + continue; + } if start > last { - spans.push(Span::styled(&text[last..start], base_style)); + spans.push(Span::styled(text[last..start].to_owned(), base_style)); } spans.push(Span::styled( - &text[start..start + self.query.len()], + text[start..end].to_owned(), base_style.add_modifier(Modifier::BOLD | Modifier::UNDERLINED), )); - last = start + self.query.len(); + last = end; } if last < text.len() { - spans.push(Span::styled(&text[last..], base_style)); + spans.push(Span::styled(text[last..].to_owned(), base_style)); } if spans.is_empty() { - Line::from(Span::styled(text, base_style)) - } else { - Line::from(spans) + spans.push(Span::styled(text.to_owned(), base_style)); } + spans + } + + pub fn highlight_line(&self, text: &str, base_style: Style) -> Line<'static> { + Line::from(self.highlight_spans(text, base_style)) } // renders the "/ query█" indicator in the block title bar @@ -110,46 +118,30 @@ impl SearchState { } } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn confirm_keeps_query_but_deactivates() { - let mut s = SearchState::default(); - s.activate(); - s.push('a'); - s.push('b'); - s.confirm(); - assert!(!s.active); - assert_eq!(s.query, "ab"); - // filter should still match - assert!(s.matches("abc")); - assert!(!s.matches("xyz")); +pub fn backspace(text: &mut String, modifiers: KeyModifiers) { + if modifiers.contains(KeyModifiers::CONTROL) { + let cursor = text.chars().count(); + delete_previous_word(text, cursor); + } else { + text.pop(); } +} - #[test] - fn deactivate_clears_query() { - let mut s = SearchState::default(); - s.activate(); - s.push('x'); - s.deactivate(); - assert!(!s.active); - assert!(s.query.is_empty()); - // with empty query, everything matches - assert!(s.matches("anything")); +pub fn delete_previous_word(text: &mut String, cursor: usize) -> usize { + let mut chars = text.chars().collect::>(); + let cursor = cursor.min(chars.len()); + let mut start = cursor; + while start > 0 && chars[start - 1].is_whitespace() { + start -= 1; } - - #[test] - fn confirm_then_reactivate_preserves_query() { - let mut s = SearchState::default(); - s.activate(); - s.push('t'); - s.push('e'); - s.confirm(); - // user presses search key again to edit - s.activate(); - assert!(s.active); - assert_eq!(s.query, "te"); + while start > 0 && !chars[start - 1].is_whitespace() { + start -= 1; } + chars.drain(start..cursor); + *text = chars.into_iter().collect(); + start } + +#[cfg(test)] +#[path = "../tests/widgets/search.rs"] +mod tests; diff --git a/src/tui/widgets/settings.rs b/src/tui/widgets/settings.rs index 21c3993..38e017d 100644 --- a/src/tui/widgets/settings.rs +++ b/src/tui/widgets/settings.rs @@ -158,7 +158,7 @@ fn effective_java_path(instance: &InstanceConfig) -> String { .java_path .clone() .or_else(|| SETTINGS.paths.effective_java_path().map(str::to_string)) - .unwrap_or_else(crate::net::detect_java_path) + .unwrap_or_else(crate::instance::java::detect_java_path) } fn java_version_label(java_path: &str) -> String { @@ -210,7 +210,7 @@ pub fn render( .border_type(BORDER_STYLE.to_border_type()) .border_style(Style::default().fg(color)); - if focused == FocusedArea::Settings { + let keybind_line = if focused == FocusedArea::Settings { let keybinds: &[(&str, &str)] = match state.pane { SettingsPane::Profile => &[ ("⏎", " select"), @@ -218,18 +218,25 @@ pub fn render( ("d", " del"), ("j/k", " move"), ("h/l", " tab"), + ("Esc", " back"), ], SettingsPane::Info => &[ ("e", " inst"), ("g", " global"), ("d", " desk"), ("h/l", " tab"), + ("Esc", " back"), ], }; - let lines = super::popups::keybind_lines_wrapped(keybinds, area.width.saturating_sub(2)); - for line in lines { - block = block.title_bottom(line); - } + Some(super::popups::keybind_line_fitted( + keybinds, + area.width.saturating_sub(2), + )) + } else { + None + }; + if let Some(line) = keybind_line { + block = block.title_bottom(line); } let inner = block.inner(area); @@ -488,7 +495,7 @@ pub fn handle_key( } KeyCode::Backspace => { let mut new_name = name.clone(); - new_name.pop(); + super::search::backspace(&mut new_name, key_event.modifiers); state.add_mode = AddMode::ProfileName(new_name); return SettingsAction::None; } @@ -565,21 +572,5 @@ pub fn handle_key( } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn removing_selected_last_profile_clamps_selection() { - let tmp = tempfile::tempdir().unwrap(); - let mut state = SettingsState::new(tmp.path().to_path_buf()); - state.profiles = vec!["first".to_string(), "second".to_string()]; - state.active_profile = Some("second".to_string()); - state.list_state.selected = Some(2); - - state.remove_profile("second"); - - assert_eq!(state.profiles, vec!["first"]); - assert_eq!(state.active_profile, None); - assert_eq!(state.list_state.selected, Some(1)); - } -} +#[path = "../tests/widgets/settings.rs"] +mod tests; diff --git a/src/tui/widgets/status.rs b/src/tui/widgets/status.rs index b7f694b..50f9126 100644 --- a/src/tui/widgets/status.rs +++ b/src/tui/widgets/status.rs @@ -11,8 +11,8 @@ use ratatui::{ use throbber_widgets_tui::{Throbber, ThrobberState}; use crate::config::theme::{BORDER_STYLE, THEME}; +use crate::feedback::progress::PROGRESS; use crate::tui::app::FocusedArea; -use crate::tui::progress::PROGRESS; use super::styled_title; diff --git a/tests/content_scanners.rs b/tests/content_scanners.rs index 6f2df0b..e3dae04 100644 --- a/tests/content_scanners.rs +++ b/tests/content_scanners.rs @@ -1,5 +1,5 @@ // integration tests for the public content-scanner APIs: shaders, worlds, -// and resource packs. each scanner walks an instance's .minecraft subdir +// and resource packs. each scanner walks an instance's minecraft subdir // and returns ContentEntry rows; shape varies slightly per content type // (worlds are directories only, packs and shaders accept .zip or dir, etc.). @@ -10,7 +10,7 @@ use rmcl::instance::content::shaders::scan_shaders; use rmcl::instance::content::worlds::scan_worlds; fn setup_subdir(tmp: &Path, instance: &str, sub: &str) -> PathBuf { - let dir = tmp.join(instance).join(".minecraft").join(sub); + let dir = tmp.join(instance).join("minecraft").join(sub); std::fs::create_dir_all(&dir).unwrap(); dir } @@ -32,15 +32,6 @@ fn shaders_missing_dir_returns_empty() { assert!(scan_shaders(tmp.path(), "ghost").is_empty()); } -#[test] -fn shaders_finds_zip_and_dir() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_subdir(tmp.path(), "inst", "shaderpacks"); - std::fs::write(dir.join("shader-a.zip"), ZIP_HEADER).unwrap(); - std::fs::create_dir(dir.join("shader-b")).unwrap(); - assert_eq!(scan_shaders(tmp.path(), "inst").len(), 2); -} - #[test] fn shaders_disabled_variants() { let tmp = tempfile::tempdir().unwrap(); @@ -81,15 +72,6 @@ fn worlds_missing_dir_returns_empty() { assert!(scan_worlds(tmp.path(), "ghost").is_empty()); } -#[test] -fn worlds_finds_directories() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_subdir(tmp.path(), "inst", "saves"); - std::fs::create_dir(dir.join("My World")).unwrap(); - std::fs::create_dir(dir.join("Creative")).unwrap(); - assert_eq!(scan_worlds(tmp.path(), "inst").len(), 2); -} - #[test] fn worlds_ignores_files() { let tmp = tempfile::tempdir().unwrap(); @@ -143,15 +125,6 @@ fn resource_packs_missing_dir_returns_empty() { assert!(scan_resource_packs(tmp.path(), "ghost").is_empty()); } -#[test] -fn resource_packs_finds_zips_and_dirs() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_subdir(tmp.path(), "inst", "resourcepacks"); - std::fs::write(dir.join("pack-a.zip"), ZIP_HEADER).unwrap(); - std::fs::create_dir(dir.join("pack-b")).unwrap(); - assert_eq!(scan_resource_packs(tmp.path(), "inst").len(), 2); -} - #[test] fn resource_packs_disabled_variants() { let tmp = tempfile::tempdir().unwrap(); diff --git a/tests/launch_pipeline.rs b/tests/launch_pipeline.rs index 170651f..b93f853 100644 --- a/tests/launch_pipeline.rs +++ b/tests/launch_pipeline.rs @@ -4,13 +4,15 @@ // fixture layout in a tempdir, call the seam, and assert on the rendered // LaunchInvocation. nothing here actually spawns a process. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use chrono::Utc; use serde_json::json; use tempfile::TempDir; -use rmcl::instance::launch::{LaunchAuth, LaunchError, build_launch_invocation}; +use rmcl::instance::launch::{ + LaunchAuth, LaunchError, build_launch_invocation, supports_quick_play, +}; use rmcl::instance::models::{InstanceConfig, ModLoader}; // ---------- helpers ---------- @@ -53,6 +55,7 @@ fn make_config_with( jvm_args: Vec::new(), resolution: None, config_sync_profile: None, + modpack_source: None, } } @@ -73,9 +76,9 @@ fn fake_java(tmp: &TempDir, major: u32) -> String { } // builds the on-disk layout that build_launch_invocation expects: -// /instances//.minecraft/ (instance dir, created empty) +// /instances//minecraft/ (instance dir, created empty) // /meta/versions//meta.json -// /meta/loader-profiles/ (created empty) +// /meta/cache/loaders/profiles/ (created empty) // /meta/libraries/ (created empty) struct Fixture { _tmp: TempDir, @@ -89,12 +92,12 @@ impl Fixture { let instances_dir = tmp.path().join("instances"); let meta_dir = tmp.path().join("meta"); - let instance_minecraft = instances_dir.join(instance_name).join(".minecraft"); + let instance_minecraft = instances_dir.join(instance_name).join("minecraft"); std::fs::create_dir_all(&instance_minecraft).unwrap(); - std::fs::create_dir_all(meta_dir.join("libraries")).unwrap(); - std::fs::create_dir_all(meta_dir.join("loader-profiles")).unwrap(); - let version_dir = meta_dir.join("versions").join(game_version); + std::fs::create_dir_all(meta_dir.join("cache/minecraft/libraries")).unwrap(); + std::fs::create_dir_all(meta_dir.join("cache/loaders/profiles")).unwrap(); + let version_dir = meta_dir.join("cache/minecraft/versions").join(game_version); std::fs::create_dir_all(&version_dir).unwrap(); std::fs::write( version_dir.join("meta.json"), @@ -111,7 +114,7 @@ impl Fixture { fn write_loader_profile(&self, filename: &str, content: serde_json::Value) { std::fs::write( - self.meta_dir.join("loader-profiles").join(filename), + self.meta_dir.join("cache/loaders/profiles").join(filename), serde_json::to_vec_pretty(&content).unwrap(), ) .unwrap(); @@ -120,7 +123,7 @@ impl Fixture { fn instance_libraries_dir(&self, instance_name: &str) -> PathBuf { self.instances_dir .join(instance_name) - .join(".minecraft") + .join("minecraft") .join("libraries") } } @@ -201,7 +204,7 @@ async fn vanilla_modern_builds_complete_invocation() { let fx = Fixture::new("v1", "1.20.1", modern_vanilla_meta("1.20.1")); let config = make_config("v1", "1.20.1", ModLoader::Vanilla); - let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth(), None) .await .unwrap(); @@ -209,13 +212,21 @@ async fn vanilla_modern_builds_complete_invocation() { assert_eq!(inv.main_class, "net.minecraft.client.main.Main"); assert!(inv.extra_args.is_empty()); - let expected_natives = fx.meta_dir.join("versions").join("1.20.1").join("natives"); - assert!( - inv.jvm_args - .iter() - .any(|a| a == &format!("-Djava.library.path={}", expected_natives.display())), + let expected_natives = fx + .meta_dir + .join("cache/minecraft/versions") + .join("1.20.1") + .join("natives"); + let actual_natives = inv + .jvm_args + .iter() + .find_map(|arg| arg.strip_prefix("-Djava.library.path=")) + .map(Path::new); + assert_eq!( + actual_natives, + Some(expected_natives.as_path()), "jvm_args missing natives substitution: {:?}", - inv.jvm_args + inv.jvm_args, ); assert!( inv.jvm_args @@ -225,20 +236,59 @@ async fn vanilla_modern_builds_complete_invocation() { inv.jvm_args ); - // working_dir is //.minecraft + // working_dir is //minecraft assert_eq!( inv.working_dir, - fx.instances_dir.join("v1").join(".minecraft") + fx.instances_dir.join("v1").join("minecraft") ); // classpath = the one vanilla lib + the vanilla client jar let slf4j = fx .meta_dir - .join("libraries/org/slf4j/slf4j-api/2.0.7/slf4j-api-2.0.7.jar"); - let client_jar = fx.meta_dir.join("versions/1.20.1/1.20.1.jar"); + .join("cache/minecraft/libraries/org/slf4j/slf4j-api/2.0.7/slf4j-api-2.0.7.jar"); + let client_jar = fx + .meta_dir + .join("cache/minecraft/versions/1.20.1/1.20.1.jar"); assert!(inv.classpath.contains(&slf4j)); assert!(inv.classpath.contains(&client_jar)); } +#[tokio::test] +async fn quick_play_passes_the_selected_save_folder() { + let mut meta = modern_vanilla_meta("1.20.1"); + meta["arguments"]["game"] + .as_array_mut() + .unwrap() + .push(json!({ + "rules": [{ + "action": "allow", + "features": { "is_quick_play_singleplayer": true } + }], + "value": ["--quickPlaySingleplayer", "${quickPlaySingleplayer}"] + })); + let fx = Fixture::new("quick", "1.20.1", meta); + let world_dir = fx.instances_dir.join("quick/minecraft/saves/Display World"); + std::fs::create_dir_all(&world_dir).unwrap(); + let config = make_config("quick", "1.20.1", ModLoader::Vanilla); + + assert!(supports_quick_play(&fx.meta_dir, "1.20.1")); + let invocation = build_launch_invocation( + &config, + &fx.instances_dir, + &fx.meta_dir, + &test_auth(), + Some("Display World"), + ) + .await + .unwrap(); + + assert!( + invocation + .game_args + .windows(2) + .any(|args| args == ["--quickPlaySingleplayer", "Display World"]) + ); +} + #[cfg(unix)] #[tokio::test] async fn launch_fails_when_selected_java_is_older_than_profile_requires() { @@ -250,7 +300,7 @@ async fn launch_fails_when_selected_java_is_older_than_profile_requires() { let mut config = make_config("java-old", "26.1.2", ModLoader::Vanilla); config.java_path = Some(fake_java(&fx._tmp, 21)); - let err = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + let err = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth(), None) .await .expect_err("java 21 should fail for a Java 25 profile"); @@ -272,7 +322,7 @@ async fn vanilla_legacy_args_format_substitutes_tokens() { let fx = Fixture::new("vlegacy", "1.7.10", legacy_vanilla_meta("1.7.10")); let config = make_config("vlegacy", "1.7.10", ModLoader::Vanilla); - let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth(), None) .await .unwrap(); @@ -318,7 +368,7 @@ async fn forge_modern_includes_add_opens() { ); let config = make_config_with("f1", "1.20.1", ModLoader::Forge, Some("47.2.0")); - let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth(), None) .await .unwrap(); @@ -374,7 +424,7 @@ async fn forge_local_lib_dir_preferred_over_meta_dir() { std::fs::write(&local_jar, b"jar").unwrap(); let config = make_config_with("f2", "1.20.1", ModLoader::Forge, Some("47.2.0")); - let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth(), None) .await .unwrap(); @@ -413,7 +463,7 @@ async fn fabric_implicit_inheritsfrom_resolves() { ); let config = make_config_with("fab", "1.20.1", ModLoader::Fabric, Some("0.15.0")); - let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth(), None) .await .unwrap(); @@ -422,12 +472,12 @@ async fn fabric_implicit_inheritsfrom_resolves() { "net.fabricmc.loader.impl.launch.knot.KnotClient" ); // both fabric-loader and vanilla slf4j must appear on the merged classpath - let fabric_loader = fx - .meta_dir - .join("libraries/net/fabricmc/fabric-loader/0.15.0/fabric-loader-0.15.0.jar"); + let fabric_loader = fx.meta_dir.join( + "cache/minecraft/libraries/net/fabricmc/fabric-loader/0.15.0/fabric-loader-0.15.0.jar", + ); let slf4j = fx .meta_dir - .join("libraries/org/slf4j/slf4j-api/2.0.7/slf4j-api-2.0.7.jar"); + .join("cache/minecraft/libraries/org/slf4j/slf4j-api/2.0.7/slf4j-api-2.0.7.jar"); assert!( inv.classpath.contains(&fabric_loader), "fabric-loader missing from classpath: {:?}", @@ -463,7 +513,7 @@ async fn neoforge_inheritsfrom_resolves() { ); let config = make_config_with("ne", "1.20.6", ModLoader::NeoForge, Some("20.4.190")); - let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth(), None) .await .unwrap(); @@ -487,7 +537,7 @@ async fn auth_credentials_substituted_in_game_args() { let fx = Fixture::new("auth", "1.20.1", modern_vanilla_meta("1.20.1")); let config = make_config("auth", "1.20.1", ModLoader::Vanilla); - let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth(), None) .await .unwrap(); @@ -521,7 +571,7 @@ async fn version_type_substituted() { let fx = Fixture::new("vt", "1.20.1", meta); let config = make_config("vt", "1.20.1", ModLoader::Vanilla); - let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth(), None) .await .unwrap(); @@ -539,7 +589,7 @@ async fn classpath_uses_platform_separator() { let fx = Fixture::new("cp", "1.20.1", modern_vanilla_meta("1.20.1")); let config = make_config("cp", "1.20.1", ModLoader::Vanilla); - let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth(), None) .await .unwrap(); @@ -581,7 +631,7 @@ async fn rule_disallow_excludes_library() { let fx = Fixture::new("rule", "1.20.1", meta); let config = make_config("rule", "1.20.1", ModLoader::Vanilla); - let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth(), None) .await .unwrap(); @@ -602,7 +652,7 @@ async fn xms_xmx_use_config_memory() { config.memory_min = Some("1G".into()); config.memory_max = Some("4G".into()); - let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth(), None) .await .unwrap(); @@ -616,7 +666,7 @@ async fn default_memory_used_when_unset() { let config = make_config("memdef", "1.20.1", ModLoader::Vanilla); // memory_min and memory_max default to None in make_config - let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth(), None) .await .unwrap(); @@ -631,11 +681,11 @@ async fn meta_not_found_returns_error() { let tmp = tempfile::tempdir().unwrap(); let instances_dir = tmp.path().join("instances"); let meta_dir = tmp.path().join("meta"); - std::fs::create_dir_all(instances_dir.join("ghost").join(".minecraft")).unwrap(); - std::fs::create_dir_all(meta_dir.join("versions")).unwrap(); + std::fs::create_dir_all(instances_dir.join("ghost").join("minecraft")).unwrap(); + std::fs::create_dir_all(meta_dir.join("cache/minecraft/versions")).unwrap(); let config = make_config("ghost", "1.20.1", ModLoader::Vanilla); - let err = build_launch_invocation(&config, &instances_dir, &meta_dir, &test_auth()) + let err = build_launch_invocation(&config, &instances_dir, &meta_dir, &test_auth(), None) .await .unwrap_err(); assert!( @@ -649,7 +699,7 @@ async fn loader_profile_missing_returns_error() { let fx = Fixture::new("lpmiss", "1.20.1", modern_vanilla_meta("1.20.1")); let config = make_config_with("lpmiss", "1.20.1", ModLoader::Fabric, Some("0.15.0")); - let err = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + let err = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth(), None) .await .unwrap_err(); assert!( @@ -668,7 +718,7 @@ async fn merged_profile_missing_main_class_fails() { let fx = Fixture::new("nomc", "1.20.1", meta); let config = make_config("nomc", "1.20.1", ModLoader::Vanilla); - let err = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + let err = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth(), None) .await .unwrap_err(); let msg = format!("{err:?}"); @@ -698,7 +748,7 @@ async fn legacy_loader_profile_missing_loader_version_errors() { let mut config = make_config("nolv", "1.20.1", ModLoader::Forge); config.loader_version = None; - let err = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + let err = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth(), None) .await .unwrap_err(); let msg = format!("{err:?}"); @@ -725,7 +775,7 @@ async fn legacy_loader_profile_missing_installer_json_errors() { let config = make_config_with("noinst", "1.20.1", ModLoader::Forge, Some("47.2.0")); - let err = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + let err = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth(), None) .await .unwrap_err(); let msg = format!("{err:?}"); diff --git a/tests/live_apis.rs b/tests/live_apis.rs new file mode 100644 index 0000000..4655954 --- /dev/null +++ b/tests/live_apis.rs @@ -0,0 +1,210 @@ +//! Release-gate smoke tests for live upstream APIs. +//! +//! These are intentionally ignored during normal test runs. The release +//! workflow runs them with `cargo test --all-targets -- --ignored`. + +use rmcl::instance::{ContentKind, ModLoader, VanillaInstaller}; +use rmcl::net::{self, HttpClient}; + +#[tokio::test] +#[ignore = "hits live Fabric API"] +async fn fabric_versions_are_available() { + let client = HttpClient::new(); + let versions = net::fabric::fetch_fabric_versions(&client, "1.20.1") + .await + .unwrap(); + + assert!(!versions.is_empty()); + assert!(versions[0].loader.version.contains('.')); +} + +#[tokio::test] +#[ignore = "hits live Fabric API"] +async fn fabric_game_versions_are_available() { + let versions = net::fabric::fetch_fabric_game_versions(&HttpClient::new()) + .await + .unwrap(); + + assert!(versions.iter().any(|version| version.id == "1.20.1")); +} + +#[tokio::test] +#[ignore = "hits live Quilt API"] +async fn quilt_versions_are_available() { + let versions = net::quilt::fetch_quilt_versions(&HttpClient::new(), "1.20.1") + .await + .unwrap(); + + assert!(!versions.is_empty()); +} + +#[tokio::test] +#[ignore = "hits live Quilt API"] +async fn quilt_game_versions_are_available() { + let versions = net::quilt::fetch_quilt_game_versions(&HttpClient::new()) + .await + .unwrap(); + + assert!(versions.iter().any(|version| version.id == "1.20.1")); +} + +#[tokio::test] +#[ignore = "hits live Forge API"] +async fn forge_versions_are_available() { + let versions = net::forge::fetch_forge_versions(&HttpClient::new(), "1.20.1") + .await + .unwrap(); + + assert!(!versions.is_empty()); +} + +#[tokio::test] +#[ignore = "hits live Forge API"] +async fn forge_game_versions_are_available() { + let versions = net::forge::fetch_forge_game_versions(&HttpClient::new()) + .await + .unwrap(); + + assert!(versions.iter().any(|version| version.id == "1.20.1")); +} + +#[tokio::test] +#[ignore = "hits live NeoForge API"] +async fn neoforge_versions_are_available() { + let versions = net::neoforge::fetch_neoforge_versions(&HttpClient::new(), "1.21") + .await + .unwrap(); + + assert!(!versions.is_empty()); +} + +#[tokio::test] +#[ignore = "hits live NeoForge API"] +async fn neoforge_game_versions_are_available() { + let versions = net::neoforge::fetch_neoforge_game_versions(&HttpClient::new()) + .await + .unwrap(); + + assert!(versions.iter().any(|version| version.id == "1.21")); +} + +#[tokio::test] +#[ignore = "hits live Mojang API"] +async fn vanilla_installer_lists_game_versions() { + use rmcl::instance::ModLoaderInstaller; + + let versions = VanillaInstaller + .get_game_versions(&HttpClient::new()) + .await + .unwrap(); + + assert!( + versions + .iter() + .any(|version| version.id == "1.20.1" && version.stable) + ); +} + +#[tokio::test] +#[ignore = "hits live Modrinth API"] +async fn modrinth_project_is_available() { + let project = net::modrinth::fetch_project(&HttpClient::new(), "fabulously-optimized") + .await + .unwrap(); + + assert_eq!(project.slug, "fabulously-optimized"); + assert!(!project.title.is_empty()); +} + +#[tokio::test] +#[ignore = "hits live Modrinth API"] +async fn modrinth_versions_are_available() { + let versions = net::modrinth::fetch_versions(&HttpClient::new(), "fabulously-optimized") + .await + .unwrap(); + + assert!(!versions.is_empty()); + assert!(!versions[0].files.is_empty()); +} + +#[tokio::test] +#[ignore = "hits live Modrinth API"] +async fn modrinth_discovery_returns_unique_compatible_mods() { + use std::collections::HashSet; + + use rmcl::net::modrinth::search_discovery; + + let client = HttpClient::new(); + let result = search_discovery( + &client, + ContentKind::Mod, + "sodium", + "1.21.1", + ModLoader::Fabric, + 0, + 100, + ) + .await + .unwrap(); + + assert!(!result.projects.is_empty()); + assert!(result.total_hits >= result.projects.len()); + let icon_url = result + .projects + .iter() + .find_map(|project| project.icon_url.as_deref()) + .expect("expected at least one project icon"); + let icon = client + .get_bytes_limited(icon_url, net::MAX_PROVIDER_ASSET_BYTES) + .await + .unwrap(); + assert!(image::load_from_memory(&icon).is_ok()); + + let mut seen = result + .projects + .iter() + .map(|project| project.id.clone()) + .collect::>(); + for offset in (100..result.total_hits.min(300)).step_by(100) { + let next = search_discovery( + &client, + ContentKind::Mod, + "sodium", + "1.21.1", + ModLoader::Fabric, + offset, + 100, + ) + .await + .unwrap(); + assert!(!next.projects.is_empty(), "empty page at offset {offset}"); + assert!( + next.projects + .iter() + .all(|project| seen.insert(project.id.clone())), + "duplicate project at offset {offset}" + ); + } +} + +#[tokio::test] +#[ignore = "hits live CurseForge API"] +async fn curseforge_discovery_returns_compatible_mods() { + let api_key = net::curseforge::api_key().expect("build has no CurseForge API key"); + let result = net::curseforge::search_discovery( + &HttpClient::new(), + api_key, + ContentKind::Mod, + "sodium", + "1.21.1", + ModLoader::Fabric, + 0, + 20, + ) + .await + .unwrap(); + + assert!(!result.projects.is_empty()); + assert!(result.total_hits >= result.projects.len()); + assert!(result.projects.iter().all(|project| !project.id.is_empty())); +} diff --git a/tests/log_files.rs b/tests/log_files.rs index 6e8b8f8..0aa0739 100644 --- a/tests/log_files.rs +++ b/tests/log_files.rs @@ -1,10 +1,10 @@ -// integration tests for the public log_files API. +// integration tests for the public log files API. // these tests touch the filesystem and exercise the module as an external // consumer would. use std::path::{Path, PathBuf}; -use rmcl::instance::log_files::{create_log_file, log_dir, read_log_file, scan_log_files}; +use rmcl::instance::logs::files::{create_log_file, log_dir, read_log_file, scan_log_files}; fn setup_log_dir(tmp: &Path, instance: &str) -> PathBuf { let dir = log_dir(tmp, instance); @@ -61,7 +61,8 @@ fn read_log_file_returns_lines() { #[test] fn read_log_file_missing_returns_empty() { - let lines = read_log_file(Path::new("/nonexistent/test.log")); + let tmp = tempfile::tempdir().unwrap(); + let lines = read_log_file(&tmp.path().join("missing.log")); assert!(lines.is_empty()); } diff --git a/tests/net_mojang.rs b/tests/net_mojang.rs index e4e0289..6231f31 100644 --- a/tests/net_mojang.rs +++ b/tests/net_mojang.rs @@ -1,7 +1,7 @@ // integration tests for the public mojang fetchers. wiremock stands in for // Mojang so tests are fast, deterministic, and don't depend on the live // endpoint. these are different from the #[ignore = "hits live Mojang API"] -// tests in src/net/mojang.rs which verify the upstream schema hasn't drifted; +// tests in tests/live_apis.rs which verify the upstream schema hasn't drifted; // these here verify our parsing + retry envelope on synthetic responses. use serde_json::json; @@ -170,7 +170,7 @@ async fn download_libraries_writes_artifact_to_meta_dir() { let written = tmp .path() - .join("libraries/org/slf4j/slf4j-api/2.0.7/slf4j-api-2.0.7.jar"); + .join("cache/minecraft/libraries/org/slf4j/slf4j-api/2.0.7/slf4j-api-2.0.7.jar"); assert!(written.exists(), "expected library file at {written:?}"); let contents = std::fs::read(&written).unwrap(); assert_eq!(contents, b"jar-bytes"); @@ -193,7 +193,7 @@ async fn download_libraries_skips_when_destination_exists() { let tmp = tempfile::tempdir().unwrap(); let existing = tmp .path() - .join("libraries/org/slf4j/slf4j-api/2.0.7/slf4j-api-2.0.7.jar"); + .join("cache/minecraft/libraries/org/slf4j/slf4j-api/2.0.7/slf4j-api-2.0.7.jar"); std::fs::create_dir_all(existing.parent().unwrap()).unwrap(); std::fs::write(&existing, b"already there").unwrap(); @@ -240,11 +240,11 @@ async fn download_assets_from_writes_index_and_assets() { .await .expect("download_assets_from"); - let index_path = tmp.path().join("assets/indexes/5.json"); + let index_path = tmp.path().join("cache/minecraft/assets/indexes/5.json"); assert!(index_path.exists(), "index file missing"); let asset_path = tmp .path() - .join("assets/objects/ab/ab1234567890abcdef1234567890abcdef123456"); + .join("cache/minecraft/assets/objects/ab/ab1234567890abcdef1234567890abcdef123456"); assert!(asset_path.exists(), "asset file missing"); assert_eq!(std::fs::read(&asset_path).unwrap(), b"asset-bytes"); } @@ -269,7 +269,7 @@ async fn download_assets_writes_index_when_objects_is_empty() { .await .expect("download_assets index-only"); - let index_path = tmp.path().join("assets/indexes/5.json"); + let index_path = tmp.path().join("cache/minecraft/assets/indexes/5.json"); assert!( index_path.exists(), "expected asset index at {index_path:?}" @@ -278,31 +278,3 @@ async fn download_assets_writes_index_when_objects_is_empty() { serde_json::from_slice(&std::fs::read(&index_path).unwrap()).unwrap(); assert!(body.get("objects").is_some()); } - -#[tokio::test] -async fn fetch_version_manifest_retries_5xx_and_succeeds() { - let server = MockServer::start().await; - - // one transient 503 then the real payload; covers the integration of the - // retry envelope (already unit-tested in net_retry.rs) with the actual - // VersionManifest deserialisation path. - Mock::given(method("GET")) - .and(path("/manifest.json")) - .respond_with(ResponseTemplate::new(503)) - .up_to_n_times(1) - .expect(1) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/manifest.json")) - .respond_with(ResponseTemplate::new(200).set_body_json(synthetic_manifest())) - .expect(1) - .mount(&server) - .await; - - let url = format!("{}/manifest.json", server.uri()); - let manifest = fetch_version_manifest_from(&HttpClient::new(), &url) - .await - .expect("manifest after retry"); - assert_eq!(manifest.versions.len(), 2); -} diff --git a/tests/net_retry.rs b/tests/net_retry.rs index 1b5d635..0d70804 100644 --- a/tests/net_retry.rs +++ b/tests/net_retry.rs @@ -4,11 +4,14 @@ // 4 attempts) is honoured. these tests exercise public HttpClient methods, // not the private retry helper directly. // -// note: get_with_retry sleeps between attempts (500ms, 1000ms, 2000ms), -// so the gives-up-after-max-retries test takes about 3.5s of wall time. -// nothing to be done about that without making the delays configurable. +// Tokio time is paused in retrying tests so the production backoff remains +// covered without adding wall-clock delay to the suite. use std::path::PathBuf; +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; use serde::Deserialize; use serde_json::json; @@ -22,29 +25,33 @@ struct ApiResponse { ok: bool, } +fn client_without_timeout() -> HttpClient { + reqwest::Client::builder().build().unwrap().into() +} + // ---------- get_json (via get_with_retry) ---------- -#[tokio::test] +#[tokio::test(start_paused = true)] async fn get_json_retries_5xx_then_succeeds() { let server = MockServer::start().await; + let attempts = Arc::new(AtomicUsize::new(0)); - // first request: 503. second and beyond: 200. - Mock::given(method("GET")) - .and(path("/api")) - .respond_with(ResponseTemplate::new(503)) - .up_to_n_times(1) - .expect(1) - .mount(&server) - .await; + // first request: 503. second request: 200. Mock::given(method("GET")) .and(path("/api")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) + .respond_with(move |_: &wiremock::Request| { + if attempts.fetch_add(1, Ordering::SeqCst) == 0 { + ResponseTemplate::new(503) + } else { + ResponseTemplate::new(200).set_body_json(json!({"ok": true})) + } + }) + .expect(2) .mount(&server) .await; let url = format!("{}/api", server.uri()); - let result: ApiResponse = HttpClient::new().get_json(&url).await.unwrap(); + let result: ApiResponse = client_without_timeout().get_json(&url).await.unwrap(); assert!(result.ok); } @@ -72,7 +79,7 @@ async fn get_json_fails_fast_on_4xx() { ); } -#[tokio::test] +#[tokio::test(start_paused = true)] async fn get_json_gives_up_after_max_retries() { let server = MockServer::start().await; @@ -86,7 +93,7 @@ async fn get_json_gives_up_after_max_retries() { .await; let url = format!("{}/api", server.uri()); - let err = HttpClient::new() + let err = client_without_timeout() .get_json::(&url) .await .unwrap_err(); @@ -96,23 +103,41 @@ async fn get_json_gives_up_after_max_retries() { ); } -// ---------- download_file ---------- - #[tokio::test] -async fn download_file_retries_5xx_then_succeeds() { +async fn get_bytes_limited_rejects_oversized_responses() { let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/file.bin")) - .respond_with(ResponseTemplate::new(502)) - .up_to_n_times(1) + .and(path("/large.bin")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(vec![0; 9])) .expect(1) .mount(&server) .await; + + let url = format!("{}/large.bin", server.uri()); + let error = client_without_timeout() + .get_bytes_limited(&url, 8) + .await + .unwrap_err(); + assert!(error.to_string().contains("8-byte limit")); +} + +// ---------- download_file ---------- + +#[tokio::test(start_paused = true)] +async fn download_file_retries_5xx_then_succeeds() { + let server = MockServer::start().await; + let attempts = Arc::new(AtomicUsize::new(0)); + Mock::given(method("GET")) .and(path("/file.bin")) - .respond_with(ResponseTemplate::new(200).set_body_bytes(b"hello, retried".to_vec())) - .expect(1) + .respond_with(move |_: &wiremock::Request| { + if attempts.fetch_add(1, Ordering::SeqCst) == 0 { + ResponseTemplate::new(502) + } else { + ResponseTemplate::new(200).set_body_bytes(b"hello, retried".to_vec()) + } + }) + .expect(2) .mount(&server) .await; @@ -120,7 +145,7 @@ async fn download_file_retries_5xx_then_succeeds() { let dest = tmp.path().join("downloaded.bin"); let url = format!("{}/file.bin", server.uri()); - download_file(&HttpClient::new(), &url, &dest, |_, _| {}) + download_file(&client_without_timeout(), &url, &dest, |_, _| {}) .await .unwrap(); diff --git a/tests/screenshots.rs b/tests/screenshots.rs index 699c104..5b9cd12 100644 --- a/tests/screenshots.rs +++ b/tests/screenshots.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; use rmcl::instance::screenshots::scan_screenshots; fn setup_screenshots_dir(tmp: &Path, instance: &str) -> PathBuf { - let dir = tmp.join(instance).join(".minecraft").join("screenshots"); + let dir = tmp.join(instance).join("minecraft").join("screenshots"); std::fs::create_dir_all(&dir).unwrap(); dir } @@ -32,16 +32,6 @@ fn scan_screenshots_missing_dir_returns_empty() { assert!(screenshots.is_empty()); } -#[test] -fn scan_screenshots_finds_images() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_screenshots_dir(tmp.path(), "inst"); - std::fs::write(dir.join("2024-01-01.png"), tiny_png()).unwrap(); - std::fs::write(dir.join("2024-01-02.png"), tiny_png()).unwrap(); - let screenshots = scan_screenshots(tmp.path(), "inst"); - assert_eq!(screenshots.len(), 2); -} - #[test] fn scan_screenshots_ignores_non_images() { let tmp = tempfile::tempdir().unwrap(); diff --git a/tests/smoke.rs b/tests/smoke.rs deleted file mode 100644 index 97384f2..0000000 --- a/tests/smoke.rs +++ /dev/null @@ -1,10 +0,0 @@ -// verifies the lib/main split worked: integration tests can see crate items -// through the public API. - -#[test] -fn lib_target_is_importable() { - // touch one pure function from each major module so the linker fails if - // any module went private by mistake during the split. - assert!(rmcl::net::maven_coord_to_path("a:b:1.0").is_some()); - let _ = rmcl::config::SETTINGS.paths.effective_java_path(); -}