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