From 14b25ce580255a53b90534a7b13708a3f8a5cdf2 Mon Sep 17 00:00:00 2001 From: Eric Date: Sat, 11 Apr 2026 18:22:19 +0200 Subject: [PATCH] add CLI binary for conflict detection (v0.6.0) Adds a headless CLI tool (red4-conflicts-cli) that reuses the GUI's conflict detection logic via a shared scanner module. Features: - Auto-detects Cyberpunk 2077 install (Steam/GOG) - JSON and TOON (experimental) output formats - --summary mode for LLM-friendly compact overview - --mod-filter / --file-filter with regex support - --show-no-conflicts / --show-load-order opt-in flags - CI builds and releases both GUI and CLI binaries Core logic extracted from TemplateApp into scanner.rs, shared by both binaries via Cargo feature gates (gui/cli). --- .github/workflows/check.yml | 5 + .github/workflows/release.yml | 24 ++ README.md | 33 ++- red4-conflicts/Cargo.lock | 419 +++++++++++++++++++++++++--- red4-conflicts/Cargo.toml | 47 +++- red4-conflicts/src/cli.rs | 265 ++++++++++++++++++ red4-conflicts/src/lib.rs | 224 ++------------- red4-conflicts/src/scanner.rs | 506 ++++++++++++++++++++++++++++++++++ 8 files changed, 1279 insertions(+), 244 deletions(-) create mode 100644 red4-conflicts/src/cli.rs create mode 100644 red4-conflicts/src/scanner.rs diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 8552148..35d3797 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -28,6 +28,11 @@ jobs: run: | cd ${{ matrix.target }} cargo build --${{ matrix.build_type }} + - name: Build CLI + if: matrix.target == 'red4-conflicts' + run: | + cd red4-conflicts + cargo build --${{ matrix.build_type }} --features cli --no-default-features - name: Run tests run: | cd ${{ matrix.target }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1c068b2..04e824a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,6 +31,11 @@ jobs: run: | cd ${{ matrix.target }} cargo build --${{ matrix.build_type }} + - name: Build CLI + if: matrix.target == 'red4-conflicts' + run: | + cd red4-conflicts + cargo build --${{ matrix.build_type }} --features cli --no-default-features - name: Run tests run: | cd ${{ matrix.target }} @@ -42,12 +47,31 @@ jobs: name: ${{ matrix.target }} path: ${{ matrix.target }}/target/${{ matrix.build_type }}/${{ matrix.target }}.exe + - name: Upload CLI Artifact + if: matrix.target == 'red4-conflicts' + uses: actions/upload-artifact@v4 + with: + name: red4-conflicts-cli + path: red4-conflicts/target/${{ matrix.build_type }}/red4-conflicts-cli.exe + - name: zip run: Compress-Archive -Path "${{ matrix.target }}/target/${{ matrix.build_type }}/${{ matrix.target }}.exe" -DestinationPath "${{ matrix.target }}.zip" + - name: zip CLI + if: matrix.target == 'red4-conflicts' + run: Compress-Archive -Path "red4-conflicts/target/${{ matrix.build_type }}/red4-conflicts-cli.exe" -DestinationPath "red4-conflicts-cli.zip" + - name: Upload to release uses: ncipollo/release-action@v1 with: artifacts: "${{ matrix.target }}.zip" allowUpdates: true tag: "latest" + + - name: Upload CLI to release + if: matrix.target == 'red4-conflicts' + uses: ncipollo/release-action@v1 + with: + artifacts: "red4-conflicts-cli.zip" + allowUpdates: true + tag: "latest" diff --git a/README.md b/README.md index f0bae7d..3cdd79a 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,41 @@ Some utility tools for Cyberpunk 2077 modding. > Nexus Mods link: https://www.nexusmods.com/cyberpunk2077/mods/11126 -A conflict-checker app for Cyberpunk 2077 archives. +A conflict-checker app for Cyberpunk 2077 archives. Available as both a GUI app and a headless CLI tool. -### Usage +### Using the GUI - download and extract - run `red4-conflicts.exe` and specify a folder with archives to check +### Using the CLI + +The CLI outputs structured JSON for use with LLMs or other tools. It auto-detects your game installation (Steam/GOG). + +```cmd +Usage: red4-conflicts-cli.exe [OPTIONS] [ARCHIVE_PATH] + +Options: + --pretty Pretty-print the JSON output + --toon Output in TOON format (experimental, fewer tokens for LLM input) + --summary Compact overview instead of full report + --mod-filter Filter archives by name (supports regex) + --file-filter Filter files by path (supports regex) + --show-no-conflicts Include non-conflicting files + --show-load-order Include the full load order list + -h, --help Print help +``` + +```cmd +# Get a summary of all conflicts +red4-conflicts-cli --summary --pretty + +# Filter for a specific mod +red4-conflicts-cli --mod-filter "ArchiveXL" --pretty + +# Save full report to file +red4-conflicts-cli --pretty > conflicts.json +``` + ### Screenshots ![screenshot](./assets/red4_conflicts_02.png) diff --git a/red4-conflicts/Cargo.lock b/red4-conflicts/Cargo.lock index 4436aac..6de1c22 100644 --- a/red4-conflicts/Cargo.lock +++ b/red4-conflicts/Cargo.lock @@ -58,6 +58,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "android-activity" version = "0.6.0" @@ -85,6 +94,62 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + [[package]] name = "arbitrary" version = "1.4.2" @@ -200,7 +265,7 @@ dependencies = [ "polling", "rustix 1.1.2", "slab", - "windows-sys 0.61.0", + "windows-sys 0.61.2", ] [[package]] @@ -269,7 +334,7 @@ dependencies = [ "rustix 1.1.2", "signal-hook-registry", "slab", - "windows-sys 0.61.0", + "windows-sys 0.61.2", ] [[package]] @@ -307,6 +372,21 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bitflags" version = "1.3.2" @@ -362,6 +442,17 @@ dependencies = [ "piper", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "bumpalo" version = "3.19.0" @@ -500,6 +591,46 @@ dependencies = [ "inout", ] +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "clipboard-win" version = "5.4.1" @@ -518,6 +649,21 @@ dependencies = [ "cc", ] +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "combine" version = "4.6.7" @@ -528,6 +674,17 @@ dependencies = [ "memchr", ] +[[package]] +name = "comfy-table" +version = "7.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47" +dependencies = [ + "crossterm", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "concat-idents" version = "1.1.5" @@ -648,6 +805,29 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.9.4", + "crossterm_winapi", + "document-features", + "parking_lot", + "rustix 1.1.2", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -981,7 +1161,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.0", + "windows-sys 0.61.2", ] [[package]] @@ -1011,6 +1191,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fancy-regex" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -1502,6 +1693,18 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "jni" version = "0.21.1" @@ -1569,7 +1772,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "windows-link 0.2.0", + "windows-link 0.2.1", ] [[package]] @@ -1739,9 +1942,9 @@ checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-traits" @@ -1774,6 +1977,15 @@ dependencies = [ "syn", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "objc-sys" version = "0.3.5" @@ -2052,6 +2264,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "open" version = "5.3.2" @@ -2215,7 +2433,7 @@ dependencies = [ "hermit-abi", "pin-project-lite", "rustix 1.1.2", - "windows-sys 0.61.0", + "windows-sys 0.61.2", ] [[package]] @@ -2348,17 +2566,22 @@ checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" [[package]] name = "red4-conflicts" -version = "0.5.0" +version = "0.6.0" dependencies = [ + "clap", "eframe", "egui", "egui_dnd", "log", "open", "red4lib", + "regex", "rfd", "serde", + "serde_json", "simple-logging", + "simple_logger", + "toon-format", ] [[package]] @@ -2401,6 +2624,35 @@ dependencies = [ "bitflags 2.9.4", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "rfd" version = "0.15.4" @@ -2438,6 +2690,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustix" version = "0.38.44" @@ -2461,7 +2719,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.61.0", + "windows-sys 0.61.2", ] [[package]] @@ -2493,9 +2751,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "serde" -version = "1.0.226" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dca6411025b24b60bfa7ec1fe1f8e710ac09782dca409ee8237ba74b51295fd" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", "serde_derive", @@ -2503,24 +2761,38 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.226" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba2ba63999edb9dac981fb34b3e5c0d111a69b0924e253ed29d83f7c99e966a4" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.226" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8db53ae22f34573731bafa1db20f04027b2d25e02d8205921b569171699cdb33" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", "syn", ] +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -2581,6 +2853,18 @@ dependencies = [ "thread-id", ] +[[package]] +name = "simple_logger" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7038d0e96661bf9ce647e1a6f6ef6d6f3663f66d9bf741abf14ba4876071c17" +dependencies = [ + "colored", + "log", + "time", + "windows-sys 0.61.2", +] + [[package]] name = "slab" version = "0.4.11" @@ -2659,6 +2943,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "strum" version = "0.27.2" @@ -2715,7 +3005,7 @@ dependencies = [ "getrandom", "once_cell", "rustix 1.1.2", - "windows-sys 0.61.0", + "windows-sys 0.61.2", ] [[package]] @@ -2729,11 +3019,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.16" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.16", + "thiserror-impl 2.0.18", ] [[package]] @@ -2749,9 +3039,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.16" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", @@ -2783,24 +3073,53 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "tiktoken-rs" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a19830747d9034cd9da43a60eaa8e552dfda7712424aebf187b7a60126bae0d" +dependencies = [ + "anyhow", + "base64", + "bstr", + "fancy-regex", + "lazy_static", + "regex", + "rustc-hash", +] + [[package]] name = "time" -version = "0.3.44" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", + "itoa", + "libc", "num-conv", + "num_threads", "powerfmt", - "serde", + "serde_core", "time-core", + "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] [[package]] name = "tinystr" @@ -2842,6 +3161,22 @@ dependencies = [ "winnow", ] +[[package]] +name = "toon-format" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900f7d7bd2dec1338bf78c4eda6cf8bf76d0fbb7c7defa5bd07b66e35c632938" +dependencies = [ + "anyhow", + "clap", + "comfy-table", + "indexmap", + "serde", + "serde_json", + "thiserror 2.0.18", + "tiktoken-rs", +] + [[package]] name = "tracing" version = "0.1.41" @@ -2908,6 +3243,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "url" version = "2.5.7" @@ -2932,6 +3273,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "version_check" version = "0.9.5" @@ -3198,7 +3545,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.0", + "windows-sys 0.61.2", ] [[package]] @@ -3215,9 +3562,9 @@ checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" [[package]] name = "windows-link" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-sys" @@ -3257,11 +3604,11 @@ dependencies = [ [[package]] name = "windows-sys" -version = "0.61.0" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e201184e40b2ede64bc2ea34968b28e33622acdbbf37104f0e4a33f7abe657aa" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link 0.2.0", + "windows-link 0.2.1", ] [[package]] @@ -3768,7 +4115,7 @@ dependencies = [ "memchr", "pbkdf2", "sha1", - "thiserror 2.0.16", + "thiserror 2.0.18", "time", "xz2", "zeroize", @@ -3776,6 +4123,12 @@ dependencies = [ "zstd", ] +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + [[package]] name = "zopfli" version = "0.8.2" diff --git a/red4-conflicts/Cargo.toml b/red4-conflicts/Cargo.toml index 8b5dd3a..07295fb 100644 --- a/red4-conflicts/Cargo.toml +++ b/red4-conflicts/Cargo.toml @@ -1,24 +1,45 @@ [package] name = "red4-conflicts" -version = "0.5.0" +version = "0.6.0" edition = "2021" +[features] +default = ["gui"] +gui = ["dep:egui", "dep:eframe", "dep:egui_dnd", "dep:rfd", "dep:open", "dep:simple-logging"] +cli = ["dep:clap", "dep:serde_json", "dep:simple_logger", "dep:regex", "dep:toon-format"] + [dependencies] -egui = "0.32" -eframe = { version = "0.32", default-features = false, features = [ - "default_fonts", # Embed the default egui fonts. - "glow", # Use the glow rendering backend. Alternative: "wgpu". - "persistence", # Enable restoring app state when restarting the app. -] } log = "0.4" -rfd = "0.15" serde = { version = "1", features = ["derive"] } -simple-logging = "2.0" -open = "5.3" -egui_dnd = "0.13" -[patch.crates-io] +# GUI-only dependencies +egui = { version = "0.32", optional = true } +eframe = { version = "0.32", default-features = false, features = [ + "default_fonts", + "glow", + "persistence", +], optional = true } +egui_dnd = { version = "0.13", optional = true } +rfd = { version = "0.15", optional = true } +open = { version = "5.3", optional = true } +simple-logging = { version = "2.0", optional = true } + +# CLI-only dependencies +clap = { version = "4.5", features = ["derive"], optional = true } +serde_json = { version = "1.0", optional = true } +simple_logger = { version = "5", optional = true } +regex = { version = "1", optional = true } +toon-format = { version = "0.2", optional = true } [dependencies.red4lib] git = "https://github.com/rfuzzo/red4lib" -#path = "D:\\GitHub\\__rfuzzo\\red4lib" + +[[bin]] +name = "red4-conflicts" +path = "src/main.rs" +required-features = ["gui"] + +[[bin]] +name = "red4-conflicts-cli" +path = "src/cli.rs" +required-features = ["cli"] diff --git a/red4-conflicts/src/cli.rs b/red4-conflicts/src/cli.rs new file mode 100644 index 0000000..5f21a7c --- /dev/null +++ b/red4-conflicts/src/cli.rs @@ -0,0 +1,265 @@ +use std::path::{Path, PathBuf}; + +use clap::Parser; +use regex::RegexBuilder; +use red4_conflicts::scanner; + +#[derive(Parser)] +#[command( + name = "red4-conflicts-cli", + version, + about = "Detect file-level conflicts between Cyberpunk 2077 mod archives and output a structured JSON report.", + after_long_help = "\ +EXAMPLES: + # Auto-detect game path and output compact JSON + red4-conflicts-cli + + # Specify path explicitly + red4-conflicts-cli \"C:\\Games\\Cyberpunk 2077\\archive\\pc\\mod\" + + # Pretty-print and save to file + red4-conflicts-cli --pretty > conflicts.json + + # Output in TOON format (compact, optimized for LLM token usage) + red4-conflicts-cli --toon > conflicts.txt + + # Filter to a specific mod + red4-conflicts-cli --mod-filter \"ArchiveXL\" + + # Filter conflicting files by regex (e.g. all .mesh files) + red4-conflicts-cli --file-filter \"\\.mesh$\" + + # Combine filters + red4-conflicts-cli --mod-filter \"hair\" --file-filter \"\\.mesh$\" --pretty" +)] +struct Cli { + /// Path to the folder containing .archive mod files. + /// + /// If omitted, the tool auto-detects the Cyberpunk 2077 installation + /// by searching standard Steam and GOG install locations: + /// + /// Steam: C:\Program Files (x86)\Steam\steamapps\common\Cyberpunk 2077 + /// Steam: D:\SteamLibrary\steamapps\common\Cyberpunk 2077 (D-G drives) + /// GOG: C:\Program Files (x86)\GOG Galaxy\Games\Cyberpunk 2077 + /// GOG: C:\GOG Games\Cyberpunk 2077 + /// + /// The path should point to the "archive\pc\mod" folder inside the game directory. + archive_path: Option, + + /// Pretty-print the JSON output. + #[arg(long, default_value_t = false)] + pretty: bool, + + /// Output in TOON format (Token Oriented Object Notation) instead of JSON. + /// Experimental — uses ~40% fewer tokens than JSON, optimized for LLM input. + /// When enabled, --pretty is ignored. + #[arg(long, default_value_t = false)] + toon: bool, + + /// Filter archives by name. Plain strings match as substrings (case-insensitive). + /// Regex syntax is also supported (e.g. "^ArchiveXL" or ".*hair.*"). + #[arg(long, value_name = "PATTERN")] + mod_filter: Option, + + /// Filter conflicting file entries by path. Plain strings match as substrings + /// (case-insensitive). Regex syntax is also supported (e.g. "\\.mesh$"). + #[arg(long, value_name = "PATTERN")] + file_filter: Option, + + /// Output a compact summary instead of the full report. + /// One line per conflicting archive with win/loss counts and a + /// fully_overridden flag for mods that have zero effect in-game. + /// Use --mod-filter to drill into specific mods for full details. + #[arg(long, default_value_t = false)] + summary: bool, + + /// Include non-conflicting files in each archive's report. + /// By default, only winning and losing files are shown (matching the GUI default). + #[arg(long, default_value_t = false)] + show_no_conflicts: bool, + + /// Include the full load order list in the output. + /// By default it is omitted to reduce output size — each archive already + /// includes its load_order_index for ordering context. + #[arg(long, default_value_t = false)] + show_load_order: bool, +} + +/// Search standard Cyberpunk 2077 install locations and return the mod archive path. +fn detect_game_path() -> Option { + let suffix = Path::new("Cyberpunk 2077") + .join("archive") + .join("pc") + .join("mod"); + + let mut candidates: Vec = vec![ + // Steam default + PathBuf::from(r"C:\Program Files (x86)\Steam\steamapps\common").join(&suffix), + PathBuf::from(r"C:\Program Files\Steam\steamapps\common").join(&suffix), + // GOG + PathBuf::from(r"C:\Program Files (x86)\GOG Galaxy\Games").join(&suffix), + PathBuf::from(r"C:\GOG Games").join(&suffix), + ]; + + // Steam libraries on common drive letters + for drive in ['D', 'E', 'F', 'G'] { + candidates.push( + PathBuf::from(format!("{}:\\SteamLibrary\\steamapps\\common", drive)).join(&suffix), + ); + } + + candidates.into_iter().find(|p| p.exists()) +} + +fn main() { + let _ = simple_logger::init_with_level(log::Level::Warn); + + let cli = Cli::parse(); + + // Resolve archive path + let archive_path = match cli.archive_path { + Some(path) => { + if !path.exists() { + eprintln!("Error: path does not exist: {}", path.display()); + std::process::exit(1); + } + path + } + None => match detect_game_path() { + Some(path) => { + eprintln!("Auto-detected game path: {}", path.display()); + path + } + None => { + eprintln!("Error: could not auto-detect Cyberpunk 2077 installation."); + eprintln!("Please provide the path to your archive/pc/mod folder as an argument."); + eprintln!("Run with --help for details."); + std::process::exit(1); + } + }, + }; + + // Compile filters + let mod_regex = cli.mod_filter.as_ref().map(|pattern| { + RegexBuilder::new(pattern) + .case_insensitive(true) + .build() + .unwrap_or_else(|e| { + eprintln!("Error: invalid --mod-filter regex \"{}\": {}", pattern, e); + std::process::exit(1); + }) + }); + + let file_regex = cli.file_filter.as_ref().map(|pattern| { + RegexBuilder::new(pattern) + .case_insensitive(true) + .build() + .unwrap_or_else(|e| { + eprintln!("Error: invalid --file-filter regex \"{}\": {}", pattern, e); + std::process::exit(1); + }) + }); + + eprintln!("Loading RED4 hash database..."); + let hashes = red4lib::get_red4_hashes(); + + eprintln!("Building load order..."); + let load_order = scanner::build_load_order(&archive_path); + + eprintln!( + "Scanning {} archives for conflicts...", + load_order.len() + ); + let scan = scanner::scan_conflicts(&load_order, &archive_path, None); + + if cli.summary { + eprintln!("Building summary..."); + let mut summary = scanner::build_summary(&scan, &load_order, &archive_path); + + if let Some(ref re) = mod_regex { + summary.archives.retain(|a| re.is_match(&a.name)); + } + + eprintln!( + "Found {} conflicting files across {} archives ({} fully overridden).", + summary.total_conflicting_files, + summary.total_archives, + summary.fully_overridden_count + ); + + let output = serialize(&summary, cli.toon, cli.pretty); + print_output(output); + return; + } + + eprintln!("Building report..."); + let mut report = scanner::build_report( + &scan, + &load_order, + &archive_path, + &hashes, + cli.show_no_conflicts, + cli.show_load_order, + ); + + // Apply mod filter — remove archives that don't match + if let Some(ref re) = mod_regex { + report.archives.retain(|a| re.is_match(&a.name)); + } + + // Apply file filter — remove non-matching file entries within each archive + if let Some(ref re) = file_regex { + for archive in &mut report.archives { + archive.winning_files.retain(|f| re.is_match(&f.file)); + archive.losing_files.retain(|f| re.is_match(&f.file)); + if let Some(ref mut files) = archive.no_conflict_files { + files.retain(|f| re.is_match(f)); + } + } + report.file_conflicts.retain(|f| re.is_match(&f.file)); + } + + eprintln!( + "Found {} conflicting files across {} archives.", + report.total_conflicting_files, report.total_archives + ); + + let output = serialize(&report, cli.toon, cli.pretty); + print_output(output); + +} + +fn serialize(data: &T, toon: bool, pretty: bool) -> Result { + if toon { + serde_json::to_value(data) + .map_err(|e| e.to_string()) + .and_then(|v| { + let encoded = toon_format::encode_default(&v) + .map_err(|e| e.to_string())?; + let validation: Result = + toon_format::decode_default(&encoded); + if let Err(e) = validation { + eprintln!( + "Warning: TOON round-trip validation failed: {}. \ + Output may not parse correctly with all TOON decoders.", + e + ); + } + Ok(encoded) + }) + } else if pretty { + serde_json::to_string_pretty(data).map_err(|e| e.to_string()) + } else { + serde_json::to_string(data).map_err(|e| e.to_string()) + } +} + +fn print_output(output: Result) { + match output { + Ok(s) => println!("{}", s), + Err(e) => { + eprintln!("Failed to serialize: {}", e); + std::process::exit(1); + } + } +} diff --git a/red4-conflicts/src/lib.rs b/red4-conflicts/src/lib.rs index fe44de2..06ebd5b 100644 --- a/red4-conflicts/src/lib.rs +++ b/red4-conflicts/src/lib.rs @@ -1,41 +1,28 @@ #![warn(clippy::all, rust_2018_idioms)] -use log::error; -use red4lib::fnv1a64_hash_path; -use std::fs::{self, File}; -use std::io::{self, BufRead, BufReader, Write}; -use std::path::Path; -use std::{collections::HashMap, path::PathBuf}; +pub mod scanner; +#[cfg(feature = "gui")] mod app; +// Re-export so app.rs can still use `crate::ArchiveViewModel` +pub use scanner::ArchiveViewModel; + +#[cfg(feature = "gui")] const CARGO_PKG_VERSION: &str = env!("CARGO_PKG_VERSION"); +#[cfg(feature = "gui")] const CARGO_PKG_NAME: &str = env!("CARGO_PKG_NAME"); -#[derive(Clone)] -struct ArchiveViewModel { - pub file_name: String, - /// winning file hashes - pub wins: Vec, - /// losing file hashes - pub loses: Vec, - /// all file hashes - pub files: Vec, -} - -impl ArchiveViewModel { - pub fn get_no_conflicts(&self) -> Vec { - let result: Vec = self - .files - .iter() - .filter(|&x| !self.wins.contains(x)) - .filter(|&x| !self.loses.contains(x)) - .cloned() - .collect(); - result - } -} - +#[cfg(feature = "gui")] +use log::error; +#[cfg(feature = "gui")] +use std::io::Write; +#[cfg(feature = "gui")] +use std::path::PathBuf; +#[cfg(feature = "gui")] +use std::collections::HashMap; + +#[cfg(feature = "gui")] #[derive(Default, serde::Deserialize, serde::Serialize, Debug, PartialEq)] enum ETooltipVisuals { Tooltip, @@ -45,8 +32,9 @@ enum ETooltipVisuals { } /// We derive Deserialize/Serialize so we can persist app state on shutdown. +#[cfg(feature = "gui")] #[derive(Default, serde::Deserialize, serde::Serialize)] -#[serde(default)] // if we add new fields, give them default values when deserializing old state +#[serde(default)] pub struct TemplateApp { game_path: PathBuf, // UI @@ -79,14 +67,11 @@ pub struct TemplateApp { file_filter: String, } +#[cfg(feature = "gui")] impl TemplateApp { /// Called once before the first frame. pub fn new(cc: &eframe::CreationContext<'_>) -> Self { - // This is also where you can customize the look and feel of egui using - // `cc.egui_ctx.set_visuals` and `cc.egui_ctx.set_fonts`. - // Load previous app state (if any). - // Note that you must enable the `persistence` feature for this to work. if let Some(storage) = cc.storage { return eframe::get_value(storage, eframe::APP_KEY).unwrap_or_default(); } @@ -97,91 +82,13 @@ impl TemplateApp { /// Returns the conflict map of this [`TemplateApp`]. Also sets archive and conflict maps fn generate_conflict_map(&mut self) { let old_archives = self.archives.clone(); - self.archives.clear(); - self.conflicts.clear(); - - let mut conflict_map: HashMap> = HashMap::default(); - - // scan - let mut mods = self.load_order.clone(); - mods.reverse(); - - for archive_name in mods.iter() { - let archive_file_path = &self.game_path.join(archive_name); - let archive_hash = fnv1a64_hash_path(archive_file_path); - log::info!("parsing {}", archive_file_path.display()); - - // read or get the archive - let mut archive_or_none: Option = None; - if let Some(archive) = old_archives.get(&archive_hash) { - // no need to read the file again - let mut empty_vm = archive.clone(); - // but clean it since we're calculating conflicts anew - empty_vm.wins.clear(); - empty_vm.loses.clear(); - - archive_or_none = Some(empty_vm); - } else if let Ok(archive) = red4lib::archive::open_read(archive_file_path) { - if let Some(archive_file_name) = - archive_file_path.file_name().and_then(|f| f.to_str()) - { - // conflicts - let mut hashes = archive - .get_entries() - .clone() - .into_keys() - .collect::>(); - hashes.sort(); - - let vm = ArchiveViewModel { - file_name: archive_file_name.to_owned(), - files: hashes.clone(), - wins: vec![], - loses: vec![], - }; - - archive_or_none = Some(vm); - } - } - - if let Some(mut archive_vm) = archive_or_none { - for hash in &archive_vm.files { - if let Some(archive_names) = conflict_map.get_mut(hash) { - // found a conflict - // update vms - // add this file to all previous archive's losing files - for archive in archive_names.iter() { - if !self.archives.get(archive).unwrap().loses.contains(hash) { - self.archives.get_mut(archive).unwrap().loses.push(*hash); - } - } - // add the current archive to the list of conflicting archives last - if !archive_names.contains(&archive_hash) { - archive_names.push(archive_hash); - } - // add this file to this mods winning files - if !archive_vm.wins.contains(hash) { - archive_vm.wins.push(*hash); - } - } else { - // first occurance - conflict_map.insert(*hash, vec![archive_hash]); - } - } - - self.archives.insert(archive_hash, archive_vm); - } - } - - // clean list - let mut conflicts: HashMap> = HashMap::default(); - for (hash, archives) in conflict_map.iter().filter(|p| p.1.len() > 1) { - // insert - conflicts.insert(*hash, archives.clone()); - } - - //temp_load_order.reverse(); - self.conflicts = conflicts; + let result = scanner::scan_conflicts( + &self.load_order, + &self.game_path, + Some(&old_archives), + ); + self.archives = result.archives; + self.conflicts = result.conflicts; } /// get modilist.txt path @@ -191,39 +98,7 @@ impl TemplateApp { /// Clear and regenerate load order pub fn reload_load_order(&mut self) { - self.load_order.clear(); - - let mut mods: Vec = get_files(&self.game_path, "archive"); - - // load order - mods.sort_by(|a, b| { - a.to_string_lossy() - .as_bytes() - .cmp(b.to_string_lossy().as_bytes()) - }); - - // load according to modlist.txt - let mut final_order: Vec = vec![]; - - if let Ok(lines) = read_file_to_vec(&self.get_modlist_path()) { - for name in lines { - let file_name = self.game_path.join(name); - if mods.contains(&file_name) { - final_order.push(file_name.to_owned()); - } - } - // add remaining mods last - for m in mods { - if !final_order.contains(&m) { - final_order.push(m); - } - } - } else { - final_order = mods; - } - // TODO Redmods - - self.load_order = pathbuf_to_string_vec(final_order); + self.load_order = scanner::build_load_order(&self.game_path); } fn serialize_load_order(&self) { @@ -246,46 +121,3 @@ impl TemplateApp { } } } - -fn read_file_to_vec(file_path: &PathBuf) -> io::Result> { - let file = File::open(file_path)?; - let reader = BufReader::new(file); - - let lines: Vec = reader.lines().map_while(Result::ok).collect(); - - Ok(lines) -} - -fn pathbuf_to_string_vec(paths: Vec) -> Vec { - paths - .into_iter() - .filter_map(|path| { - path.file_name() - .map(|filename| filename.to_string_lossy().into_owned()) - }) - .collect() -} - -/// Get top-level files of a folder with given extension -fn get_files(folder_path: &Path, extension: &str) -> Vec { - let mut files = Vec::new(); - if !folder_path.exists() { - return files; - } - - if let Ok(entries) = fs::read_dir(folder_path) { - for entry in entries.flatten() { - if let Ok(file_type) = entry.file_type() { - if file_type.is_file() { - if let Some(ext) = entry.path().extension() { - if ext == extension { - files.push(entry.path()); - } - } - } - } - } - } - - files -} diff --git a/red4-conflicts/src/scanner.rs b/red4-conflicts/src/scanner.rs new file mode 100644 index 0000000..ed0da0f --- /dev/null +++ b/red4-conflicts/src/scanner.rs @@ -0,0 +1,506 @@ +//! Shared conflict detection logic for both GUI and CLI. +//! No GUI dependencies in this module. + +use std::collections::HashMap; +use std::fs::{self, File}; +use std::io::{self, BufRead, BufReader}; +use std::path::{Path, PathBuf}; + +use red4lib::fnv1a64_hash_path; +use serde::Serialize; + +// --------------------------------------------------------------------------- +// Core data types +// --------------------------------------------------------------------------- + +#[derive(Clone)] +pub struct ArchiveViewModel { + pub file_name: String, + /// winning file hashes + pub wins: Vec, + /// losing file hashes + pub loses: Vec, + /// all file hashes + pub files: Vec, +} + +impl ArchiveViewModel { + pub fn get_no_conflicts(&self) -> Vec { + self.files + .iter() + .filter(|&x| !self.wins.contains(x)) + .filter(|&x| !self.loses.contains(x)) + .cloned() + .collect() + } +} + +pub struct ConflictScanResult { + pub archives: HashMap, + /// file_hash → vec of archive hashes (in load-priority order, last wins) + pub conflicts: HashMap>, +} + +// --------------------------------------------------------------------------- +// JSON report types +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +pub struct ConflictReport { + /// Scanned archive folder + pub archive_path: String, + /// Full load order list (first = lowest priority, last = highest). + /// Only included when explicitly requested. + #[serde(skip_serializing_if = "Option::is_none")] + pub load_order: Option>, + /// Total number of .archive files found + pub total_archives: usize, + /// Total number of files that appear in more than one archive + pub total_conflicting_files: usize, + /// Per-archive breakdown + pub archives: Vec, + /// Per-file conflict details (only files present in 2+ archives) + pub file_conflicts: Vec, +} + +#[derive(Serialize)] +pub struct ArchiveReport { + /// Archive file name + pub name: String, + /// 0-based position in load order (higher index = higher priority = wins) + pub load_order_index: usize, + /// Total files inside the archive + pub total_files: usize, + /// Number of files this archive wins (overrides others) + pub winning_count: usize, + /// Number of files this archive loses (overridden by a later archive) + pub losing_count: usize, + /// "no_conflicts" | "has_conflicts" | "fully_overridden" + pub status: String, + /// Files this archive wins — with names of archives it overrides + pub winning_files: Vec, + /// Files this archive loses — with name of the archive that overrides it + pub losing_files: Vec, + /// Files with no conflicts (only present when show_no_conflicts is enabled) + #[serde(skip_serializing_if = "Option::is_none")] + pub no_conflict_files: Option>, +} + +#[derive(Serialize)] +pub struct ConflictFileEntry { + /// Human-readable file path, or raw hash if unknown + pub file: String, + /// Archives involved in this specific conflict (excluding current archive) + pub conflicting_archives: Vec, +} + +#[derive(Serialize)] +pub struct FileConflict { + /// Human-readable file path, or raw hash if unknown + pub file: String, + /// All archives containing this file, ordered by load priority (last wins) + pub archives: Vec, + /// The archive that currently wins (highest in load order) + pub winner: String, +} + +// --------------------------------------------------------------------------- +// Summary report types +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +pub struct SummaryReport { + pub archive_path: String, + pub total_archives: usize, + pub total_conflicting_files: usize, + /// Number of mods that are completely overridden (every file lost, nothing wins) + pub fully_overridden_count: usize, + /// One entry per archive that has conflicts + pub archives: Vec, +} + +#[derive(Serialize)] +pub struct ArchiveSummary { + pub name: String, + pub load_order_index: usize, + pub total_files: usize, + pub winning_count: usize, + pub losing_count: usize, + /// true when every file in this archive is overridden and nothing wins — + /// the mod has zero effect in-game, which may indicate a load order problem + pub fully_overridden: bool, +} + +// --------------------------------------------------------------------------- +// Core functions +// --------------------------------------------------------------------------- + +/// Build load order from the archive folder. +/// Reads `modlist.txt` from `game_path` if present; remaining archives are +/// appended in binary-alphabetical order (matching game behaviour). +pub fn build_load_order(game_path: &Path) -> Vec { + let mut mods = get_files(game_path, "archive"); + mods.sort_by(|a, b| { + a.to_string_lossy() + .as_bytes() + .cmp(b.to_string_lossy().as_bytes()) + }); + + let mut final_order: Vec = Vec::new(); + + let modlist_path = game_path.join("modlist.txt"); + if let Ok(lines) = read_file_to_vec(&modlist_path) { + for name in lines { + let file_name = game_path.join(&name); + if mods.contains(&file_name) { + final_order.push(file_name); + } + } + // add remaining mods last + for m in &mods { + if !final_order.contains(m) { + final_order.push(m.clone()); + } + } + } else { + final_order = mods; + } + + pathbuf_to_string_vec(final_order) +} + +/// Scan archives and build the conflict map. +/// `load_order` is lowest-priority first, highest-priority last. +/// +/// When `old_archives` is `Some`, cached archive data is reused (with +/// wins/loses cleared) instead of re-reading archive files from disk. +/// The CLI passes `None` to always read fresh. +pub fn scan_conflicts( + load_order: &[String], + game_path: &Path, + old_archives: Option<&HashMap>, +) -> ConflictScanResult { + let mut archives: HashMap = HashMap::new(); + let mut conflict_map: HashMap> = HashMap::new(); + + // Iterate in reverse so the *last* archive processed is the highest-priority + let mut mods_reversed = load_order.to_vec(); + mods_reversed.reverse(); + + for archive_name in &mods_reversed { + let archive_file_path = game_path.join(archive_name); + let archive_hash = fnv1a64_hash_path(&archive_file_path); + log::info!("parsing {}", archive_file_path.display()); + + // Try cache first, then read from disk + let archive_vm = if let Some(old) = old_archives { + if let Some(cached) = old.get(&archive_hash) { + let mut vm = cached.clone(); + vm.wins.clear(); + vm.loses.clear(); + Some(vm) + } else { + read_archive_vm(&archive_file_path) + } + } else { + read_archive_vm(&archive_file_path) + }; + + let Some(mut vm) = archive_vm else { + log::warn!("Skipping {}", archive_name); + continue; + }; + + for hash in &vm.files { + if let Some(existing) = conflict_map.get_mut(hash) { + // Mark all previous archives as losing this file + for prev_hash in existing.iter() { + if let Some(prev) = archives.get_mut(prev_hash) { + if !prev.loses.contains(hash) { + prev.loses.push(*hash); + } + } + } + // Current archive wins this file + if !existing.contains(&archive_hash) { + existing.push(archive_hash); + } + if !vm.wins.contains(hash) { + vm.wins.push(*hash); + } + } else { + conflict_map.insert(*hash, vec![archive_hash]); + } + } + + archives.insert(archive_hash, vm); + } + + // Keep only entries with actual conflicts (2+ archives) + let conflicts: HashMap> = conflict_map + .into_iter() + .filter(|(_, v)| v.len() > 1) + .collect(); + + ConflictScanResult { + archives, + conflicts, + } +} + +/// Build a JSON-serializable report from scan results. +/// +/// When `show_no_conflicts` is true, each archive includes a list of files +/// that have no conflicts. Archives with zero wins and zero losses are always +/// excluded (matching GUI behaviour). +/// +/// When `include_load_order` is true, the full load order list is included +/// in the report. Otherwise it is omitted to reduce output size. +pub fn build_report( + scan: &ConflictScanResult, + load_order: &[String], + game_path: &Path, + hashes: &HashMap, + show_no_conflicts: bool, + include_load_order: bool, +) -> ConflictReport { + let resolve = |h: &u64| -> String { + hashes + .get(h) + .cloned() + .unwrap_or_else(|| h.to_string()) + }; + + let resolve_archive = |ah: &u64| -> String { + scan.archives + .get(ah) + .map(|a| a.file_name.clone()) + .unwrap_or_else(|| ah.to_string()) + }; + + // Per-archive reports, in load-order + let mut archive_reports = Vec::new(); + for (idx, name) in load_order.iter().enumerate() { + let path = game_path.join(name); + let key = fnv1a64_hash_path(&path); + + if let Some(vm) = scan.archives.get(&key) { + // Always skip archives with no conflicts (matching GUI) + if vm.wins.is_empty() && vm.loses.is_empty() { + continue; + } + + let winning_files: Vec = vm + .wins + .iter() + .map(|h| { + let conflicting: Vec = scan + .conflicts + .get(h) + .map(|arcs| { + arcs.iter() + .filter(|a| **a != key) + .map(|a| resolve_archive(a)) + .collect() + }) + .unwrap_or_default(); + ConflictFileEntry { + file: resolve(h), + conflicting_archives: conflicting, + } + }) + .collect(); + + let losing_files: Vec = vm + .loses + .iter() + .map(|h| { + let conflicting: Vec = scan + .conflicts + .get(h) + .map(|arcs| { + arcs.iter() + .filter(|a| **a != key) + .map(|a| resolve_archive(a)) + .collect() + }) + .unwrap_or_default(); + ConflictFileEntry { + file: resolve(h), + conflicting_archives: conflicting, + } + }) + .collect(); + + let status = if vm.wins.is_empty() && vm.files.len() == vm.loses.len() { + "fully_overridden" + } else { + "has_conflicts" + }; + + let no_conflict_files = if show_no_conflicts { + Some( + vm.get_no_conflicts() + .iter() + .map(|h| resolve(h)) + .collect(), + ) + } else { + None + }; + + archive_reports.push(ArchiveReport { + name: vm.file_name.clone(), + load_order_index: idx, + total_files: vm.files.len(), + winning_count: vm.wins.len(), + losing_count: vm.loses.len(), + status: status.to_string(), + winning_files, + losing_files, + no_conflict_files, + }); + } + } + + // Per-file conflicts + let mut file_conflicts: Vec = scan + .conflicts + .iter() + .map(|(file_hash, archive_hashes)| { + let archives_ordered: Vec = + archive_hashes.iter().map(|a| resolve_archive(a)).collect(); + let winner = archives_ordered.last().cloned().unwrap_or_default(); + FileConflict { + file: resolve(file_hash), + archives: archives_ordered, + winner, + } + }) + .collect(); + file_conflicts.sort_by(|a, b| a.file.cmp(&b.file)); + + ConflictReport { + archive_path: game_path.to_string_lossy().into_owned(), + total_archives: load_order.len(), + total_conflicting_files: scan.conflicts.len(), + load_order: if include_load_order { + Some(load_order.to_vec()) + } else { + None + }, + archives: archive_reports, + file_conflicts, + } +} + +/// Build a compact summary — one line per conflicting archive, no file details. +/// Designed to fit in an LLM context window even with thousands of mods. +pub fn build_summary( + scan: &ConflictScanResult, + load_order: &[String], + game_path: &Path, +) -> SummaryReport { + let mut summaries = Vec::new(); + + for (idx, name) in load_order.iter().enumerate() { + let path = game_path.join(name); + let key = fnv1a64_hash_path(&path); + + if let Some(vm) = scan.archives.get(&key) { + if vm.wins.is_empty() && vm.loses.is_empty() { + continue; + } + + let fully_overridden = + vm.wins.is_empty() && vm.files.len() == vm.loses.len(); + + summaries.push(ArchiveSummary { + name: vm.file_name.clone(), + load_order_index: idx, + total_files: vm.files.len(), + winning_count: vm.wins.len(), + losing_count: vm.loses.len(), + fully_overridden, + }); + } + } + + let fully_overridden_count = summaries.iter().filter(|a| a.fully_overridden).count(); + + SummaryReport { + archive_path: game_path.to_string_lossy().into_owned(), + total_archives: load_order.len(), + total_conflicting_files: scan.conflicts.len(), + fully_overridden_count, + archives: summaries, + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn read_archive_vm(archive_file_path: &Path) -> Option { + match red4lib::archive::open_read(archive_file_path) { + Ok(archive) => { + let file_name = archive_file_path + .file_name() + .and_then(|f| f.to_str()) + .map(|s| s.to_owned())?; + + let mut hashes: Vec = archive.get_entries().clone().into_keys().collect(); + hashes.sort(); + + Some(ArchiveViewModel { + file_name, + files: hashes, + wins: Vec::new(), + loses: Vec::new(), + }) + } + Err(e) => { + log::warn!("Failed to read {}: {}", archive_file_path.display(), e); + None + } + } +} + +pub fn get_files(folder_path: &Path, extension: &str) -> Vec { + let mut files = Vec::new(); + if !folder_path.exists() { + return files; + } + + if let Ok(entries) = fs::read_dir(folder_path) { + for entry in entries.flatten() { + if let Ok(file_type) = entry.file_type() { + if file_type.is_file() { + if let Some(ext) = entry.path().extension() { + if ext == extension { + files.push(entry.path()); + } + } + } + } + } + } + + files +} + +pub fn read_file_to_vec(file_path: &Path) -> io::Result> { + let file = File::open(file_path)?; + let reader = BufReader::new(file); + Ok(reader.lines().map_while(Result::ok).collect()) +} + +fn pathbuf_to_string_vec(paths: Vec) -> Vec { + paths + .into_iter() + .filter_map(|path| { + path.file_name() + .map(|filename| filename.to_string_lossy().into_owned()) + }) + .collect() +}