diff --git a/.gitignore b/.gitignore index 929aee3..133e4dd 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,8 @@ cars_repo.json tracks_repo.json times/ *.csv +ca/ +captures/ +capture.toml AGENTS.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 03aff7d..2cfcfe3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed - `plans/code-review-v2.md` (completed action items) +### Fixed +- `trash.csv` Config column now lists only the copies to delete: for cars where just one tune is useless (e.g. owned 332 + best-everywhere 323) only the dead tune is shown instead of all owned configs +- Cars the records site knows but has no times for (typically new prize cars, returned as `200` with an empty body) no longer abort `fetch` after 3 retries — they are skipped with an info log +- Empty timing files are now treated like missing files instead of producing `EOF while parsing a value` parse errors + ## [0.3.0] - 2026-05-07 ### Added diff --git a/Cargo.lock b/Cargo.lock index 6574a9e..a456d84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -11,6 +17,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "anstream" version = "1.0.0" @@ -67,6 +88,45 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -77,6 +137,29 @@ dependencies = [ "serde_json", ] +[[package]] +name = "async-compression" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -89,6 +172,29 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "base64" version = "0.22.1" @@ -101,7 +207,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", ] [[package]] @@ -110,12 +216,77 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -135,6 +306,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -144,6 +317,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core 0.10.1", +] + [[package]] name = "clap" version = "4.6.1" @@ -184,6 +368,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -199,6 +392,45 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", + "zstd", + "zstd-safe", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "core-foundation" version = "0.9.4" @@ -225,12 +457,67 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "dashmap" version = "6.1.0" @@ -245,6 +532,84 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -256,6 +621,18 @@ dependencies = [ "syn", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -265,6 +642,29 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "env_filter" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -281,6 +681,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -293,6 +713,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -329,6 +759,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures" version = "0.3.32" @@ -417,6 +853,31 @@ dependencies = [ "slab", ] +[[package]] +name = "generator" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -449,6 +910,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", ] @@ -544,6 +1006,43 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hudsucker" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a1d7c1c828ff6db12ddafb16768ecbac920df6301778d0eb78e95da0e44d94b" +dependencies = [ + "async-compression", + "bstr", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tungstenite", + "hyper-util", + "moka", + "rand 0.10.2", + "rcgen", + "thiserror", + "time", + "tokio", + "tokio-graceful", + "tokio-rustls", + "tokio-tungstenite", + "tokio-util", + "tracing", +] + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.9.0" @@ -575,10 +1074,12 @@ dependencies = [ "http", "hyper", "hyper-util", + "log", "rustls", "tokio", "tokio-rustls", "tower-service", + "webpki-roots 1.0.9", ] [[package]] @@ -597,6 +1098,21 @@ dependencies = [ "tower-service", ] +[[package]] +name = "hyper-tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aee7ba11c4933715ee2c2fea1d8932f19356d31648c0a1bb65223ef5d7962c4c" +dependencies = [ + "http-body-util", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tokio-tungstenite", + "tungstenite", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -743,6 +1259,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console", + "number_prefix", + "portable-atomic", + "unicode-width", + "web-time", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -761,6 +1290,52 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.2", + "libc", +] + [[package]] name = "js-sys" version = "0.3.97" @@ -773,6 +1348,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -812,6 +1393,41 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "pin-utils", + "scoped-tls", + "serde", + "serde_json", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + [[package]] name = "memchr" version = "2.8.0" @@ -824,6 +1440,22 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.0" @@ -852,7 +1484,7 @@ dependencies = [ "hyper-util", "log", "pin-project-lite", - "rand", + "rand 0.9.4", "regex", "serde_json", "serde_urlencoded", @@ -860,6 +1492,26 @@ dependencies = [ "tokio", ] +[[package]] +name = "moka" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4293f18e7567a1caf3c584855554377025c65e0aa445344d04171f5ad63d19b9" +dependencies = [ + "async-lock", + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "event-listener", + "futures-util", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + [[package]] name = "native-tls" version = "0.2.18" @@ -877,6 +1529,50 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -886,6 +1582,21 @@ dependencies = [ "autocfg", ] +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -904,7 +1615,7 @@ version = "0.10.79" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" dependencies = [ - "bitflags", + "bitflags 2.11.1", "cfg-if", "foreign-types", "libc", @@ -941,6 +1652,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -964,6 +1681,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -976,12 +1703,33 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "pkg-config" version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -991,6 +1739,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1026,10 +1780,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set", - "bit-vec", - "bitflags", + "bit-vec 0.8.0", + "bitflags 2.11.1", "num-traits", - "rand", + "rand 0.9.4", "rand_chacha", "rand_xorshift", "regex-syntax", @@ -1072,7 +1826,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", ] [[package]] @@ -1082,7 +1847,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", ] [[package]] @@ -1094,13 +1859,33 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_xorshift" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" dependencies = [ - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rcgen" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", ] [[package]] @@ -1109,7 +1894,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.1", ] [[package]] @@ -1195,13 +1980,22 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys", @@ -1214,6 +2008,8 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ + "aws-lc-rs", + "log", "once_cell", "rustls-pki-types", "rustls-webpki", @@ -1236,6 +2032,7 @@ version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -1274,6 +2071,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -1286,7 +2089,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.11.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -1352,6 +2155,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1364,12 +2176,48 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "similar" version = "2.7.0" @@ -1453,7 +2301,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags", + "bitflags 2.11.1", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -1468,6 +2316,12 @@ dependencies = [ "libc", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "tempfile" version = "3.27.0" @@ -1501,6 +2355,45 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -1522,11 +2415,25 @@ dependencies = [ "mio", "parking_lot", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-graceful" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45740b38b48641855471cd402922e89156bdfbd97b69b45eeff170369cc18c7d" +dependencies = [ + "loom", + "pin-project-lite", + "slab", + "tokio", + "tracing", +] + [[package]] name = "tokio-macros" version = "2.7.0" @@ -1558,6 +2465,22 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -1571,13 +2494,62 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "top_drives" version = "0.3.0" dependencies = [ + "base64", "clap", "dashmap", + "env_logger", "futures", + "http", + "http-body-util", + "hudsucker", + "indicatif", + "log", + "md-5", "mockito", "proptest", "reqwest", @@ -1585,7 +2557,9 @@ dependencies = [ "serde_json", "tempfile", "thiserror", + "time", "tokio", + "toml", "top_drives", ] @@ -1610,7 +2584,7 @@ version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28f0d049ccfaa566e14e9663d304d8577427b368cb4710a20528690287a738b" dependencies = [ - "bitflags", + "bitflags 2.11.1", "bytes", "futures-util", "http", @@ -1640,10 +2614,23 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -1651,6 +2638,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -1659,6 +2676,30 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.10.2", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unarray" version = "0.1.4" @@ -1671,6 +2712,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -1707,12 +2754,35 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcpkg" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wait-timeout" version = "0.2.1" @@ -1838,7 +2908,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.1", "hashbrown 0.15.5", "indexmap", "semver", @@ -1854,6 +2924,34 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -1898,6 +2996,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1971,6 +3078,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -2035,7 +3151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.1", "indexmap", "log", "serde", @@ -2071,6 +3187,34 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror", + "time", +] + +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec 0.9.1", + "time", +] + [[package]] name = "yoke" version = "0.8.2" @@ -2179,3 +3323,31 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index 2b8f724..264c0f7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,9 +7,16 @@ edition = "2021" name = "top_drives" path = "src/lib.rs" +# Bin-only (logger init in records.rs); required-features keeps the lib crate's +# downstream consumers free of env_logger unless they opt in. [[bin]] name = "records" path = "src/records.rs" +required-features = ["bin-logging"] + +[[bin]] +name = "capture" +path = "src/capture/main.rs" [dependencies] reqwest = "0.12.15" @@ -20,13 +27,26 @@ futures = "0.3.31" clap = { version = "4", features = ["derive"] } thiserror = "2" dashmap = "6" +log = "0.4" +env_logger = { version = "0.11", optional = true } +indicatif = "0.17" + +[target.'cfg(windows)'.dependencies] +hudsucker = "0.25" +http = "1" +http-body-util = "0.1" +md-5 = "0.10" +time = { version = "0.3", features = ["formatting"] } +base64 = "0.22" +toml = "0.8" [features] -default = [] +default = ["bin-logging", "test-utils"] +bin-logging = ["dep:env_logger"] test-utils = [] [dev-dependencies] -top_drives = { path = ".", features = ["test-utils"] } +top_drives = { path = ".", features = ["test-utils"], default-features = false } tempfile = "3.10.1" proptest = "1" mockito = "1" diff --git a/README.md b/README.md index 301e13c..0496117 100644 --- a/README.md +++ b/README.md @@ -112,18 +112,48 @@ cargo run --bin records -- best --min-count 5 ```bash cargo run --bin records -- trash ``` -Generates `trash.csv` — owned cars that are not best on any track, have RQ < 80, and are not prize cars. Sorted by RQ. +Generates `trash.csv` — owned cars that are not best on any track, have RQ < 80, and are not prize cars, plus low-value upgrade fodder: unfinished cars whose best config reaches at most a per-RQ-band track count (RQ 10-19: ≤4, RQ 20-29: ≤2, RQ 30-39: ≤1 by default). Sorted by RQ. **Output File:** ``` -trash.csv — Owned cars safe to remove +trash.csv — Owned cars safe to remove or use as fusion material +``` + +The band thresholds derive from a single base value (`--trash-base-count`, default 4); each higher band gets half of the previous one. The tool prints the effective thresholds and a garage-space projection (`user.garageSlots`/`garageSlotsUsed` from the export). When the projected free slots after clean-up fall below `--garage-min-free` (default 20), the base count is raised automatically — and printed — until the target is reached or no more cars qualify: + +```bash +cargo run --bin records -- trash --trash-base-count 5 --garage-min-free 30 +``` + +Pass `--trash-base-count 0` to disable the improve-based criteria entirely. + +#### Protect & Storage Analysis +```bash +cargo run --bin records -- protect +``` +Generates `protect.csv` — cars in the **garage** that are currently **not locked** and are worth locking more copies of — and `storage.csv`, a keep/trash recommendation for cars in the **storage** ("Lager", `state == 0` in the export). Both apply the same dynamic criteria (including garage-space escalation and its printed plan) as the trash command. + +**Output Files:** +``` +protect.csv — Unlocked garage cars worth locking +storage.csv — Keep/Trash recommendation for storage cars +``` + +`protect.csv` columns: `RQ,Brand,Model,Year,FinalBuild,Count,ToLock`. A car drops off the list once its locked copies cover every best config surviving the RQ-band thresholds; `ToLock` is the number of additional copies worth locking. Cars not best on any track never appear. + +`storage.csv` columns: `RQ,Brand,Model,Year,FinalBuild,Count,Recommendation`. Cars not best on any track, best only in configs at or below their RQ-band threshold, or with enough locked copies already (surplus unlocked copies are fusion material), are `Trash`; prize cars and RQ ≥ 80 are always `Keep`. + +The trash criteria flags apply here as well: + +```bash +cargo run --bin records -- protect --trash-base-count 5 --garage-min-free 30 ``` #### Wishlist Management ```bash cargo run --bin records -- wishlist ``` -Generates `wishlist.csv` (ideal wishlist: very_best unowned cars, capped at 300), `add.csv` (cars that should be on your wishlist but aren't), and `remove.csv` (cars on your wishlist that shouldn't be). All sorted by RQ. +Generates `wishlist.csv` (ideal wishlist: very_best unowned cars, capped at 300), `add.csv` (cars that should be on your wishlist but aren't), and `remove.csv` (cars on your wishlist that shouldn't be). All sorted by RQ. Prize cars are excluded from the ideal wishlist; prize cars still on your actual wishlist appear in `remove.csv`. **Output Files:** ``` @@ -146,7 +176,7 @@ cargo run --bin records -- wishlist --max-wishlist 500 ```bash cargo run --bin records -- all ``` -Generates all 7 CSV files in a single parse pass. Equivalent to running `best`, `trash`, `wishlist`, and `improve` together, but more efficient. +Generates all 10 CSV files in a single parse pass. Equivalent to running `best`, `trash`, `wishlist`, `improve`, and `protect` together, but more efficient. **Output Files:** ``` @@ -157,11 +187,13 @@ wishlist.csv — Ideal wishlist (very_best unowned, capped) add.csv — Cars to add to wishlist remove.csv — Cars to remove from wishlist improve.csv — Owned cars not yet at final build config +protect.csv — Unlocked garage cars worth locking +storage.csv — Keep/Trash recommendation for storage cars ``` -Supports both `--min-count N` (default 10) and `--max-wishlist M` (default 300): +Supports `--min-count N` (default 10), `--max-wishlist M` (default 300), and the trash criteria flags: ```bash -cargo run --bin records -- all --min-count 5 --max-wishlist 500 +cargo run --bin records -- all --min-count 5 --max-wishlist 500 --trash-base-count 4 --garage-min-free 20 ``` #### Detailed Performance Data @@ -202,9 +234,10 @@ The analyzer supports multiple track conditions: |---------|-------------|-----------| | `fetch` | Download latest car and track data | None | | `best` | Generate `best.csv` and `very_best.csv` | `[--min-count N]` | -| `trash` | Generate `trash.csv` (owned cars safe to remove) | None | +| `trash` | Generate `trash.csv` (owned cars safe to remove) | `[--trash-base-count N] [--garage-min-free N]` | +| `protect` | Generate `protect.csv` (unlocked garage cars) and `storage.csv` (storage keep/trash) | `[--trash-base-count N] [--garage-min-free N]` | | `wishlist` | Generate `wishlist.csv`, `add.csv`, `remove.csv` | `[--min-count N] [--max-wishlist M]` | -| `all` | Generate all 7 CSV files in a single pass | `[--min-count N] [--max-wishlist M]` | +| `all` | Generate all 10 CSV files in a single pass | `[--min-count N] [--max-wishlist M] [--trash-base-count N] [--garage-min-free N]` | | `details` | Print detailed timing data for all tracks (stdout) | None | | `track` | Print best car for specific track (stdout) | ` ` | | `improve` | Generate `improve.csv` (owned cars not at final build) | None | @@ -215,6 +248,8 @@ The analyzer supports multiple track conditions: |------|---------|-------------| | `--min-count N` | `10` | Minimum number of tracks a car must be best on to qualify as "very best" | | `--max-wishlist M` | `300` | Maximum number of cars on the ideal wishlist | +| `--trash-base-count N` | `4` | Maximum improve track count for RQ 10-19 to count as trash; RQ 20-29/30-39 get half/a quarter. `0` disables the criteria | +| `--garage-min-free N` | `20` | Minimum projected free garage slots; the trash base count is raised automatically when the projection falls short | ## 📅 Daily Workflow diff --git a/capture.toml.example b/capture.toml.example new file mode 100644 index 0000000..2bb2955 --- /dev/null +++ b/capture.toml.example @@ -0,0 +1,31 @@ +# Example configuration for `cargo run --bin capture`. +# Copy to capture.toml and adjust to your machine; capture.toml is gitignored. +# +# The capture binary is Windows-only (BlueStacks + adb interception); on Unix +# hosts it builds as a stub that exits with an error message. + +[device] +# BlueStacks ADB endpoint (BlueStacks Settings -> Advanced -> Android Debug Bridge) +serial = "127.0.0.1:5555" +# How long to wait for the emulator to come online before giving up +wait_secs = 180 +# Close BlueStacks automatically after the capture finished +shutdown_bluestacks = true + +[paths] +# Path to adb.exe (empty = auto-detect from LOCALAPPDATA, then PATH) +adb = 'C:\Users\\AppData\Local\Android\Sdk\platform-tools\adb.exe' +# Path to the BlueStacks player, started automatically if the device is offline +bluestacks = 'C:\Program Files\BlueStacks_nxt\HD-Player.exe' +# Where the captured garage document is written +output = 'my_cars.json' + +[proxy] +# Local port for the MITM proxy (must be free; close HTTP Toolkit first) +port = 8080 + +[capture] +# How long to wait for the game to boot and issue the sync request +timeout_secs = 300 +# Per-request timeout when re-issuing the stored sync request (`capture replay`) +replay_timeout_secs = 30 diff --git a/run_capture.cmd b/run_capture.cmd new file mode 100644 index 0000000..6c032ef --- /dev/null +++ b/run_capture.cmd @@ -0,0 +1,23 @@ +@echo off +setlocal +cd /d "%~dp0" + +if not exist capture.toml ( + echo No capture.toml found. + echo Copy capture.toml.example to capture.toml and adjust the paths first. + pause + exit /b 1 +) + +if not exist target\release\capture.exe ( + echo Building capture binary ... + cargo build --release --bin capture + if errorlevel 1 ( + pause + exit /b 1 + ) +) + +target\release\capture.exe run +echo. +pause diff --git a/src/capture/main.rs b/src/capture/main.rs new file mode 100644 index 0000000..85bc8d5 --- /dev/null +++ b/src/capture/main.rs @@ -0,0 +1,13 @@ +#[cfg(windows)] +mod windows; + +#[cfg(windows)] +fn main() { + windows::run(); +} + +#[cfg(not(windows))] +fn main() { + eprintln!("error: the capture binary is Windows-only (BlueStacks + ADB MITM interception)"); + std::process::exit(2); +} diff --git a/src/capture/windows.rs b/src/capture/windows.rs new file mode 100644 index 0000000..78ae50e --- /dev/null +++ b/src/capture/windows.rs @@ -0,0 +1,902 @@ +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use base64::Engine; +use http::request::Request; +use http_body_util::BodyExt; +use hudsucker::certificate_authority::RcgenAuthority; +use hudsucker::rcgen::{ + BasicConstraints, CertificateParams, DnType, IsCa, Issuer, KeyPair, KeyUsagePurpose, + PKCS_ECDSA_P256_SHA256, +}; +use hudsucker::{Body, HttpContext, HttpHandler, Proxy, RequestOrResponse}; +use md5::{Digest, Md5}; +use serde::{Deserialize, Serialize}; + +const GAME_PACKAGE: &str = "com.hutchgames.cccg"; +const CA_COMMON_NAME: &str = "Top Drives Capture CA"; +const GARAGE_MARKER: &[u8] = b"playerDeck"; +const CONFIG_FILE: &str = "capture.toml"; + +type Result = std::result::Result>; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(default)] +struct CaptureConfig { + device: DeviceConfig, + paths: PathsConfig, + proxy: ProxyConfig, + capture: CaptureSettings, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(default)] +struct DeviceConfig { + serial: String, + wait_secs: u64, + shutdown_bluestacks: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(default)] +struct PathsConfig { + adb: String, + bluestacks: String, + output: String, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(default)] +struct ProxyConfig { + port: u16, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(default)] +struct CaptureSettings { + timeout_secs: u64, + replay_timeout_secs: u64, +} + +impl Default for CaptureConfig { + fn default() -> Self { + Self { + device: DeviceConfig::default(), + paths: PathsConfig::default(), + proxy: ProxyConfig::default(), + capture: CaptureSettings::default(), + } + } +} + +impl Default for DeviceConfig { + fn default() -> Self { + Self { serial: "127.0.0.1:5555".to_string(), wait_secs: 180, shutdown_bluestacks: true } + } +} + +impl Default for PathsConfig { + fn default() -> Self { + Self { + adb: detect_adb(), + bluestacks: detect_bluestacks(), + output: "my_cars.json".to_string(), + } + } +} + +impl Default for ProxyConfig { + fn default() -> Self { + Self { port: 8080 } + } +} + +impl Default for CaptureSettings { + fn default() -> Self { + Self { timeout_secs: 300, replay_timeout_secs: 30 } + } +} + +fn detect_adb() -> String { + if let Ok(local_app_data) = std::env::var("LOCALAPPDATA") { + let candidate = format!("{local_app_data}\\Android\\Sdk\\platform-tools\\adb.exe"); + if Path::new(&candidate).exists() { + return candidate; + } + } + "adb".to_string() +} + +fn detect_bluestacks() -> String { + const CANDIDATES: [&str; 3] = [ + r"C:\Program Files\BlueStacks_nxt\HD-Player.exe", + r"C:\Program Files\BlueStacks\HD-Player.exe", + r"C:\Program Files (x86)\BlueStacks\HD-Player.exe", + ]; + CANDIDATES + .iter() + .find(|candidate| Path::new(candidate).exists()) + .map(|candidate| candidate.to_string()) + .unwrap_or_default() +} + +fn load_config(project_root: &Path) -> Result { + let config_path = project_root.join(CONFIG_FILE); + if config_path.exists() { + let text = std::fs::read_to_string(&config_path)?; + let config: CaptureConfig = toml::from_str(&text)?; + return Ok(config); + } + + let config = CaptureConfig::default(); + let pretty = toml::to_string_pretty(&config)?.replace("\\\\", "/"); + std::fs::write(&config_path, &pretty)?; + println!("created default configuration at {}", config_path.display()); + println!("review it and adjust paths if needed, then run this command again"); + std::process::exit(0); +} + +fn output_path(config: &CaptureConfig, project_root: &Path) -> PathBuf { + let raw = Path::new(&config.paths.output); + if raw.is_absolute() { + raw.to_path_buf() + } else { + project_root.join(raw) + } +} + +#[derive(Serialize, Deserialize)] +struct CapturedRequest { + method: String, + url: String, + headers: Vec<(String, String)>, + #[serde(skip_serializing_if = "Option::is_none")] + body: Option, +} + +#[derive(Clone)] +struct CaptureHandler { + state: Arc, +} + +struct CaptureState { + found: AtomicBool, + done: tokio::sync::Notify, + project_root: PathBuf, + output: PathBuf, + pending: std::sync::Mutex>, +} + +impl HttpHandler for CaptureHandler { + async fn handle_request( + &mut self, + _ctx: &HttpContext, + mut request: Request, + ) -> RequestOrResponse { + let host = request_host(&request).to_lowercase(); + + if request.method() == http::Method::CONNECT { + return request.into(); + } + + let path_and_query = request + .uri() + .path_and_query() + .map(|value| value.as_str().to_string()) + .unwrap_or_else(|| "/".to_string()); + + if !is_noise_host(&host) { + println!(" {} {}{}", request.method(), host, path_and_query); + } + + request.headers_mut().remove(http::header::ACCEPT_ENCODING); + + if !self.state.found.load(Ordering::SeqCst) + && host.contains("topdrives") + && path_and_query.contains("/sync") + { + let body_bytes = + BodyExt::collect(std::mem::replace(request.body_mut(), Body::from(Vec::new()))) + .await + .map(|collected| collected.to_bytes().to_vec()) + .unwrap_or_default(); + + let template = CapturedRequest { + method: request.method().to_string(), + url: format!("https://{host}{path_and_query}"), + headers: request + .headers() + .iter() + .map(|(name, value)| { + ( + name.as_str().to_string(), + String::from_utf8_lossy(value.as_bytes()).to_string(), + ) + }) + .collect(), + body: if body_bytes.is_empty() { + None + } else { + Some(base64::engine::general_purpose::STANDARD.encode(&body_bytes)) + }, + }; + *self.state.pending.lock().unwrap() = Some(template); + *request.body_mut() = Body::from(body_bytes); + } + + request.into() + } + + async fn handle_response( + &mut self, + _ctx: &HttpContext, + mut response: http::Response, + ) -> http::Response { + if self.state.found.load(Ordering::SeqCst) { + return response; + } + + let looks_textual = response + .headers() + .get(http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(|value| value.contains("json") || value.contains("text")) + .unwrap_or(false); + if !looks_textual { + return response; + } + + let bytes = + match BodyExt::collect(std::mem::replace(response.body_mut(), Body::from(Vec::new()))) + .await + { + Ok(collected) => collected.to_bytes().to_vec(), + Err(_) => return response, + }; + + if contains_marker(&bytes, GARAGE_MARKER) { + if let Ok(json) = serde_json::from_slice::(&bytes) { + if json.get("user").is_some() { + let template = self.state.pending.lock().unwrap().take(); + if let Err(error) = persist_capture( + &self.state.project_root, + &self.state.output, + &json, + template.as_ref(), + ) { + eprintln!(" failed to save capture: {error}"); + } + self.state.found.store(true, Ordering::SeqCst); + self.state.done.notify_waiters(); + } + } + } + + *response.body_mut() = Body::from(bytes); + response + } +} + +fn request_host(request: &Request) -> String { + if let Some(host) = request.uri().host() { + return host.to_string(); + } + request + .headers() + .get(http::header::HOST) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.split(':').next()) + .unwrap_or_default() + .to_string() +} + +async fn fetch_response( + client: &reqwest::Client, + method: &http::Method, + url: &str, + headers: &http::HeaderMap, + body: &[u8], + timeout: Duration, +) -> Result> { + let mut builder = client + .request(reqwest::Method::from_bytes(method.as_str().as_bytes())?, url) + .timeout(timeout); + + for (name, value) in headers.iter() { + let lower = name.as_str().to_lowercase(); + if matches!( + lower.as_str(), + "host" + | "content-length" + | "accept-encoding" + | "connection" + | "proxy-connection" + | "keep-alive" + ) { + continue; + } + builder = builder.header(name.as_str(), value.as_bytes()); + } + + if !body.is_empty() { + builder = builder.body(body.to_vec()); + } + + let response = builder.send().await?; + let status = response.status(); + let bytes = response.bytes().await?; + if !status.is_success() { + return Err(format!("HTTP {status}").into()); + } + Ok(bytes.to_vec()) +} + +fn persist_capture( + project_root: &Path, + output: &Path, + json: &serde_json::Value, + template: Option<&CapturedRequest>, +) -> Result<()> { + let timestamp = + time::OffsetDateTime::now_utc().format(&time::format_description::parse_borrowed::<2>( + "[year]-[month]-[day]T[hour]-[minute]-[second]", + )?)?; + let run_dir = project_root.join("captures").join(×tamp); + std::fs::create_dir_all(&run_dir)?; + + let pretty = serde_json::to_string_pretty(json)?; + std::fs::write(run_dir.join("my_cars.json"), &pretty)?; + + if output.exists() { + let backup = run_dir.join("my_cars.previous.json"); + std::fs::copy(output, backup)?; + } + std::fs::write(output, &pretty)?; + + if let Some(template) = template { + std::fs::write( + project_root.join("captures").join("request.json"), + serde_json::to_string_pretty(template)?, + )?; + } + + println!(" garage data saved to {}", output.display()); + print_garage_summary(json); + Ok(()) +} + +fn print_garage_summary(json: &serde_json::Value) { + let social_name = json + .pointer("/user/socialName") + .and_then(|value| value.as_str()) + .unwrap_or("unknown player"); + let deck = json + .get("playerDeck") + .and_then(|value| value.as_array()) + .map(|cards| cards.len()) + .unwrap_or(0); + let locked = json + .get("playerDeck") + .and_then(|value| value.as_array()) + .map(|cards| { + cards + .iter() + .filter(|card| card.get("locked").and_then(|l| l.as_bool()).unwrap_or(false)) + .count() + }) + .unwrap_or(0); + println!(" player: {social_name}, garage: {deck} cars ({locked} locked)"); +} + +fn is_noise_host(host: &str) -> bool { + const NOISE: [&str; 15] = [ + "google", + "gstatic", + "crashlytics", + "firebase", + "facebook", + "fbcdn", + "unity", + "applovin", + "admob", + "doubleclick", + "vungle", + "ironsource", + "chartboost", + "sentry", + "bugsnag", + ]; + NOISE.iter().any(|fragment| host.contains(fragment)) +} + +fn contains_marker(haystack: &[u8], marker: &[u8]) -> bool { + haystack.windows(marker.len()).any(|window| window == marker) +} + +fn run_adb(config: &CaptureConfig, args: &[&str]) -> Result { + let output = Command::new(&config.paths.adb) + .args(args) + .stdin(Stdio::null()) + .output() + .map_err(|error| format!("failed to run adb ({}): {error}", config.paths.adb))?; + if !output.status.success() { + return Err(format!( + "adb {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr).trim() + ) + .into()); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +fn adb(config: &CaptureConfig, args: &[&str]) -> Result { + let serial = config.device.serial.as_str(); + run_adb(config, &[&["-s", serial][..], args].concat()) +} + +fn adb_shell(config: &CaptureConfig, command: &str) -> Result { + adb(config, &["shell", command]) +} + +fn adb_shell_root(config: &CaptureConfig, command: &str) -> Result { + let quoted = format!("su -c '{command}'"); + adb(config, &["shell", quoted.as_str()]) +} + +fn device_listed(config: &CaptureConfig) -> bool { + match run_adb(config, &["devices"]) { + Ok(devices) => devices + .lines() + .any(|line| line.starts_with(&config.device.serial) && line.contains("device")), + Err(_) => false, + } +} + +fn ensure_device_connected(config: &CaptureConfig) -> Result<()> { + let deadline = Instant::now() + Duration::from_secs(config.device.wait_secs); + let mut bluestacks_started = false; + + loop { + let _ = run_adb(config, &["connect", &config.device.serial]); + if device_listed(config) { + return Ok(()); + } + + if !bluestacks_started { + if config.paths.bluestacks.is_empty() || !Path::new(&config.paths.bluestacks).exists() { + return Err(format!( + "device {} is offline and no usable BlueStacks path is configured ({} in {CONFIG_FILE})", + config.device.serial, config.paths.bluestacks + ) + .into()); + } + println!( + "device {} offline, starting BlueStacks ({}) ...", + config.device.serial, config.paths.bluestacks + ); + Command::new(&config.paths.bluestacks).spawn()?; + bluestacks_started = true; + } + + if Instant::now() >= deadline { + return Err(format!( + "device {} did not come online within {}s", + config.device.serial, config.device.wait_secs + ) + .into()); + } + std::thread::sleep(Duration::from_secs(3)); + } +} + +fn android_sdk_level(config: &CaptureConfig) -> Result { + adb_shell(config, "getprop ro.build.version.sdk")? + .trim() + .parse() + .map_err(|_| "could not read Android SDK level".into()) +} + +fn load_or_create_ca(project_root: &Path) -> Result<(String, String)> { + let ca_dir = project_root.join("ca"); + let cert_path = ca_dir.join("topdrives-ca.pem"); + let key_path = ca_dir.join("topdrives-ca.key.pem"); + + if cert_path.exists() && key_path.exists() { + let cert = std::fs::read_to_string(&cert_path)?; + let key = std::fs::read_to_string(&key_path)?; + return Ok((cert, key)); + } + + std::fs::create_dir_all(&ca_dir)?; + let key_pair = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256)?; + let mut params = CertificateParams::new(Vec::new())?; + params.distinguished_name.push(DnType::CommonName, CA_COMMON_NAME); + params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + params.key_usages = vec![ + KeyUsagePurpose::KeyCertSign, + KeyUsagePurpose::CrlSign, + KeyUsagePurpose::DigitalSignature, + ]; + let now = time::OffsetDateTime::now_utc(); + params.not_before = now - time::Duration::days(1); + params.not_after = now + time::Duration::days(3650); + let certificate = params.self_signed(&key_pair)?; + + let cert_pem = certificate.pem(); + let key_pem = key_pair.serialize_pem(); + std::fs::write(&cert_path, &cert_pem)?; + std::fs::write(&key_path, &key_pem)?; + println!("created new CA certificate at {}", cert_path.display()); + Ok((cert_pem, key_pem)) +} + +fn android_ca_filename(common_name: &str) -> String { + let mut canonical = Vec::new(); + canonical.extend_from_slice(&[0x06, 0x03, 0x55, 0x04, 0x03]); + canonical.push(0x0C); + canonical.push(common_name.len() as u8); + canonical.extend_from_slice(common_name.as_bytes()); + + let digest = Md5::digest(&canonical); + let hash = u32::from_le_bytes([digest[0], digest[1], digest[2], digest[3]]); + format!("{hash:08x}.0") +} + +fn inject_ca(config: &CaptureConfig, cert_pem: &str, project_root: &Path) -> Result<()> { + let filename = android_ca_filename(CA_COMMON_NAME); + let store_path = format!("/system/etc/security/cacerts/{filename}"); + let staging_path = "/data/local/tmp/topdrives-ca.pem"; + let local_path = project_root.join("ca").join("topdrives-ca.pem"); + std::fs::write(&local_path, cert_pem)?; + + let presence = + adb_shell(config, &format!("test -f {store_path} && echo present || echo absent"))?; + if presence.trim() == "present" { + println!("CA already present in the system trust store"); + return Ok(()); + } + + adb(config, &["push", local_path.to_string_lossy().as_ref(), staging_path])?; + adb_shell_root( + config, + "mkdir -p /data/local/tmp/cacerts-backup && cp /system/etc/security/cacerts/* /data/local/tmp/cacerts-backup/", + )?; + + let mounts = adb_shell(config, "cat /proc/mounts")?; + if !mounts.contains("/system/etc/security/cacerts") { + adb_shell_root(config, "mount -t tmpfs tmpfs /system/etc/security/cacerts")?; + } + + adb_shell_root( + config, + &format!( + "cp /data/local/tmp/cacerts-backup/* /system/etc/security/cacerts/ && cp {staging_path} {store_path} && chmod 644 {store_path}" + ), + )?; + + let count_text = adb_shell(config, "ls /system/etc/security/cacerts | wc -l")?; + let count: u32 = count_text + .trim() + .parse() + .map_err(|_| "could not count certificates in the store".to_string())?; + if count < 100 { + return Err(format!("system store looks emptied ({count} certificates), aborting").into()); + } + println!("injected CA into the system trust store as {filename} ({count} certificates total)"); + Ok(()) +} + +fn set_device_proxy(config: &CaptureConfig, port: u16) -> Result<()> { + adb_shell(config, &format!("settings put global http_proxy 127.0.0.1:{port}")).map(|_| ()) +} + +fn clear_device_proxy(config: &CaptureConfig) { + let _ = adb_shell(config, "settings put global http_proxy :0"); + let _ = adb_shell(config, "settings delete global http_proxy"); +} + +fn setup_adb_reverse(config: &CaptureConfig, port: u16) -> Result<()> { + let socket = format!("tcp:{port}"); + match adb(config, &["reverse", socket.as_str(), socket.as_str()]) { + Ok(_) => Ok(()), + Err(_) => { + let route = adb_shell(config, "ip route")?; + let gateway = route.lines().find_map(|line| { + line.strip_prefix("default via ").and_then(|rest| rest.split(' ').next()) + }); + match gateway { + Some(gateway) => { + println!( + "adb reverse failed, falling back to gateway address {gateway}:{port}" + ); + adb_shell(config, &format!("settings put global http_proxy {gateway}:{port}")) + .map(|_| ()) + } + None => Err("could not set up adb reverse and no default gateway found".into()), + } + } + } +} + +fn launch_game(config: &CaptureConfig) -> Result<()> { + adb_shell(config, &format!("am force-stop {GAME_PACKAGE}"))?; + std::thread::sleep(Duration::from_secs(2)); + adb( + config, + &["shell", "monkey", "-p", GAME_PACKAGE, "-c", "android.intent.category.LAUNCHER", "1"], + )?; + Ok(()) +} + +fn build_proxy( + listener: tokio::net::TcpListener, + cert_pem: &str, + key_pem: &str, + handler: CaptureHandler, +) -> Result { + let key_pair = KeyPair::from_pem(key_pem)?; + let issuer = Issuer::from_ca_cert_pem(cert_pem, key_pair)?; + let provider = hudsucker::rustls::crypto::aws_lc_rs::default_provider(); + let authority = RcgenAuthority::new(issuer, 1_000, provider); + + let proxy = Proxy::builder() + .with_listener(listener) + .with_ca(authority) + .with_rustls_connector(hudsucker::rustls::crypto::aws_lc_rs::default_provider()) + .with_http_handler(handler) + .build()?; + Ok(Box::pin(proxy.start())) +} + +type ProxyFuture = std::pin::Pin< + Box> + Send>, +>; + +async fn command_run(config: &CaptureConfig) -> Result<()> { + let project_root = std::env::current_dir()?; + let (cert_pem, key_pem) = load_or_create_ca(&project_root)?; + + ensure_device_connected(config)?; + let sdk_level = android_sdk_level(config)?; + if sdk_level > 28 { + println!("warning: Android {sdk_level} detected, the tmpfs CA injection is only tested up to Android 9"); + } + inject_ca(config, &cert_pem, &project_root)?; + + let listener = tokio::net::TcpListener::bind(std::net::SocketAddr::from(( + [127, 0, 0, 1], + config.proxy.port, + ))) + .await + .map_err(|error| { + format!( + "cannot listen on 127.0.0.1:{} (HTTP Toolkit still running?): {error}", + config.proxy.port + ) + })?; + + let state = Arc::new(CaptureState { + found: AtomicBool::new(false), + done: tokio::sync::Notify::new(), + project_root: project_root.clone(), + output: output_path(config, &project_root), + pending: std::sync::Mutex::new(None), + }); + let proxy = + build_proxy(listener, &cert_pem, &key_pem, CaptureHandler { state: state.clone() })?; + tokio::spawn(proxy); + + set_device_proxy(config, config.proxy.port)?; + setup_adb_reverse(config, config.proxy.port)?; + println!("proxy active on 127.0.0.1:{}, launching {GAME_PACKAGE} ...", config.proxy.port); + launch_game(config)?; + + println!( + "waiting for the garage request (timeout {}s, Ctrl+C to abort)", + config.capture.timeout_secs + ); + let outcome: Result<()> = tokio::select! { + _ = state.done.notified() => Ok(()), + _ = tokio::time::sleep(Duration::from_secs(config.capture.timeout_secs)) => Err("timed out waiting for the garage request".into()), + _ = tokio::signal::ctrl_c() => Err("aborted".into()), + }; + + clear_device_proxy(config); + let remove_socket = format!("--remove tcp:{}", config.proxy.port); + let _ = adb(config, &["reverse", remove_socket.as_str()]); + println!("device proxy settings restored"); + + println!("stopping {GAME_PACKAGE}"); + let _ = adb_shell(config, &format!("am force-stop {GAME_PACKAGE}")); + let _ = run_adb(config, &["kill-server"]); + println!("adb stopped"); + if config.device.shutdown_bluestacks { + close_bluestacks(); + } + + match outcome { + Ok(()) => { + println!("capture finished, run `cargo run --bin records -- best` to analyze"); + Ok(()) + } + Err(message) => Err(message), + } +} + +async fn command_setup(config: &CaptureConfig) -> Result<()> { + let project_root = std::env::current_dir()?; + let (cert_pem, _) = load_or_create_ca(&project_root)?; + ensure_device_connected(config)?; + let sdk_level = android_sdk_level(config)?; + println!("device {} connected, Android SDK {}", config.device.serial, sdk_level); + inject_ca(config, &cert_pem, &project_root)?; + Ok(()) +} + +async fn command_replay(config: &CaptureConfig) -> Result<()> { + let project_root = std::env::current_dir()?; + let request_path = project_root.join("captures").join("request.json"); + if !request_path.exists() { + return Err("no captured request found, run `capture run` first".into()); + } + let template: CapturedRequest = serde_json::from_str(&std::fs::read_to_string(&request_path)?)?; + + let mut headers = http::HeaderMap::new(); + for (name, value) in &template.headers { + let lower = name.to_lowercase(); + if matches!( + lower.as_str(), + "host" + | "content-length" + | "accept-encoding" + | "connection" + | "proxy-connection" + | "keep-alive" + ) { + continue; + } + headers.insert( + http::HeaderName::from_bytes(name.as_bytes())?, + http::HeaderValue::from_bytes(value.as_bytes())?, + ); + } + + let body = template + .body + .as_deref() + .map(|encoded| base64::engine::general_purpose::STANDARD.decode(encoded)) + .transpose()? + .unwrap_or_default(); + + let method: http::Method = template.method.parse()?; + let timeout = Duration::from_secs(config.capture.replay_timeout_secs); + let http = reqwest::Client::builder().timeout(timeout).build()?; + + println!("replaying {} {}", method, template.url); + let bytes = fetch_response(&http, &method, &template.url, &headers, &body, timeout).await?; + if !contains_marker(&bytes, GARAGE_MARKER) { + return Err( + "response no longer contains the garage document, the session token probably expired; run `capture run` again".into(), + ); + } + let json: serde_json::Value = serde_json::from_slice(&bytes)?; + persist_capture(&project_root, &output_path(config, &project_root), &json, Some(&template))?; + println!("replay finished successfully"); + Ok(()) +} + +async fn command_clean(config: &CaptureConfig) -> Result<()> { + ensure_device_connected(config)?; + clear_device_proxy(config); + let remove_socket = format!("--remove tcp:{}", config.proxy.port); + let _ = adb(config, &["reverse", remove_socket.as_str()]); + let filename = android_ca_filename(CA_COMMON_NAME); + let _ = adb_shell_root(config, &format!("rm -f /system/etc/security/cacerts/{filename}")); + let _ = adb_shell_root(config, "umount /system/etc/security/cacerts"); + println!("proxy settings cleared and CA removed from the device"); + Ok(()) +} + +fn close_bluestacks() { + let silent = |args: &[&str]| { + let _ = Command::new("taskkill") + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + }; + silent(&["/IM", "HD-Player.exe"]); + std::thread::sleep(Duration::from_secs(5)); + silent(&["/F", "/IM", "HD-Player.exe"]); + println!("BlueStacks closed"); +} + +fn print_usage() { + println!( + "usage: capture + +commands: + setup generate the CA and inject it into BlueStacks + run full flow: start BlueStacks if needed, proxy, game launch, garage capture + replay re-issue the stored garage request without the emulator + clean remove proxy settings and the injected CA + +configuration is read from {CONFIG_FILE} in the working directory" + ); +} + +#[tokio::main] +pub(crate) async fn run() { + let command = std::env::args().nth(1).unwrap_or_default(); + if command.is_empty() { + print_usage(); + return; + } + + let project_root = std::env::current_dir().expect("cannot determine working directory"); + let config = match load_config(&project_root) { + Ok(config) => config, + Err(error) => { + eprintln!("error reading {CONFIG_FILE}: {error}"); + std::process::exit(1); + } + }; + + let result = match command.as_str() { + "setup" => command_setup(&config).await, + "run" => command_run(&config).await, + "replay" => command_replay(&config).await, + "clean" => command_clean(&config).await, + _ => { + print_usage(); + return; + } + }; + if let Err(error) = result { + eprintln!("error: {error}"); + std::process::exit(1); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn marker_detection_finds_nested_json_key() { + let body = br#"{"serverId":"x","playerDeck":[{"cardId":"a"}]}"#; + assert!(contains_marker(body, GARAGE_MARKER)); + assert!(!contains_marker(b"{}", GARAGE_MARKER)); + } + + #[test] + fn android_ca_filename_is_openssl_style_hash() { + let filename = android_ca_filename(CA_COMMON_NAME); + assert_eq!(filename.len(), 10); + assert!(filename.ends_with(".0")); + assert!(filename[..8].chars().all(|character| character.is_ascii_hexdigit())); + } + + #[test] + fn noise_hosts_are_filtered() { + assert!(is_noise_host("ads.google.com")); + assert!(is_noise_host("config.firebase.io")); + assert!(!is_noise_host("api.hutchgames.com")); + assert!(!is_noise_host("topdrives-api.example.com")); + } + + #[test] + fn default_config_round_trips_through_toml() { + let config = CaptureConfig::default(); + let text = toml::to_string_pretty(&config).unwrap(); + let parsed: CaptureConfig = toml::from_str(&text).unwrap(); + assert_eq!(parsed.device.serial, config.device.serial); + assert_eq!(parsed.paths.output, config.paths.output); + assert_eq!(parsed.proxy.port, config.proxy.port); + } +} diff --git a/src/commands/all.rs b/src/commands/all.rs index 5957cff..3658a8d 100644 --- a/src/commands/all.rs +++ b/src/commands/all.rs @@ -1,11 +1,14 @@ //! All command implementation. //! //! Generates all output files in a single pass: best.csv, very_best.csv, -//! trash.csv, details.csv, wishlist.csv, add.csv, remove.csv, and improve.csv. +//! trash.csv, details.csv, wishlist.csv, add.csv, remove.csv, improve.csv, +//! protect.csv, and storage.csv. use top_drives::AppError; -use crate::commands::{load_my_cars_or_warn, run_analysis}; +use crate::commands::{ + load_my_cars_or_warn, run_analysis, write_trash_with_rules, GarageInfo, TrashParams, +}; use crate::output; /// Executes the all command: generates all output files in a single analysis pass. @@ -16,24 +19,27 @@ pub async fn run_all( output_dir: &str, min_count: usize, max_wishlist: usize, + trash_params: &TrashParams, ) -> Result<(), AppError> { let my_data = load_my_cars_or_warn(my_cars_path)?; - let (ctx, counts) = run_analysis(&my_data, cars_db_path, times_dir).await?; + let owned = my_data.owned.clone(); + let wishlist = my_data.wishlist.clone(); + let unlocked = my_data.unlocked.clone(); + let garage_unlocked = my_data.garage_unlocked.clone(); + let storage = my_data.storage.clone(); + let garage = GarageInfo::from_my_cars(&my_data); + let (ctx, counts) = run_analysis(my_data, cars_db_path, times_dir).await?; output::write_best_csv(output_dir, &counts)?; output::write_very_best_csv(output_dir, &counts, min_count)?; - output::write_trash_csv(output_dir, &counts, &my_data.owned)?; - output::write_details_csv(output_dir, &ctx)?; - output::write_wishlist_files( - output_dir, - &counts, - &my_data.owned, - &my_data.wishlist, - min_count, - max_wishlist, - )?; + let thresholds = + write_trash_with_rules(output_dir, &counts, &owned, &unlocked, trash_params, &garage)?; + output::write_details_csv(output_dir, &ctx, &counts)?; + output::write_wishlist_files(output_dir, &counts, &owned, &wishlist, min_count, max_wishlist)?; output::write_improve_csv(output_dir, &counts)?; + output::write_protect_csv(output_dir, &counts, &garage_unlocked, thresholds.as_ref())?; + output::write_storage_csv(output_dir, &counts, &storage, thresholds.as_ref())?; println!( - "Written: best.csv, very_best.csv, trash.csv, details.csv, wishlist.csv, add.csv, remove.csv, improve.csv" + "Written: best.csv, very_best.csv, trash.csv, details.csv, wishlist.csv, add.csv, remove.csv, improve.csv, protect.csv, storage.csv" ); Ok(()) } diff --git a/src/commands/best.rs b/src/commands/best.rs index 1aa1d19..c96ccc1 100644 --- a/src/commands/best.rs +++ b/src/commands/best.rs @@ -17,7 +17,7 @@ pub async fn run_best( min_count: usize, ) -> Result<(), AppError> { let my_data = load_my_cars_or_warn(my_cars_path)?; - let (_ctx, counts) = run_analysis(&my_data, cars_db_path, times_dir).await?; + let (_ctx, counts) = run_analysis(my_data, cars_db_path, times_dir).await?; write_best_csv(output_dir, &counts)?; write_very_best_csv(output_dir, &counts, min_count)?; println!("Written: best.csv, very_best.csv"); diff --git a/src/commands/details.rs b/src/commands/details.rs index 90ccc66..1b1b47a 100644 --- a/src/commands/details.rs +++ b/src/commands/details.rs @@ -16,8 +16,8 @@ pub async fn run_details( output_dir: &str, ) -> Result<(), AppError> { let my_data = load_my_cars_or_warn(my_cars_path)?; - let (ctx, _counts) = run_analysis(&my_data, cars_db_path, times_dir).await?; - write_details_csv(output_dir, &ctx)?; + let (ctx, counts) = run_analysis(my_data, cars_db_path, times_dir).await?; + write_details_csv(output_dir, &ctx, &counts)?; println!("Written: details.csv"); Ok(()) } diff --git a/src/commands/improve.rs b/src/commands/improve.rs index c533f7e..1da9803 100644 --- a/src/commands/improve.rs +++ b/src/commands/improve.rs @@ -16,7 +16,7 @@ pub async fn run_improve( output_dir: &str, ) -> Result<(), AppError> { let my_data = load_my_cars_or_warn(my_cars_path)?; - let (_ctx, counts) = run_analysis(&my_data, cars_db_path, times_dir).await?; + let (_ctx, counts) = run_analysis(my_data, cars_db_path, times_dir).await?; let count = write_improve_csv(output_dir, &counts)?; if count == 0 { println!("No cars to improve — all owned cars are already in final or maxed configs."); diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 85f38a5..fd6f635 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -8,16 +8,60 @@ pub mod best; pub mod details; pub mod fetch; pub mod improve; +pub mod protect; pub mod track; pub mod trash; pub mod wishlist; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use top_drives::{ compute_car_counts, load_my_cars_data, AnalysisContext, AppError, CarCounts, MyCarsData, }; +use crate::output::{ + collect_improve_entries, plan_trash_rules, write_trash_csv, GarageProjection, TrashThresholds, +}; + +// --------------------------------------------------------------------------- +// Trash criteria parameters +// --------------------------------------------------------------------------- + +/// Default base threshold for the improve-based trash criteria (RQ 10-19). +const DEFAULT_TRASH_BASE_COUNT: usize = 4; + +/// Default minimum projected free garage slots for trash threshold escalation. +const DEFAULT_GARAGE_MIN_FREE: usize = 20; + +/// CLI parameters for the improve-based trash criteria. +#[derive(clap::Args)] +pub struct TrashParams { + /// Maximum improve track count for RQ 10-19 to count as trash; RQ 20-29/30-39 + /// get half/a quarter of it. 0 disables these criteria (default: 4) + #[arg(long, default_value_t = DEFAULT_TRASH_BASE_COUNT)] + pub trash_base_count: usize, + /// Minimum projected free garage slots; the base count is raised + /// automatically when the projection falls short (default: 20) + #[arg(long, default_value_t = DEFAULT_GARAGE_MIN_FREE)] + pub garage_min_free: usize, +} + +/// Garage slot capacity and occupancy from the user export. +pub struct GarageInfo { + /// Total garage slot capacity (0 = unknown). + pub slots: usize, + /// Currently occupied garage slots. + pub slots_used: usize, +} + +impl GarageInfo { + /// Extracts garage slot data from the parsed user export. + pub fn from_my_cars(my_data: &MyCarsData) -> Self { + GarageInfo { slots: my_data.garage_slots, slots_used: my_data.garage_slots_used } + } +} + use crate::parsing::parse_all_times; // --------------------------------------------------------------------------- @@ -33,8 +77,8 @@ pub fn load_my_cars_or_warn(path: &str) -> Result { match load_my_cars_data(path) { Ok(data) => Ok(data), Err(AppError::MissingFile(msg)) => { - eprintln!("Warning: {msg}"); - eprintln!(" Analysis will run without ownership data."); + log::warn!("{msg}"); + log::warn!("Analysis will run without ownership data."); Ok(MyCarsData::default()) } Err(e) => Err(e), @@ -45,23 +89,119 @@ pub fn load_my_cars_or_warn(path: &str) -> Result { // Analysis orchestration // --------------------------------------------------------------------------- -/// Common analysis setup: loads user data, parses all timing files, and computes car counts. +/// Common analysis setup: parses all timing files and computes car counts. +/// +/// Takes `my_data` by value: `owned` is shared with the parse tasks via `Arc`, +/// and `owned_configs`/`copy_count` are moved into [`CarCounts`]. Callers that +/// need `owned` or `wishlist` after analysis must retain their own copy before +/// calling this function. /// /// Returns the analysis context (for stdout commands) and the aggregated car counts -/// (for CSV output commands). The car list is stored inside `CarCounts`. +/// (for CSV output commands). pub async fn run_analysis( - my_data: &MyCarsData, + my_data: MyCarsData, cars_db_path: &str, times_dir: &str, ) -> Result<(AnalysisContext, CarCounts), AppError> { let ctx = AnalysisContext::new(); - let owned = Arc::new(my_data.owned.clone()); + let MyCarsData { owned, owned_configs, copy_count, .. } = my_data; + let owned = Arc::new(owned); let car_list = parse_all_times(owned, &ctx, cars_db_path, times_dir).await?; - let counts = compute_car_counts( - &ctx, - car_list, - my_data.owned_configs.clone(), - my_data.copy_count.clone(), - ); + let counts = compute_car_counts(&ctx, car_list, owned_configs, copy_count); Ok((ctx, counts)) } + +// --------------------------------------------------------------------------- +// Trash rule planning output +// --------------------------------------------------------------------------- + +/// Plans the effective trash thresholds, escalating the base value against the +/// garage-space projection, and prints the plan. +/// +/// Returns `None` when the improve-based criteria are disabled +/// (`trash_base_count == 0`). +pub fn plan_thresholds( + counts: &CarCounts, + unlocked: &HashMap, + params: &TrashParams, + garage: &GarageInfo, +) -> Option { + if params.trash_base_count == 0 { + return None; + } + let improve = collect_improve_entries(counts); + let (thresholds, projection) = plan_trash_rules( + counts, + &improve, + unlocked, + params.trash_base_count, + garage.slots, + garage.slots_used, + params.garage_min_free, + ); + print_trash_plan(&thresholds, &projection, params.garage_min_free); + Some(thresholds) +} + +/// Plans the effective trash thresholds (printing the garage-space projection) +/// and writes trash.csv, returning the thresholds applied for other outputs +/// (protect.csv, storage.csv). +/// +/// The caller is responsible for printing the "Written: ..." line so compound +/// commands like `all` can fold it into their summary. +pub fn write_trash_with_rules( + output_dir: &str, + counts: &CarCounts, + owned: &HashSet, + unlocked: &HashMap, + params: &TrashParams, + garage: &GarageInfo, +) -> Result, AppError> { + let thresholds = plan_thresholds(counts, unlocked, params, garage); + let improve = collect_improve_entries(counts); + write_trash_csv(output_dir, counts, owned, thresholds.as_ref(), &improve)?; + Ok(thresholds) +} + +/// Prints the effective trash thresholds and the garage-space projection. +pub fn print_trash_plan( + thresholds: &TrashThresholds, + projection: &GarageProjection, + min_free: usize, +) { + let base_note = if projection.base_count != projection.configured_base_count { + format!( + " (base raised from {} to {})", + projection.configured_base_count, projection.base_count + ) + } else { + String::new() + }; + println!( + "Trash thresholds: RQ 10-19: count <= {}, RQ 20-29: count <= {}, RQ 30-39: count <= {}{}", + thresholds.rq10_19, thresholds.rq20_29, thresholds.rq30_39, base_note + ); + match projection.projected_free { + Some(free) => { + println!( + "Garage: {}/{} slots used ({} free now), {} unlocked to delete, {} unlocked to lock, {} new trash candidates -> {} free projected (target: {})", + projection.slots_used, + projection.garage_slots, + projection.free_now().unwrap_or(0), + projection.unlocked_freed, + projection.unlocked_protected, + projection.trash_candidates, + free, + min_free + ); + if free < min_free { + log::warn!( + "Projected free garage slots ({free}) stay below the target ({min_free}) — no further cars qualify for the trash criteria" + ); + } + } + None => { + println!("Garage: no slot data in export — thresholds applied without space projection") + } + } +} diff --git a/src/commands/protect.rs b/src/commands/protect.rs new file mode 100644 index 0000000..b42dec4 --- /dev/null +++ b/src/commands/protect.rs @@ -0,0 +1,40 @@ +//! Protect command implementation. +//! +//! Generates protect.csv (unlocked garage cars worth locking more copies of) +//! and storage.csv (a keep/trash recommendation for cars in the storage), +//! both applying the same dynamic trash criteria as the trash command — +//! including the garage-space threshold escalation. + +use top_drives::AppError; + +use crate::commands::{ + load_my_cars_or_warn, plan_thresholds, run_analysis, GarageInfo, TrashParams, +}; +use crate::output::{write_protect_csv, write_storage_csv}; + +/// Executes the protect command: generates protect.csv and storage.csv. +/// +/// protect.csv covers unlocked cars in the **garage** (`state != 0`, +/// `locked == false`); storage.csv covers cars in the **storage** (`state == 0`). +pub async fn run_protect( + my_cars_path: &str, + cars_db_path: &str, + times_dir: &str, + output_dir: &str, + params: &TrashParams, +) -> Result<(), AppError> { + let my_data = load_my_cars_or_warn(my_cars_path)?; + let unlocked = my_data.unlocked.clone(); + let garage_unlocked = my_data.garage_unlocked.clone(); + let storage = my_data.storage.clone(); + let garage = GarageInfo::from_my_cars(&my_data); + let (_ctx, counts) = run_analysis(my_data, cars_db_path, times_dir).await?; + let thresholds = plan_thresholds(&counts, &unlocked, params, &garage); + + let protected = write_protect_csv(output_dir, &counts, &garage_unlocked, thresholds.as_ref())?; + let (keep, trash) = write_storage_csv(output_dir, &counts, &storage, thresholds.as_ref())?; + println!( + "Written: protect.csv ({protected} garage cars to lock), storage.csv ({keep} keep, {trash} trash)" + ); + Ok(()) +} diff --git a/src/commands/track.rs b/src/commands/track.rs index 9b77189..e4fc372 100644 --- a/src/commands/track.rs +++ b/src/commands/track.rs @@ -9,19 +9,16 @@ use crate::output::print_best_car; /// Executes the track command: shows best car for a specific track/condition/RQ. /// -/// Note: `_output_dir` is unused because the track command prints to stdout -/// rather than writing a CSV file. It exists for API consistency with other -/// command signatures so the caller doesn't need per-command branching. +/// Prints to stdout rather than writing a CSV file, so no output directory is needed. pub async fn run_track( my_cars_path: &str, cars_db_path: &str, times_dir: &str, - _output_dir: &str, track: &str, condition: &str, rq: u8, ) -> Result<(), AppError> { let my_data = load_my_cars_or_warn(my_cars_path)?; - let (ctx, _counts) = run_analysis(&my_data, cars_db_path, times_dir).await?; + let (ctx, _counts) = run_analysis(my_data, cars_db_path, times_dir).await?; print_best_car(&ctx, track, condition, rq) } diff --git a/src/commands/trash.rs b/src/commands/trash.rs index 7fcfc6e..812ee41 100644 --- a/src/commands/trash.rs +++ b/src/commands/trash.rs @@ -1,13 +1,16 @@ //! Trash command implementation. //! //! Generates trash.csv — owned cars that are not best on any track in their -//! current configuration (RQ < 80, not prize). Includes cars whose actual -//! final/maxed build is not best, even if other configs of the same car are. +//! current configuration (RQ < 80, not prize), plus low-value upgrade fodder +//! selected by RQ-band track-count thresholds. The base threshold is escalated +//! automatically when the projected free garage slots would fall short of a +//! configurable reserve. use top_drives::AppError; -use crate::commands::{load_my_cars_or_warn, run_analysis}; -use crate::output::write_trash_csv; +use crate::commands::{ + load_my_cars_or_warn, run_analysis, write_trash_with_rules, GarageInfo, TrashParams, +}; /// Executes the trash command: generates trash.csv. pub async fn run_trash( @@ -15,10 +18,14 @@ pub async fn run_trash( cars_db_path: &str, times_dir: &str, output_dir: &str, + params: &TrashParams, ) -> Result<(), AppError> { let my_data = load_my_cars_or_warn(my_cars_path)?; - let (_ctx, counts) = run_analysis(&my_data, cars_db_path, times_dir).await?; - write_trash_csv(output_dir, &counts, &my_data.owned)?; + let owned = my_data.owned.clone(); + let unlocked = my_data.unlocked.clone(); + let garage = GarageInfo::from_my_cars(&my_data); + let (_ctx, counts) = run_analysis(my_data, cars_db_path, times_dir).await?; + write_trash_with_rules(output_dir, &counts, &owned, &unlocked, params, &garage)?; println!("Written: trash.csv"); Ok(()) } diff --git a/src/commands/wishlist.rs b/src/commands/wishlist.rs index e02801e..ce72dc8 100644 --- a/src/commands/wishlist.rs +++ b/src/commands/wishlist.rs @@ -17,15 +17,10 @@ pub async fn run_wishlist( max_wishlist: usize, ) -> Result<(), AppError> { let my_data = load_my_cars_or_warn(my_cars_path)?; - let (_ctx, counts) = run_analysis(&my_data, cars_db_path, times_dir).await?; - write_wishlist_files( - output_dir, - &counts, - &my_data.owned, - &my_data.wishlist, - min_count, - max_wishlist, - )?; + let owned = my_data.owned.clone(); + let wishlist = my_data.wishlist.clone(); + let (_ctx, counts) = run_analysis(my_data, cars_db_path, times_dir).await?; + write_wishlist_files(output_dir, &counts, &owned, &wishlist, min_count, max_wishlist)?; println!("Written: wishlist.csv, add.csv, remove.csv"); Ok(()) } diff --git a/src/context.rs b/src/context.rs index a8064a1..fc06be3 100644 --- a/src/context.rs +++ b/src/context.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use dashmap::DashMap; -use crate::types::{BestCarRecord, Car, TripletKey}; +use crate::types::{is_final_build, BestCarRecord, Car, TripletKey}; /// Holds the mutable analysis state for a records analysis run. /// @@ -50,9 +50,9 @@ pub struct CarCounts { pub guid_to_rid: HashMap>, /// Map from guid → Car (for O(1) lookups of car metadata by GUID). /// Built once in `compute_car_counts()` and reused by trash/wishlist output. + /// Sole owner of the car database — iterate `car_map.values()` where a + /// car list was previously used. pub car_map: HashMap, - /// Full car list from the database (kept for any callers that need iteration). - pub car_list: Vec, /// Actual tune configs for each owned car (guid → list of configs from all copies). /// Used by the improve command to determine which cars are not yet at a final build. pub owned_configs: HashMap>, @@ -62,6 +62,55 @@ pub struct CarCounts { pub copy_count: HashMap, } +/// Resolves same-car tie duplicates within one track's best-car list. +/// +/// All records in a track list are within the time tolerance of each other, +/// so multiple entries of the **same car** (different tune configs) are +/// tie-equivalent: either tune achieves the same time, and counting both +/// would double-claim the track per config and surface pointless +/// recommendations (e.g. wishlisting a second copy tuned 332 when the owned +/// 323 ties it). Exactly one config per car is kept: +/// +/// - the player's owned final-build config, if one of the tied configs is +/// owned (so downstream ownership checks match what the player has), else +/// - the first record in list order (deterministic). +/// +/// Records of other cars are passed through unchanged, preserving order. +pub fn resolve_tied_configs<'a>( + records: &'a [BestCarRecord], + owned_final: &HashMap<&str, HashSet<&str>>, +) -> Vec<&'a BestCarRecord> { + let is_owned_final = |record: &BestCarRecord| -> bool { + owned_final + .get(record.guid.as_str()) + .is_some_and(|configs| configs.contains(record.normalized_config.as_str())) + }; + + let mut chosen: HashMap<&str, usize> = HashMap::with_capacity(records.len()); + for (index, record) in records.iter().enumerate() { + match chosen.get(record.car_rid.as_str()) { + Some(¤t) => { + // Replace only when the current pick is not owned-final and + // this record is — first-seen order wins otherwise. + let current_is_owned = is_owned_final(&records[current]); + if !current_is_owned && is_owned_final(record) { + chosen.insert(record.car_rid.as_str(), index); + } + } + None => { + chosen.insert(record.car_rid.as_str(), index); + } + } + } + + records + .iter() + .enumerate() + .filter(|(index, record)| chosen.get(record.car_rid.as_str()) == Some(index)) + .map(|(_, record)| record) + .collect() +} + /// Computes per-car win counts from the analysis context. /// /// Returns a `CarCounts` struct that can be shared across all output functions. @@ -69,18 +118,30 @@ pub struct CarCounts { /// `HashMap` inserts. /// /// Groups by `(car_rid, normalized_config)` so that the same car with different -/// final builds is counted as separate entries. +/// final builds is counted as separate entries. Same-car ties on a track are +/// attributed to a single config (see [`resolve_tied_configs`]). pub fn compute_car_counts( ctx: &AnalysisContext, car_list: Vec, owned_configs: HashMap>, copy_count: HashMap, ) -> CarCounts { + // guid → owned final-build configs, borrowed views for tie attribution. + let owned_final: HashMap<&str, HashSet<&str>> = owned_configs + .iter() + .map(|(guid, configs)| { + ( + guid.as_str(), + configs.iter().filter(|c| is_final_build(c)).map(|c| c.as_str()).collect(), + ) + }) + .collect(); + let mut by_rid: HashMap<(String, String), (BestCarRecord, usize)> = HashMap::new(); let mut guid_to_rid: HashMap> = HashMap::new(); for entry in ctx.best_cars.iter() { - for best_car in entry.value() { + for best_car in resolve_tied_configs(entry.value(), &owned_final) { let key = (best_car.car_rid.clone(), best_car.normalized_config.clone()); let rid_entry = by_rid.entry(key.clone()).or_insert_with(|| (best_car.clone(), 0usize)); rid_entry.1 += 1; @@ -94,10 +155,10 @@ pub fn compute_car_counts( } // Build guid → Car lookup once, reused by trash/wishlist output functions. - let car_map: HashMap = - car_list.iter().map(|c| (c.guid.clone(), c.clone())).collect(); + // Consumes car_list so each Car is stored exactly once. + let car_map: HashMap = car_list.into_iter().map(|c| (c.guid.clone(), c)).collect(); - CarCounts { by_rid, guid_to_rid, car_map, car_list, owned_configs, copy_count } + CarCounts { by_rid, guid_to_rid, car_map, owned_configs, copy_count } } #[cfg(test)] @@ -109,6 +170,128 @@ mod tests { HashMap::new() } + fn tie_record(rid: &str, config: &str, guid: &str, time: f64) -> BestCarRecord { + BestCarRecord { + car_rid: rid.to_string(), + brand: "TestBrand".to_string(), + only_name: "TestModel".to_string(), + year: 2020, + rq: 85, + config: config.to_string(), + normalized_config: config.to_string(), + time, + owned: false, + prize: false, + guid: guid.to_string(), + } + } + + fn owned_final_map<'a>( + guid: &'a str, + configs: &[&'a str], + ) -> HashMap<&'a str, HashSet<&'a str>> { + HashMap::from([(guid, configs.iter().copied().collect::>())]) + } + + #[test] + fn test_resolve_tied_configs_keeps_single_config_per_car() { + // Mercedes 280 SL case: 323 and 332 tie; unowned → first kept. + let records = vec![ + tie_record("Car_A", "323", "guid_a", 61.31), + tie_record("Car_B", "332", "guid_b", 61.31), + tie_record("Car_A", "332", "guid_a", 61.31), + ]; + + let resolved = resolve_tied_configs(&records, &HashMap::new()); + + assert_eq!(resolved.len(), 2, "one config per car plus the other car"); + assert!(resolved.iter().any(|r| r.car_rid == "Car_A" && r.normalized_config == "323")); + assert!(resolved.iter().any(|r| r.car_rid == "Car_B")); + assert!(!resolved.iter().any(|r| r.car_rid == "Car_A" && r.normalized_config == "332")); + } + + #[test] + fn test_resolve_tied_configs_prefers_owned_final_config() { + // Player owns 332 as a final build → the tie is attributed to 332 + // even though 323 comes first. + let records = vec![ + tie_record("Car_A", "323", "guid_a", 61.31), + tie_record("Car_A", "332", "guid_a", 61.31), + ]; + + let owned_final = owned_final_map("guid_a", &["332"]); + let resolved = resolve_tied_configs(&records, &owned_final); + + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].normalized_config, "332"); + } + + #[test] + fn test_resolve_tied_configs_ignores_owned_non_final_config() { + // An unfinished owned build does not absorb the tie. + let records = vec![ + tie_record("Car_A", "323", "guid_a", 61.31), + tie_record("Car_A", "332", "guid_a", 61.31), + ]; + + // "332" owned but NOT final is not in owned_final — first config kept. + let owned_final: HashMap<&str, HashSet<&str>> = HashMap::new(); + let resolved = resolve_tied_configs(&records, &owned_final); + + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].normalized_config, "323"); + } + + #[test] + fn test_resolve_tied_configs_keeps_first_owned_when_several_owned() { + // Both configs owned as final builds → the first owned one wins. + let records = vec![ + tie_record("Car_A", "323", "guid_a", 61.31), + tie_record("Car_A", "332", "guid_a", 61.31), + ]; + + let owned_final = owned_final_map("guid_a", &["323", "332"]); + let resolved = resolve_tied_configs(&records, &owned_final); + + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].normalized_config, "323"); + } + + #[test] + fn test_compute_car_counts_attributes_tie_to_owned_config() { + // End-to-end: the tie track counts for the owned config only. + let ctx = AnalysisContext::new(); + ctx.best_cars.insert( + ("Ocean Highway".to_string(), 21, "Wet".to_string()), + vec![ + tie_record("Car_A", "323", "guid_a", 61.31), + tie_record("Car_A", "332", "guid_a", 61.31), + ], + ); + + let car_list = vec![Car { + rid: "Car_A".to_string(), + brand: "TestBrand".to_string(), + only_name: "TestModel".to_string(), + year: 2020, + rq: 21, + prize: false, + guid: "guid_a".to_string(), + }]; + + let mut owned_configs = HashMap::new(); + owned_configs.insert("guid_a".to_string(), vec!["323".to_string()]); + + let counts = compute_car_counts(&ctx, car_list, owned_configs, HashMap::new()); + + let count_323 = + counts.by_rid.get(&("Car_A".to_string(), "323".to_string())).map(|(_, c)| *c); + let count_332 = + counts.by_rid.get(&("Car_A".to_string(), "332".to_string())).map(|(_, c)| *c); + assert_eq!(count_323, Some(1), "tie attributed to the owned config"); + assert_eq!(count_332, None, "tie-equivalent config is not counted"); + } + #[test] fn test_compute_car_counts_basic() { let ctx = AnalysisContext::new(); @@ -158,7 +341,7 @@ mod tests { // car_map should have the car assert!(counts.car_map.contains_key("guid_test")); - assert_eq!(counts.car_list.len(), 1); + assert_eq!(counts.car_map.len(), 1); } #[test] diff --git a/src/http.rs b/src/http.rs index f69253f..aca3a3b 100644 --- a/src/http.rs +++ b/src/http.rs @@ -9,6 +9,11 @@ use reqwest::get; use crate::types::AppError; +/// Linear backoff between retry attempts: 500ms, 1000ms, 1500ms, ... +fn retry_delay(attempt: usize) -> std::time::Duration { + std::time::Duration::from_millis(500 * (attempt as u64 + 1)) +} + /// Downloads a file from a URL and saves it locally. /// /// Returns `Ok(())` on success, or an error describing what went wrong. @@ -16,10 +21,13 @@ use crate::types::AppError; /// Writes to a temporary file first, then renames on success, so a failed /// download never deletes the existing file. /// -/// Retries up to `MAX_FETCH_RETRIES` times on transient failures (server errors, -/// connection errors, or empty responses). Permanent HTTP errors (4xx) fail immediately. +/// Retries up to `MAX_FETCH_RETRIES` times on transient failures (server errors +/// or connection errors). Permanent conditions fail immediately: client HTTP +/// errors (4xx) and empty bodies ([`AppError::EmptyResponse`] — the records API +/// reports "no data submitted yet" as a 200 with an empty body, which no amount +/// of retrying can change). pub async fn get_file(file_url: &str, local_file_path: &str) -> Result<(), AppError> { - /// Maximum number of retries when the server returns an empty response. + /// Maximum number of retries on transient failures (5xx, connection errors). const MAX_FETCH_RETRIES: usize = 3; // Parse the URL once so malformed URLs are caught before any network I/O. @@ -39,15 +47,12 @@ pub async fn get_file(file_url: &str, local_file_path: &str) -> Result<(), AppEr if !status.is_success() { // Only retry on server errors (5xx); client errors (4xx) are permanent. if status.is_server_error() && attempt < MAX_FETCH_RETRIES { - eprintln!( - "Warning: HTTP {} from {file_url} (attempt {}/{MAX_FETCH_RETRIES}), retrying...", + log::warn!( + "HTTP {} from {file_url} (attempt {}/{MAX_FETCH_RETRIES}), retrying...", status, attempt + 1, ); - tokio::time::sleep(std::time::Duration::from_millis( - 500 * (attempt + 1) as u64, - )) - .await; + tokio::time::sleep(retry_delay(attempt)).await; attempt += 1; continue; } @@ -56,21 +61,9 @@ pub async fn get_file(file_url: &str, local_file_path: &str) -> Result<(), AppEr match response.text().await { Ok(content) => { if content.is_empty() { - if attempt < MAX_FETCH_RETRIES { - eprintln!( - "Warning: empty response from {file_url} (attempt {}/{MAX_FETCH_RETRIES}), retrying...", - attempt + 1, - ); - tokio::time::sleep(std::time::Duration::from_millis( - 500 * (attempt + 1) as u64, - )) - .await; - attempt += 1; - continue; - } - return Err(AppError::Network(format!( - "Empty response from {file_url} after {MAX_FETCH_RETRIES} retries" - ))); + // "Known resource, no data yet" — deterministic, so + // fail immediately instead of burning retries. + return Err(AppError::EmptyResponse { url: file_url.to_string() }); } fs::write(&tmp_path, &content).map_err(AppError::Io)?; return fs::rename(&tmp_path, local_file_path).map_err(AppError::Io); @@ -84,14 +77,11 @@ pub async fn get_file(file_url: &str, local_file_path: &str) -> Result<(), AppEr } Err(error) => { if attempt < MAX_FETCH_RETRIES { - eprintln!( - "Warning: failed to fetch {file_url} (attempt {}/{MAX_FETCH_RETRIES}): {error}", + log::warn!( + "failed to fetch {file_url} (attempt {}/{MAX_FETCH_RETRIES}): {error}", attempt + 1, ); - tokio::time::sleep(std::time::Duration::from_millis( - 500 * (attempt + 1) as u64, - )) - .await; + tokio::time::sleep(retry_delay(attempt)).await; attempt += 1; continue; } diff --git a/src/lib.rs b/src/lib.rs index 165f6ec..052e245 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -41,7 +41,7 @@ pub mod test_utils; // Re-export all public types and functions for backward compatibility. // External code can use `top_drives::Car` etc. without knowing the internal module layout. -pub use context::{compute_car_counts, AnalysisContext, CarCounts}; +pub use context::{compute_car_counts, resolve_tied_configs, AnalysisContext, CarCounts}; pub use csv_util::{compare_cars, escape_csv_field}; pub use http::get_file; pub use translate::{translate_track_name, translate_track_type}; diff --git a/src/output/mod.rs b/src/output/mod.rs index d03c81b..de045bb 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -6,11 +6,11 @@ use std::collections::{HashMap, HashSet}; use std::fs; use std::io::Write; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use top_drives::{ - compare_cars, escape_csv_field, is_final_build, AnalysisContext, AppError, BestCarRecord, Car, - CarCounts, TripletKey, + compare_cars, escape_csv_field, is_final_build, resolve_tied_configs, AnalysisContext, + AppError, BestCarRecord, Car, CarCounts, TripletKey, }; // --------------------------------------------------------------------------- @@ -25,16 +25,25 @@ const TRASH_MIN_RQ: u8 = 80; // Sorting helpers // --------------------------------------------------------------------------- +/// Field-wise ordering shared by record-based CSV outputs: +/// RQ, then normalized config, then brand, then model name. +fn compare_by_rq_config_brand_name( + (rq_a, config_a, brand_a, name_a): (&u8, &str, &str, &str), + (rq_b, config_b, brand_b, name_b): (&u8, &str, &str, &str), +) -> std::cmp::Ordering { + rq_a.cmp(rq_b).then(config_a.cmp(config_b)).then(brand_a.cmp(brand_b)).then(name_a.cmp(name_b)) +} + /// Comparison function for sorting `BestCarRecord`s by RQ, then normalized config, /// then brand, then model name. /// /// Avoids cloning into a `Car` just for sorting — uses the same ordering as /// `compare_cars` but operates directly on `BestCarRecord`. pub fn compare_records(a: &BestCarRecord, b: &BestCarRecord) -> std::cmp::Ordering { - a.rq.cmp(&b.rq) - .then(a.normalized_config.cmp(&b.normalized_config)) - .then(a.brand.cmp(&b.brand)) - .then(a.only_name.cmp(&b.only_name)) + compare_by_rq_config_brand_name( + (&a.rq, &a.normalized_config, &a.brand, &a.only_name), + (&b.rq, &b.normalized_config, &b.brand, &b.only_name), + ) } // --------------------------------------------------------------------------- @@ -46,7 +55,7 @@ pub fn compare_records(a: &BestCarRecord, b: &BestCarRecord) -> std::cmp::Orderi /// If the write callback returns an error, the temporary file is cleaned up /// and no partial file is left at the target path. fn atomic_write( - path: &PathBuf, + path: &Path, write_fn: impl FnOnce(&mut fs::File) -> Result<(), AppError>, ) -> Result<(), AppError> { let tmp_path = PathBuf::from(format!("{}.tmp", path.display())); @@ -156,25 +165,216 @@ pub fn write_very_best_csv( ) } +// --------------------------------------------------------------------------- +// Shared lookup +// --------------------------------------------------------------------------- + +/// Returns the `(record, count)` entry with the highest track count for `guid`, +/// or `None` if the car is not best on any track (in any config). +fn best_entry_for_guid<'a>( + counts: &'a CarCounts, + guid: &str, +) -> Option<&'a (BestCarRecord, usize)> { + counts + .guid_to_rid + .get(guid)? + .iter() + .filter_map(|key| counts.by_rid.get(key)) + .max_by_key(|(_, count)| *count) +} + // --------------------------------------------------------------------------- // CSV output: trash // --------------------------------------------------------------------------- +/// Per-decade maximum track counts for the improve-based trash criteria. +/// +/// Derived from a single base value for RQ 10-19: each subsequent decade is +/// half of the previous one (integer division). With the default base of 4 +/// this yields 4 / 2 / 1, matching the halving rule (even numbers halve +/// exactly). RQ 40+ is never covered. +/// +/// Integer division keeps the thresholds monotonic: raising the base never +/// shrinks the derived thresholds, which the garage-space escalation relies on. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TrashThresholds { + /// Maximum track count for RQ 10-19 (the base value). + pub rq10_19: usize, + /// Maximum track count for RQ 20-29 (half of the base). + pub rq20_29: usize, + /// Maximum track count for RQ 30-39 (a quarter of the base). + pub rq30_39: usize, +} + +impl TrashThresholds { + /// Derives thresholds from the base value for RQ 10-19. + pub fn from_base(base: usize) -> Self { + TrashThresholds { rq10_19: base, rq20_29: base / 2, rq30_39: base / 4 } + } + + /// Returns the maximum track count for a car with the given RQ, or `None` + /// if the RQ band is not covered by the criteria (RQ < 10 or RQ >= 40; + /// the car database starts at RQ 10). + pub fn max_count_for_rq(&self, rq: u8) -> Option { + match rq { + 10..=19 => Some(self.rq10_19), + 20..=29 => Some(self.rq20_29), + 30..=39 => Some(self.rq30_39), + _ => None, + } + } +} + +/// Garage-space projection accompanying a [`TrashThresholds`] decision. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct GarageProjection { + /// Total garage slot capacity (0 = unknown, export had no garage data). + pub garage_slots: usize, + /// Currently occupied garage slots. + pub slots_used: usize, + /// Number of unlocked deck cards (counting copies) that are NOT best + /// anywhere — deletion candidates. + pub unlocked_freed: usize, + /// Number of unlocked deck cards (counting copies) that are best somewhere + /// (keep candidates → protect.csv). + pub unlocked_protected: usize, + /// Cars falling into trash.csv via the improve-based criteria. + pub trash_candidates: usize, + /// Free slots after the projected clean-up; `None` if garage data is unknown. + pub projected_free: Option, + /// The base threshold value that was actually applied. + pub base_count: usize, + /// The base threshold value the caller configured. + pub configured_base_count: usize, +} + +impl GarageProjection { + /// Number of slots free right now, if garage data is known. + pub fn free_now(&self) -> Option { + self.garage_slots.checked_sub(self.slots_used).filter(|_| self.garage_slots > 0) + } +} + +/// Determines the effective trash thresholds, escalating the base value if the +/// garage-space projection falls below `min_free` free slots. +/// +/// Projection: `free = garage_slots - slots_used + unlocked cards that are not +/// best anywhere + trash candidates` (unlocked cards counted per copy). +/// Unlocked best cars are NOT counted as freed because they are meant to be +/// locked (kept) via protect.csv. +/// +/// `improve` holds the precomputed improve entries (see +/// [`collect_improve_entries`]) so callers that also write trash.csv can +/// compute them once. +/// +/// The base is raised step by step while the projection stays below `min_free`, +/// but only as long as raising it actually adds trash candidates. When garage +/// data is missing (`garage_slots == 0`), no escalation happens and +/// `projected_free` is `None`. +pub fn plan_trash_rules( + counts: &CarCounts, + improve: &[ImproveEntry<'_>], + unlocked: &HashMap, + configured_base_count: usize, + garage_slots: usize, + garage_slots_used: usize, + min_free: usize, +) -> (TrashThresholds, GarageProjection) { + // Only cars that write_trash_csv would actually list: prize cars and + // RQ >= 80 are always excluded from trash. + let eligible: Vec<_> = + improve.iter().filter(|entry| !entry.car.prize && entry.car.rq < TRASH_MIN_RQ).collect(); + let candidates_for = |thresholds: &TrashThresholds| -> usize { + eligible + .iter() + .filter(|entry| { + thresholds.max_count_for_rq(entry.car.rq).is_some_and(|m| entry.track_count <= m) + }) + .count() + }; + + let unlocked_freed = unlocked + .iter() + .filter(|(guid, _)| !counts.guid_to_rid.contains_key(*guid)) + .map(|(_, copies)| *copies) + .sum(); + let unlocked_protected = unlocked + .iter() + .filter(|(guid, _)| counts.guid_to_rid.contains_key(*guid)) + .map(|(_, copies)| *copies) + .sum(); + + let project = |base: usize, candidates: usize| GarageProjection { + garage_slots, + slots_used: garage_slots_used, + unlocked_freed, + unlocked_protected, + trash_candidates: candidates, + projected_free: if garage_slots > 0 { + Some( + garage_slots + .saturating_sub(garage_slots_used) + .saturating_add(unlocked_freed) + .saturating_add(candidates), + ) + } else { + None + }, + base_count: base, + configured_base_count, + }; + + let mut base = configured_base_count; + let mut candidates = candidates_for(&TrashThresholds::from_base(base)); + let mut projection = project(base, candidates); + + while garage_slots > 0 + && min_free > 0 + && projection.projected_free.is_some_and(|free| free < min_free) + { + let next_base = base + 1; + let next_candidates = candidates_for(&TrashThresholds::from_base(next_base)); + if next_candidates == candidates { + break; // raising the base frees nothing more + } + base = next_base; + candidates = next_candidates; + projection = project(base, candidates); + } + + (TrashThresholds::from_base(base), projection) +} + /// Writes trash.csv — owned cars that should be fused or sold. /// -/// A car is trash if **either**: +/// A car is trash if **any** of: /// 1. Its GUID is not best on any track (in any config), **or** /// 2. Its actual tune config is a final/maxed build (sum ≥ 8) that is not -/// best on any track — even if other configs of the same car model are best. +/// best on any track — even if other configs of the same car model are best, **or** +/// 3. It has unfinished copies and its best remaining target config reaches +/// at most the RQ-band threshold (see [`TrashThresholds`]) — low-value +/// upgrade fodder. /// -/// Non-final builds (sum < 8) are excluded from category 2 because they -/// belong in `improve.csv` instead. Prize cars and RQ ≥ 80 are always excluded. +/// Non-final builds without a qualifying best target are excluded from +/// categories 1 and 2 because they belong in `improve.csv` instead (unless +/// caught by category 3). Prize cars and RQ ≥ 80 are always excluded. +/// +/// The Config column lists only the copies to delete: for category 2 just the +/// final builds that are not best on any track (a best-on-213-tracks 323 next +/// to a useless 332 shows only `332`); for categories 1 and 3 all owned +/// configs (the whole car goes). +/// +/// `improve` holds the precomputed improve entries (see +/// [`collect_improve_entries`]) so callers can share one computation with +/// [`plan_trash_rules`]. /// /// Format: RQ,Brand,Model,Year,Config pub fn write_trash_csv( output_dir: &str, counts: &CarCounts, owned: &HashSet, + thresholds: Option<&TrashThresholds>, + improve: &[ImproveEntry<'_>], ) -> Result<(), AppError> { /// A car identified as trash, with its actual tune config. struct TrashEntry<'a> { @@ -182,30 +382,57 @@ pub fn write_trash_csv( config: String, } + // Category 3: owned cars with unfinished copies whose best target count + // falls at or below the RQ-band threshold. + let low_value_guids: HashSet<&str> = thresholds + .map(|t| { + improve + .iter() + .filter(|entry| { + t.max_count_for_rq(entry.car.rq).is_some_and(|m| entry.track_count <= m) + }) + .map(|entry| entry.car.guid.as_str()) + .collect() + }) + .unwrap_or_default(); + let trash: Vec = owned .iter() - .filter(|guid| { - let Some(best_configs) = counts.guid_to_rid.get(*guid) else { - return true; - }; - if let Some(configs) = counts.owned_configs.get(*guid) { - // Any copy with a final build not matching any best config → trash - if configs.iter().any(|actual_config| { - is_final_build(actual_config) - && !best_configs.iter().any(|(_, nc)| nc == actual_config) - }) { - return true; - } - } - false - }) .filter_map(|guid| { let car = counts.car_map.get(guid)?; if car.prize || car.rq >= TRASH_MIN_RQ { return None; } - let config = - counts.owned_configs.get(guid).map(|configs| configs.join("/")).unwrap_or_default(); + // Categories 1 (not best anywhere) and 3 (low-value fodder) trash + // the whole car — every owned copy goes. Category 2 trashes only + // the copies whose final build is not best on any track; valuable + // copies of the same car (e.g. a best 323 next to a useless 332) + // stay and must not appear in the Config column. + let whole_car = + low_value_guids.contains(guid.as_str()) || !counts.guid_to_rid.contains_key(guid); + let config = if whole_car { + counts.owned_configs.get(guid).map(|configs| configs.join("/")).unwrap_or_default() + } else { + let best_configs = &counts.guid_to_rid[guid]; + counts + .owned_configs + .get(guid) + .map(|configs| { + configs + .iter() + .filter(|actual_config| { + is_final_build(actual_config) + && !best_configs.iter().any(|(_, nc)| nc == *actual_config) + }) + .cloned() + .collect::>() + }) + .unwrap_or_default() + .join("/") + }; + if !whole_car && config.is_empty() { + return None; + } Some(TrashEntry { car, config }) }) .collect(); @@ -230,6 +457,182 @@ pub fn write_trash_csv( ) } +// --------------------------------------------------------------------------- +// CSV output: protect / storage +// --------------------------------------------------------------------------- + +/// Number of copies of an unlocked car that are worth having **locked**. +/// +/// Each best config that survives the dynamic trash criteria deserves one +/// locked copy (multi-tune strategy, mirroring the wishlist's +/// copies-vs-qualifying-configs logic). Prize cars and RQ >= 80 always warrant +/// keeping one copy per best config (or a single copy when not best +/// anywhere). Returns 0 for cars worth nothing: not best on any track, or +/// best only in configs at or below their RQ-band threshold. +fn needed_locked_copies( + counts: &CarCounts, + guid: &str, + thresholds: Option<&TrashThresholds>, +) -> usize { + let Some(car) = counts.car_map.get(guid) else { + return 0; + }; + let Some(keys) = counts.guid_to_rid.get(guid) else { + return usize::from(car.prize || car.rq >= TRASH_MIN_RQ); + }; + if car.prize || car.rq >= TRASH_MIN_RQ { + return keys.len().max(1); + } + keys.iter() + .filter(|key| { + counts.by_rid.get(*key).is_some_and(|(_, count)| { + thresholds.and_then(|t| t.max_count_for_rq(car.rq)).is_none_or(|max| *count > max) + }) + }) + .count() +} + +/// Writes protect.csv — unlocked deck cars that are worth locking **more +/// copies of**: cars with no locked copies at all, or fewer locked copies +/// than there are valuable configs. +/// +/// A car drops off the list once its locked-copy count (`copy_count`) covers +/// every best config surviving the dynamic trash criteria (see +/// [`TrashThresholds`]). The `Count` column shows the number of tracks the +/// car is best on under its strongest config; `ToLock` is the number of +/// additional copies worth locking. +/// +/// Format: RQ,Brand,Model,Year,FinalBuild,Count,ToLock +/// +/// Returns the number of cars written. +pub fn write_protect_csv( + output_dir: &str, + counts: &CarCounts, + unlocked: &HashMap, + thresholds: Option<&TrashThresholds>, +) -> Result { + /// A car worth locking more copies of. + struct ProtectEntry<'a> { + best: &'a (BestCarRecord, usize), + to_lock: usize, + } + + let entries: Vec = unlocked + .keys() + .filter_map(|guid| { + let needed = needed_locked_copies(counts, guid, thresholds); + let locked = counts.copy_count.get(guid).copied().unwrap_or(0); + if needed == 0 || locked >= needed { + return None; + } + best_entry_for_guid(counts, guid) + .map(|best| ProtectEntry { best, to_lock: needed - locked }) + }) + .collect(); + + let written = entries.len(); + write_sorted_csv( + output_dir, + "protect.csv", + "RQ,Brand,Model,Year,FinalBuild,Count,ToLock", + &entries, + |a, b| compare_records(&a.best.0, &b.best.0), + |entry, file| { + writeln!( + file, + "{},{},{},{},{},{},{}", + entry.best.0.rq, + escape_csv_field(&entry.best.0.brand), + escape_csv_field(&entry.best.0.only_name), + entry.best.0.year, + entry.best.0.normalized_config, + entry.best.1, + entry.to_lock + ) + }, + )?; + Ok(written) +} + +/// Writes storage.csv — a keep/trash recommendation for every unlocked deck +/// car ("storage"), applying the same dynamic criteria as protect.csv and +/// trash.csv. +/// +/// Recommendation is `Keep` (lock the car) or `Trash` (delete or fuse it). +/// `Keep` requires more valuable configs than already-locked copies: cars not +/// best on any track, best only in configs at or below their RQ-band +/// threshold, or with enough locked copies already (surplus unlocked copies +/// are fusion material), are `Trash`. `FinalBuild`/`Count` describe the car's +/// strongest config and are empty/zero when the car is not best anywhere. +/// +/// Format: RQ,Brand,Model,Year,FinalBuild,Count,Recommendation +/// +/// Returns the number of `Keep` and `Trash` rows written. +pub fn write_storage_csv( + output_dir: &str, + counts: &CarCounts, + unlocked: &HashMap, + thresholds: Option<&TrashThresholds>, +) -> Result<(usize, usize), AppError> { + /// An unlocked car with its keep/trash recommendation. + struct StorageEntry<'a> { + car: &'a Car, + final_build: String, + count: usize, + keep: bool, + } + + let mut entries: Vec = Vec::new(); + let mut keep_count = 0usize; + + for guid in unlocked.keys() { + let Some(car) = counts.car_map.get(guid) else { + log::warn!("unlocked car {guid} not found in cars database — skipped for storage"); + continue; + }; + let best = best_entry_for_guid(counts, guid); + let needed = needed_locked_copies(counts, guid, thresholds); + let locked = counts.copy_count.get(guid).copied().unwrap_or(0); + let keep = needed > locked; + if keep { + keep_count += 1; + } + entries.push(StorageEntry { + car, + final_build: best + .map(|(record, _)| record.normalized_config.clone()) + .unwrap_or_default(), + count: best.map(|(_, count)| *count).unwrap_or(0), + keep, + }); + } + + let trash_count = entries.len() - keep_count; + + write_sorted_csv( + output_dir, + "storage.csv", + "RQ,Brand,Model,Year,FinalBuild,Count,Recommendation", + &entries, + |a, b| compare_cars(a.car, b.car).then(a.final_build.cmp(&b.final_build)), + |entry, file| { + writeln!( + file, + "{},{},{},{},{},{},{}", + entry.car.rq, + escape_csv_field(&entry.car.brand), + escape_csv_field(&entry.car.only_name), + entry.car.year, + escape_csv_field(&entry.final_build), + entry.count, + if entry.keep { "Keep" } else { "Trash" } + ) + }, + )?; + + Ok((keep_count, trash_count)) +} + // --------------------------------------------------------------------------- // CSV output: details // --------------------------------------------------------------------------- @@ -238,17 +641,48 @@ pub fn write_trash_csv( /// /// Outputs comprehensive performance data showing the best car for every combination /// of track, surface condition, and RQ level. Results are sorted by RQ then track ID -/// for consistent output ordering. +/// for consistent output ordering. Same-car tie duplicates are collapsed to the +/// relevant config (see [`resolve_tied_configs`]) so each car appears at most once +/// per track/condition/RQ. +/// +/// # Memory +/// +/// Takes a full snapshot of the `best_cars` DashMap (cloning every key and record +/// list) so it can be sorted and written without holding DashMap shard locks. +/// Peak memory roughly doubles for the duration of the write — accepted here +/// because this runs once at the end of analysis, not in a hot loop. /// /// # Output Format /// ```csv /// RQ,TrackID,Condition,CarRQ,Brand,Model,Year,Config,Time /// 85,laguna,Dry,85,McLaren,F1,1993,233,112.45 /// ``` -pub fn write_details_csv(output_dir: &str, ctx: &AnalysisContext) -> Result<(), AppError> { +pub fn write_details_csv( + output_dir: &str, + ctx: &AnalysisContext, + counts: &CarCounts, +) -> Result<(), AppError> { + // guid → owned final-build configs for same-car tie resolution. + let owned_final: HashMap<&str, HashSet<&str>> = counts + .owned_configs + .iter() + .map(|(guid, configs)| { + ( + guid.as_str(), + configs.iter().filter(|c| is_final_build(c)).map(|c| c.as_str()).collect(), + ) + }) + .collect(); + // Collect data from DashMap — no global lock needed - let mut sorted_data: Vec<(TripletKey, Vec)> = - ctx.best_cars.iter().map(|entry| (entry.key().clone(), entry.value().clone())).collect(); + let mut sorted_data: Vec<(TripletKey, Vec)> = ctx + .best_cars + .iter() + .map(|entry| { + let resolved = resolve_tied_configs(entry.value(), &owned_final); + (entry.key().clone(), resolved.into_iter().cloned().collect()) + }) + .collect(); // Sort by RQ then Track ID for consistent output sorted_data.sort_by(|a, b| a.0 .1.cmp(&b.0 .1).then(a.0 .0.cmp(&b.0 .0))); @@ -286,13 +720,19 @@ pub fn write_details_csv(output_dir: &str, ctx: &AnalysisContext) -> Result<(), /// /// A car qualifies only when **one config** reaches `min_count` tracks — counts are /// NOT summed across different configs. If multiple configs for the same car qualify, -/// the one with the highest count is kept as the representative entry. +/// the one with the highest count is kept as the representative entry; on equal +/// counts the first record seen in `by_rid` iteration order wins (deterministic +/// but arbitrary, since `by_rid` is a `HashMap`). /// /// **Owned cars** are included only if the player has a **final build** that differs /// from the qualifying config. This allows wishlisting a second copy for a different /// tune (e.g., the player owns config 323 but needs config 332). /// /// Non-final builds are excluded — they belong in `improve.csv` instead. +/// +/// **Prize cars** are excluded: they cannot be acquired through normal means, +/// so wishlisting them is pointless. Prize cars still present on the player's +/// actual wishlist surface in `remove.csv` via the ideal-set difference. pub fn compute_ideal_wishlist( counts: &CarCounts, owned: &HashSet, @@ -318,6 +758,9 @@ pub fn compute_ideal_wishlist( .collect(); for (record, count) in counts.by_rid.values() { + if record.prize { + continue; + } if *count < min_count { continue; } @@ -418,23 +861,15 @@ pub fn write_wishlist_files( .filter(|guid| !ideal_guids.contains(*guid)) .filter_map(|guid| { // First try: find the best entry for this car (if it's best on any track) - if let Some(keys) = counts.guid_to_rid.get(guid) { - // Pick the entry with the highest count (most impactful config) - if let Some(key) = keys - .iter() - .max_by_key(|key| counts.by_rid.get(key).map(|(_, c)| *c).unwrap_or(0)) - { - if let Some((record, count)) = counts.by_rid.get(key) { - return Some(RemoveEntry { - brand: record.brand.clone(), - only_name: record.only_name.clone(), - year: record.year, - rq: record.rq, - normalized_config: record.normalized_config.clone(), - track_count: *count, - }); - } - } + if let Some((record, count)) = best_entry_for_guid(counts, guid) { + return Some(RemoveEntry { + brand: record.brand.clone(), + only_name: record.only_name.clone(), + year: record.year, + rq: record.rq, + normalized_config: record.normalized_config.clone(), + track_count: *count, + }); } // Fallback: use car_map for cars not in best data (not best on any track) @@ -454,9 +889,7 @@ pub fn write_wishlist_files( }) .collect(); if dropped_count > 0 { - eprintln!( - "Warning: {dropped_count} wishlist car(s) not found in cars database and omitted from remove.csv" - ); + log::warn!("{dropped_count} wishlist car(s) not found in cars database and omitted from remove.csv"); } write_sorted_csv( @@ -465,10 +898,10 @@ pub fn write_wishlist_files( "RQ,Brand,Model,Year,FinalBuild,Count", &to_remove, |a, b| { - a.rq.cmp(&b.rq) - .then(a.normalized_config.cmp(&b.normalized_config)) - .then(a.brand.cmp(&b.brand)) - .then(a.only_name.cmp(&b.only_name)) + compare_by_rq_config_brand_name( + (&a.rq, &a.normalized_config, &a.brand, &a.only_name), + (&b.rq, &b.normalized_config, &b.brand, &b.only_name), + ) }, |entry, file| { writeln!( @@ -491,6 +924,84 @@ pub fn write_wishlist_files( // CSV output: improve // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Improve entry collection (shared by improve.csv and trash.csv) +// --------------------------------------------------------------------------- + +/// An owned car with at least one unfinished copy, together with its best +/// remaining target config (highest track count), excluding configs the player +/// already owns as final builds on another copy of the same car. +pub struct ImproveEntry<'a> { + /// Borrowed car metadata from the counts' car map. + pub car: &'a Car, + /// Best remaining target config (highest track count). + pub target: String, + /// Number of tracks this car is best on under `target`. + pub track_count: usize, +} + +/// Collects improve entries for all owned cars with at least one unfinished copy. +/// +/// For each car, exactly one entry is produced: the best remaining target +/// config (highest track count), excluding configs the player already owns +/// as final builds on another copy of the same car. Cars whose copies are all +/// final builds are skipped. +pub fn collect_improve_entries(counts: &CarCounts) -> Vec> { + // Pre-compute configs already owned as final builds, grouped by car_rid. + // This lets us exclude targets the player already has on another copy. + // Borrowed keys/values — the hot-path membership check below allocates nothing. + let owned_final_configs: HashMap<&str, HashSet<&str>> = counts + .owned_configs + .iter() + .filter_map(|(guid, configs)| counts.car_map.get(guid).map(|car| (car, configs))) + .flat_map(|(car, configs)| { + let rid = car.rid.as_str(); + configs.iter().filter(|c| is_final_build(c)).map(move |c| (rid, c.as_str())) + }) + .fold(HashMap::new(), |mut map, (rid, config)| { + map.entry(rid).or_default().insert(config); + map + }); + + let mut entries: Vec = Vec::new(); + + for (guid, configs) in &counts.owned_configs { + // Skip this car if ALL copies are already at final builds + let has_unfinished = configs.iter().any(|c| !is_final_build(c)); + if !has_unfinished { + continue; + } + + let Some(car) = counts.car_map.get(guid) else { + log::warn!("owned car {guid} not found in cars database — skipped for improve"); + continue; + }; + + // Collect target configs, excluding any already owned on another copy of this car. + // Keep only the best remaining target (highest track count). + if let Some(keys) = counts.guid_to_rid.get(guid) { + let best_target = + keys.iter() + .filter_map(|key| { + let (record, track_count) = counts.by_rid.get(key)?; + if owned_final_configs.get(car.rid.as_str()).is_some_and(|finals| { + finals.contains(record.normalized_config.as_str()) + }) { + return None; + } + Some((record.normalized_config.clone(), *track_count)) + }) + .max_by_key(|(_, target_count)| *target_count); + + if let Some((target, track_count)) = best_target { + entries.push(ImproveEntry { car, target, track_count }); + } + } + } + + entries +} + /// Writes improve.csv — owned locked cars with at least one unfinished copy, /// sorted by number of best tracks descending (most impactful upgrades first). /// @@ -506,73 +1017,10 @@ pub fn write_wishlist_files( /// best on under that specific normalized config. /// /// Format: RQ,Brand,Model,Year,Target,Count +/// +/// Returns the number of cars written. pub fn write_improve_csv(output_dir: &str, counts: &CarCounts) -> Result { - /// A car that needs upgrading to a final build, with its target config and track count. - struct ImproveEntry { - brand: String, - only_name: String, - year: u16, - rq: u8, - target: String, - track_count: usize, - } - - // Pre-compute configs already owned as final builds, grouped by car_rid. - // This lets us exclude targets the player already has on another copy. - let owned_final_configs: HashSet<(String, String)> = counts - .owned_configs - .iter() - .filter_map(|(guid, configs)| counts.car_map.get(guid).map(|car| (car, configs))) - .flat_map(|(car, configs)| { - configs.iter().filter(|c| is_final_build(c)).map(|c| (car.rid.clone(), c.clone())) - }) - .collect(); - - // Iterate over owned cars from the player's actual config data. - // For each owned car, check if its actual config is NOT a final build. - let mut entries: Vec = Vec::new(); - - for (guid, configs) in &counts.owned_configs { - // Skip this car if ALL copies are already at final builds - let has_unfinished = configs.iter().any(|c| !is_final_build(c)); - if !has_unfinished { - continue; - } - - let Some(car) = counts.car_map.get(guid) else { - eprintln!("Warning: owned car {guid} not found in cars database — skipped for improve"); - continue; - }; - - // Collect target configs, excluding any already owned on another copy of this car. - // Keep only the best remaining target (highest track count). - if let Some(keys) = counts.guid_to_rid.get(guid) { - let best_target = keys - .iter() - .filter_map(|key| { - let (record, track_count) = counts.by_rid.get(key)?; - if owned_final_configs - .contains(&(car.rid.clone(), record.normalized_config.clone())) - { - return None; - } - Some((record.normalized_config.clone(), *track_count)) - }) - .max_by_key(|(_, count)| *count); - - if let Some((target, track_count)) = best_target { - entries.push(ImproveEntry { - brand: car.brand.clone(), - only_name: car.only_name.clone(), - year: car.year, - rq: car.rq, - target, - track_count, - }); - } - } - } - + let entries = collect_improve_entries(counts); let count = entries.len(); // Sort by count descending (most tracks first), then by RQ/target/brand/model @@ -585,19 +1033,17 @@ pub fn write_improve_csv(output_dir: &str, counts: &CarCounts) -> Result = content.lines().collect(); @@ -940,7 +1386,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let output_dir = dir.path().to_str().unwrap(); - write_trash_csv(output_dir, &counts, &owned).unwrap(); + write_trash_csv(output_dir, &counts, &owned, None, &[]).unwrap(); let content = fs::read_to_string(dir.path().join("trash.csv")).unwrap(); let lines: Vec<&str> = content.lines().collect(); @@ -964,6 +1410,43 @@ mod tests { ); } + #[test] + fn test_write_trash_csv_partial_copies_shows_only_deletable_config() { + let ctx = AnalysisContext::new(); + + // Car_A is best on 3 tracks with 323; player owns a 332 copy too. + let mut rec_323 = make_record("Car_A", 10, 90.0, "guid_a"); + rec_323.config = "323".to_string(); + rec_323.normalized_config = "323".to_string(); + + for i in 0..3 { + ctx.best_cars + .insert((format!("Track{i}"), 10, "Dry".to_string()), vec![rec_323.clone()]); + } + + let car_list = vec![make_car("Car_A", 10, "guid_a")]; + + let mut owned_configs = HashMap::new(); + owned_configs.insert("guid_a".to_string(), vec!["332".to_string(), "323".to_string()]); + + let counts = compute_car_counts(&ctx, car_list, owned_configs, HashMap::new()); + + let owned: HashSet = vec!["guid_a".to_string()].into_iter().collect(); + + let dir = tempfile::tempdir().unwrap(); + let output_dir = dir.path().to_str().unwrap(); + write_trash_csv(output_dir, &counts, &owned, None, &[]).unwrap(); + + let content = fs::read_to_string(dir.path().join("trash.csv")).unwrap(); + let lines: Vec<&str> = content.lines().collect(); + + assert_eq!(lines.len(), 2, "Car_A should be trash via its 332 copy, got: {lines:?}"); + assert_eq!( + lines[1], "10,TestBrand,TestModel,2020,332", + "Config column must list only the deletable 332 copy, not 332/323" + ); + } + // ======================================================================= // compare_records tests // ======================================================================= @@ -1018,7 +1501,6 @@ mod tests { assert!(counts.by_rid.is_empty()); assert!(counts.guid_to_rid.is_empty()); assert!(counts.car_map.is_empty()); - assert!(counts.car_list.is_empty()); } #[test] @@ -1540,4 +2022,580 @@ mod tests { "2 copies / 2 qualifying (3 total) → should NOT be wishlisted" ); } + + // ======================================================================= + // Prize car exclusion tests + // ======================================================================= + + #[test] + fn test_wishlist_excludes_prize_cars() { + let ctx = AnalysisContext::new(); + + let mut rec_prize = make_record("Car_Prize", 85, 100.0, "guid_prize"); + rec_prize.prize = true; + let rec_normal = make_record("Car_Normal", 85, 101.0, "guid_normal"); + + for i in 0..15 { + ctx.best_cars + .insert((format!("Track{i}"), 85, "Dry".to_string()), vec![rec_prize.clone()]); + ctx.best_cars + .insert((format!("Track{i}"), 85, "Wet".to_string()), vec![rec_normal.clone()]); + } + + let car_list = vec![ + make_car("Car_Prize", 85, "guid_prize"), + make_car("Car_Normal", 85, "guid_normal"), + ]; + let counts = compute_car_counts(&ctx, car_list, empty_my_data(), HashMap::new()); + + let owned: HashSet = HashSet::new(); + let wishlist = compute_ideal_wishlist(&counts, &owned, 10, 300); + + assert_eq!(wishlist.len(), 1, "Only the non-prize car should be wishlisted"); + assert_eq!(wishlist[0].0.guid, "guid_normal"); + } + + #[test] + fn test_prize_car_on_real_wishlist_lands_in_remove_csv() { + let ctx = AnalysisContext::new(); + + let mut rec_prize = make_record("Car_Prize", 85, 100.0, "guid_prize"); + rec_prize.prize = true; + + for i in 0..15 { + ctx.best_cars + .insert((format!("Track{i}"), 85, "Dry".to_string()), vec![rec_prize.clone()]); + } + + let car_list = vec![make_car("Car_Prize", 85, "guid_prize")]; + let counts = compute_car_counts(&ctx, car_list, empty_my_data(), HashMap::new()); + + let dir = tempfile::tempdir().unwrap(); + let output_dir = dir.path().to_str().unwrap(); + + let real_wishlist: HashSet = vec!["guid_prize".to_string()].into_iter().collect(); + write_wishlist_files(output_dir, &counts, &HashSet::new(), &real_wishlist, 10, 300) + .unwrap(); + + let wishlist_content = fs::read_to_string(dir.path().join("wishlist.csv")).unwrap(); + assert_eq!( + wishlist_content.lines().count(), + 1, + "wishlist.csv should only contain the header: {:?}", + wishlist_content + ); + let add_content = fs::read_to_string(dir.path().join("add.csv")).unwrap(); + assert_eq!(add_content.lines().count(), 1, "add.csv should only contain the header"); + + let remove_content = fs::read_to_string(dir.path().join("remove.csv")).unwrap(); + let lines: Vec<&str> = remove_content.lines().collect(); + assert_eq!(lines.len(), 2, "Prize car should appear in remove.csv: {remove_content}"); + assert!(lines[1].contains("TestModel"), "Row should describe the prize car"); + } + + // ======================================================================= + // protect.csv tests + // ======================================================================= + + #[test] + fn test_write_protect_csv_lists_only_best_unlocked_cars() { + let ctx = AnalysisContext::new(); + + let rec_a = make_record("Car_A", 85, 100.0, "guid_a"); + for i in 0..5 { + ctx.best_cars.insert((format!("Track{i}"), 85, "Dry".to_string()), vec![rec_a.clone()]); + } + + // Car_B is not best anywhere; Car_C is best but locked (owned). + let car_list = vec![ + make_car("Car_A", 85, "guid_a"), + make_car("Car_B", 70, "guid_b"), + make_car("Car_C", 90, "guid_c"), + ]; + let counts = compute_car_counts(&ctx, car_list, empty_my_data(), HashMap::new()); + + let unlocked: HashMap = vec![ + ("guid_a".to_string(), 1), // unlocked and best → protect + ("guid_b".to_string(), 2), // unlocked but not best → no row + ] + .into_iter() + .collect(); + + let dir = tempfile::tempdir().unwrap(); + let output_dir = dir.path().to_str().unwrap(); + let written = write_protect_csv(output_dir, &counts, &unlocked, None).unwrap(); + + assert_eq!(written, 1); + let content = fs::read_to_string(dir.path().join("protect.csv")).unwrap(); + let lines: Vec<&str> = content.lines().collect(); + assert_eq!(lines[0], "RQ,Brand,Model,Year,FinalBuild,Count,ToLock"); + assert_eq!(lines.len(), 2, "Only Car_A should be protected: {content}"); + assert!(lines[1].starts_with("85,"), "Row should be Car_A (RQ 85)"); + assert!(lines[1].ends_with(",1"), "one copy to lock: {content}"); + } + + #[test] + fn test_protect_csv_skips_cars_with_enough_locked_copies() { + // Car has two valuable configs (332 on 5 tracks, 323 on 3 tracks). + // With 2 locked copies the unlocked copy is surplus → no row. + // With 1 locked copy one more is worth locking → row with ToLock 1. + let ctx = AnalysisContext::new(); + + let mut rec_332 = make_record("Car_A", 85, 90.0, "guid_a"); + rec_332.config = "332".to_string(); + rec_332.normalized_config = "332".to_string(); + let mut rec_323 = make_record("Car_A", 85, 95.0, "guid_a"); + rec_323.config = "323".to_string(); + rec_323.normalized_config = "323".to_string(); + + for i in 0..5 { + ctx.best_cars + .insert((format!("TrackA{i}"), 85, "Dry".to_string()), vec![rec_332.clone()]); + } + for i in 0..3 { + ctx.best_cars + .insert((format!("TrackB{i}"), 85, "Dry".to_string()), vec![rec_323.clone()]); + } + + let unlocked: HashMap = + vec![("guid_a".to_string(), 1)].into_iter().collect(); + + for (locked_copies, expect_row) in [(2usize, false), (1, true)] { + let car_list = vec![make_car("Car_A", 85, "guid_a")]; + let mut copy_count = HashMap::new(); + copy_count.insert("guid_a".to_string(), locked_copies); + let counts = compute_car_counts(&ctx, car_list, empty_my_data(), copy_count); + + let dir = tempfile::tempdir().unwrap(); + let output_dir = dir.path().to_str().unwrap(); + let written = write_protect_csv(output_dir, &counts, &unlocked, None).unwrap(); + assert_eq!(written, usize::from(expect_row), "{locked_copies} locked copies"); + if expect_row { + let content = fs::read_to_string(dir.path().join("protect.csv")).unwrap(); + assert!(content.lines().nth(1).unwrap().ends_with(",1"), "ToLock 1: {content}"); + } + } + } + + #[test] + fn test_write_protect_csv_empty_when_no_best_unlocked_cars() { + let ctx = AnalysisContext::new(); + let counts = compute_car_counts(&ctx, vec![], empty_my_data(), HashMap::new()); + + let unlocked: HashMap = + vec![("guid_b".to_string(), 1)].into_iter().collect(); + + let dir = tempfile::tempdir().unwrap(); + let output_dir = dir.path().to_str().unwrap(); + let written = write_protect_csv(output_dir, &counts, &unlocked, None).unwrap(); + + assert_eq!(written, 0); + let content = fs::read_to_string(dir.path().join("protect.csv")).unwrap(); + assert_eq!(content.lines().count(), 1, "Only the header should be written"); + } + + #[test] + fn test_protect_csv_applies_dynamic_thresholds() { + // RQ 15 car best on 2 tracks: 2 <= threshold 4 → not protected. + // RQ 25 car best on 3 tracks: 3 > threshold 2 → protected. + // RQ 85 car best on 1 track: no band → protected. + let ctx = AnalysisContext::new(); + + let specs = [ + ("Car_Low", 15u8, 2usize, "guid_low"), + ("Car_Mid", 25, 3, "guid_mid"), + ("Car_High", 85, 1, "guid_high"), + ]; + let mut car_list = Vec::new(); + for (rid, rq, tracks, guid) in specs { + let rec = make_record(rid, rq, 100.0, guid); + for i in 0..tracks { + ctx.best_cars + .insert((format!("Track{rid}_{i}"), rq, "Dry".to_string()), vec![rec.clone()]); + } + car_list.push(make_car(rid, rq, guid)); + } + let counts = compute_car_counts(&ctx, car_list, empty_my_data(), HashMap::new()); + + let unlocked: HashMap = + specs.iter().map(|(_, _, _, guid)| (guid.to_string(), 1)).collect(); + + let dir = tempfile::tempdir().unwrap(); + let output_dir = dir.path().to_str().unwrap(); + let thresholds = TrashThresholds::from_base(4); + let written = write_protect_csv(output_dir, &counts, &unlocked, Some(&thresholds)).unwrap(); + + assert_eq!(written, 2, "low-value car should be filtered out"); + let content = fs::read_to_string(dir.path().join("protect.csv")).unwrap(); + let rows: Vec<&str> = content.lines().skip(1).collect(); + let rqs: Vec = rows.iter().filter_map(|r| r.split(',').next()?.parse().ok()).collect(); + assert_eq!(rqs, vec![25, 85], "only cars above the band thresholds, got: {content}"); + } + + // ======================================================================= + // storage.csv tests + // ======================================================================= + + #[test] + fn test_storage_csv_recommendations() { + // guid_not_best: not best anywhere → Trash + // guid_low: RQ 15, best on 2 tracks (2 <= 4) → Trash + // guid_mid: RQ 25, best on 3 tracks (3 > 2) → Keep + // guid_high: RQ 85, best on 1 track → Keep (no band) + // guid_prize: RQ 15 prize car, not best anywhere → Keep (never trash) + let ctx = AnalysisContext::new(); + + let specs = [ + ("Car_Low", 15u8, 2usize, "guid_low"), + ("Car_Mid", 25, 3, "guid_mid"), + ("Car_High", 85, 1, "guid_high"), + ]; + let mut car_list = Vec::new(); + for (rid, rq, tracks, guid) in specs { + let rec = make_record(rid, rq, 100.0, guid); + for i in 0..tracks { + ctx.best_cars + .insert((format!("Track{rid}_{i}"), rq, "Dry".to_string()), vec![rec.clone()]); + } + car_list.push(make_car(rid, rq, guid)); + } + let mut car_prize = make_car("Car_Prize", 15, "guid_prize"); + car_prize.prize = true; + car_list.push(car_prize); + car_list.push(make_car("Car_NotBest", 15, "guid_not_best")); + + let counts = compute_car_counts(&ctx, car_list, empty_my_data(), HashMap::new()); + + let unlocked: HashMap = vec![ + ("guid_not_best".to_string(), 1), + ("guid_low".to_string(), 1), + ("guid_mid".to_string(), 1), + ("guid_high".to_string(), 1), + ("guid_prize".to_string(), 1), + ] + .into_iter() + .collect(); + + let dir = tempfile::tempdir().unwrap(); + let output_dir = dir.path().to_str().unwrap(); + let thresholds = TrashThresholds::from_base(4); + let (keep, trash) = + write_storage_csv(output_dir, &counts, &unlocked, Some(&thresholds)).unwrap(); + + assert_eq!((keep, trash), (3, 2), "3 keep, 2 trash"); + + let content = fs::read_to_string(dir.path().join("storage.csv")).unwrap(); + assert_eq!( + content.lines().next().unwrap(), + "RQ,Brand,Model,Year,FinalBuild,Count,Recommendation" + ); + // Rows share brand/model (test helper); identify them by RQ + count. + let recommendation = |rq: u8, count: usize| -> String { + content + .lines() + .skip(1) + .find(|l| { + l.starts_with(&format!("{rq},")) + && l.split(',').nth(5) == Some(count.to_string().as_str()) + }) + .unwrap_or_else(|| panic!("no row for RQ {rq} count {count}: {content}")) + .rsplit(',') + .next() + .unwrap() + .to_string() + }; + // guid_not_best and guid_prize share RQ 15 count 0 — disambiguated below. + // guid_low: RQ 15, count 2 → Trash + assert_eq!(recommendation(15, 2), "Trash"); + // guid_prize: RQ 15, count 0 but Keep — disambiguate from not_best below + let rq15_count0_rows: Vec<&str> = content + .lines() + .skip(1) + .filter(|l| l.starts_with("15,") && l.split(',').nth(5) == Some("0")) + .collect(); + assert_eq!(rq15_count0_rows.len(), 2, "not_best and prize both RQ 15 count 0: {content}"); + assert_eq!(rq15_count0_rows.iter().filter(|l| l.ends_with("Trash")).count(), 1); + assert_eq!(rq15_count0_rows.iter().filter(|l| l.ends_with("Keep")).count(), 1); + // guid_mid: RQ 25, count 3 → Keep + assert_eq!(recommendation(25, 3), "Keep"); + // guid_high: RQ 85, count 1 → Keep + assert_eq!(recommendation(85, 1), "Keep"); + } + + #[test] + fn test_storage_csv_without_thresholds_keeps_all_best_cars() { + let ctx = AnalysisContext::new(); + + let rec = make_record("Car_Low", 15, 100.0, "guid_low"); + for i in 0..2 { + ctx.best_cars.insert((format!("Track{i}"), 15, "Dry".to_string()), vec![rec.clone()]); + } + let car_list = vec![make_car("Car_Low", 15, "guid_low")]; + let counts = compute_car_counts(&ctx, car_list, empty_my_data(), HashMap::new()); + + let unlocked: HashMap = + vec![("guid_low".to_string(), 1)].into_iter().collect(); + + let dir = tempfile::tempdir().unwrap(); + let output_dir = dir.path().to_str().unwrap(); + let (keep, trash) = write_storage_csv(output_dir, &counts, &unlocked, None).unwrap(); + + assert_eq!((keep, trash), (1, 0), "without thresholds every best car is kept"); + } + + #[test] + fn test_storage_csv_marks_surplus_copies_as_trash() { + // Car is best in one config (2 tracks); player already holds 1 locked + // copy — the unlocked storage copy is surplus fusion material. + let ctx = AnalysisContext::new(); + + let rec = make_record("Car_A", 15, 100.0, "guid_a"); + for i in 0..2 { + ctx.best_cars.insert((format!("Track{i}"), 15, "Dry".to_string()), vec![rec.clone()]); + } + + let car_list = vec![make_car("Car_A", 15, "guid_a")]; + let mut copy_count = HashMap::new(); + copy_count.insert("guid_a".to_string(), 1); + let counts = compute_car_counts(&ctx, car_list, empty_my_data(), copy_count); + + let storage: HashMap = vec![("guid_a".to_string(), 1)].into_iter().collect(); + + let dir = tempfile::tempdir().unwrap(); + let output_dir = dir.path().to_str().unwrap(); + let (keep, trash) = write_storage_csv(output_dir, &counts, &storage, None).unwrap(); + + assert_eq!((keep, trash), (0, 1), "surplus unlocked copy → Trash"); + } + + // ======================================================================= + // TrashThresholds tests + // ======================================================================= + + #[test] + fn test_trash_thresholds_from_base() { + let t = TrashThresholds::from_base(4); + assert_eq!((t.rq10_19, t.rq20_29, t.rq30_39), (4, 2, 1)); + } + + #[test] + fn test_trash_thresholds_monotonic_in_base() { + let lower = TrashThresholds::from_base(4); + let higher = TrashThresholds::from_base(7); + assert!(higher.rq10_19 >= lower.rq10_19); + assert!(higher.rq20_29 >= lower.rq20_29); + assert!(higher.rq30_39 >= lower.rq30_39); + } + + #[test] + fn test_max_count_for_rq_bands() { + let t = TrashThresholds::from_base(4); + assert_eq!(t.max_count_for_rq(5), None, "RQ < 10 is never covered"); + assert_eq!(t.max_count_for_rq(9), None); + assert_eq!(t.max_count_for_rq(10), Some(4)); + assert_eq!(t.max_count_for_rq(19), Some(4)); + assert_eq!(t.max_count_for_rq(20), Some(2)); + assert_eq!(t.max_count_for_rq(29), Some(2)); + assert_eq!(t.max_count_for_rq(30), Some(1)); + assert_eq!(t.max_count_for_rq(39), Some(1)); + assert_eq!(t.max_count_for_rq(40), None, "RQ 40+ is never covered"); + assert_eq!(t.max_count_for_rq(85), None); + } + + // ======================================================================= + // trash.csv low-value improve criteria tests + // ======================================================================= + + /// Builds counts where each car with RQ `rq` is best on exactly `tracks` + /// tracks and owned with an unfinished config ("111"). + fn counts_with_improve_cars(specs: &[(u8, usize)]) -> CarCounts { + let ctx = AnalysisContext::new(); + let mut car_list = Vec::new(); + let mut owned_configs = HashMap::new(); + + for (idx, (rq, tracks)) in specs.iter().enumerate() { + let guid = format!("guid_{idx}"); + let rid = format!("Car_{idx}"); + let rec = make_record(&rid, *rq, 100.0, &guid); + for i in 0..*tracks { + ctx.best_cars + .insert((format!("Track{idx}_{i}"), *rq, "Dry".to_string()), vec![rec.clone()]); + } + car_list.push(make_car(&rid, *rq, &guid)); + owned_configs.insert(guid, vec!["111".to_string()]); + } + + compute_car_counts(&ctx, car_list, owned_configs, HashMap::new()) + } + + #[test] + fn test_trash_csv_low_value_improve_cars() { + // (rq, track count): thresholds 4/2/1 apply per band + let counts = counts_with_improve_cars(&[ + (15, 4), // RQ 10-19, count 4 <= 4 → trash + (15, 5), // RQ 10-19, count 5 > 4 → keep + (25, 2), // RQ 20-29, count 2 <= 2 → trash + (25, 3), // RQ 20-29, count 3 > 2 → keep + (35, 1), // RQ 30-39, count 1 <= 1 → trash + (35, 2), // RQ 30-39, count 2 > 1 → keep + (45, 1), // RQ 40+ → never trash via these criteria + ]); + + let owned: HashSet = (0..7).map(|i| format!("guid_{i}")).collect(); + let thresholds = TrashThresholds::from_base(4); + + let dir = tempfile::tempdir().unwrap(); + let output_dir = dir.path().to_str().unwrap(); + write_trash_csv( + output_dir, + &counts, + &owned, + Some(&thresholds), + &collect_improve_entries(&counts), + ) + .unwrap(); + + let content = fs::read_to_string(dir.path().join("trash.csv")).unwrap(); + let rows: Vec<&str> = content.lines().skip(1).collect(); + let rqs: Vec = rows.iter().filter_map(|r| r.split(',').next()?.parse().ok()).collect(); + assert_eq!( + rqs, + vec![15, 25, 35], + "only cars at or below the band thresholds, got: {content}" + ); + } + + #[test] + fn test_trash_csv_without_thresholds_keeps_improve_cars() { + let counts = counts_with_improve_cars(&[(15, 1)]); + let owned: HashSet = vec!["guid_0".to_string()].into_iter().collect(); + + let dir = tempfile::tempdir().unwrap(); + let output_dir = dir.path().to_str().unwrap(); + write_trash_csv(output_dir, &counts, &owned, None, &[]).unwrap(); + + let content = fs::read_to_string(dir.path().join("trash.csv")).unwrap(); + assert_eq!( + content.lines().count(), + 1, + "best-somewhere unfinished car must stay out of trash.csv: {content}" + ); + } + + // ======================================================================= + // plan_trash_rules tests + // ======================================================================= + + #[test] + fn test_plan_trash_rules_no_escalation_when_space_sufficient() { + // 100 - 80 = 20 free now; one candidate → 21 >= 20 → base stays. + let counts = counts_with_improve_cars(&[(15, 4)]); + let unlocked = HashMap::new(); + + let (thresholds, projection) = + plan_trash_rules(&counts, &collect_improve_entries(&counts), &unlocked, 4, 100, 80, 20); + + assert_eq!(thresholds, TrashThresholds::from_base(4)); + assert_eq!(projection.base_count, 4); + assert_eq!(projection.trash_candidates, 1); + assert_eq!(projection.projected_free, Some(21)); + } + + #[test] + fn test_plan_trash_rules_escalates_until_target_or_saturation() { + // 100 - 99 = 1 free now. Base 4 → only the count-4 car qualifies (1 + // candidate, 2 free). Base 5 adds the count-5 car (2 candidates, 3 + // free). No further candidates exist, so escalation stops at base 5 + // with the target still unmet. + let counts = counts_with_improve_cars(&[(15, 4), (15, 5)]); + let unlocked = HashMap::new(); + + let (thresholds, projection) = + plan_trash_rules(&counts, &collect_improve_entries(&counts), &unlocked, 4, 100, 99, 20); + + assert_eq!(thresholds, TrashThresholds::from_base(5)); + assert_eq!(projection.base_count, 5); + assert_eq!(projection.configured_base_count, 4); + assert_eq!(projection.trash_candidates, 2); + assert_eq!(projection.projected_free, Some(3)); + } + + #[test] + fn test_plan_trash_rules_without_garage_data() { + let counts = counts_with_improve_cars(&[(15, 4)]); + let unlocked = HashMap::new(); + + let (thresholds, projection) = + plan_trash_rules(&counts, &collect_improve_entries(&counts), &unlocked, 4, 0, 0, 20); + + assert_eq!(thresholds, TrashThresholds::from_base(4)); + assert_eq!(projection.projected_free, None, "no garage data → no projection"); + } + + #[test] + fn test_plan_trash_rules_ignores_prize_and_high_rq_candidates() { + // Prize car and RQ >= 80 car would qualify by count but are never + // listed in trash.csv, so they must not count as candidates. + let ctx = AnalysisContext::new(); + let mut car_list = Vec::new(); + let mut owned_configs = HashMap::new(); + + let mut car_prize = make_car("Car_Prize", 15, "guid_prize"); + car_prize.prize = true; + let car_high = make_car("Car_High", 85, "guid_high"); + let car_low = make_car("Car_Low", 15, "guid_low"); + + for (car, tracks) in [(car_prize, 3), (car_high, 3), (car_low, 3)] { + let rec = make_record(&car.rid, car.rq, 100.0, &car.guid); + let suffix = car.rid.clone(); + for i in 0..tracks { + ctx.best_cars.insert( + (format!("Track{suffix}_{i}"), car.rq, "Dry".to_string()), + vec![rec.clone()], + ); + } + owned_configs.insert(car.guid.clone(), vec!["111".to_string()]); + car_list.push(car); + } + + let counts = compute_car_counts(&ctx, car_list, owned_configs, HashMap::new()); + let unlocked = HashMap::new(); + + let (_thresholds, projection) = plan_trash_rules( + &counts, + &collect_improve_entries(&counts), + &unlocked, + 4, + 100, + 100, + 20, + ); + + assert_eq!(projection.trash_candidates, 1, "only the regular RQ 15 car counts"); + } + + #[test] + fn test_plan_trash_rules_counts_unlocked_cars() { + // guid_0 (best, unlocked) → protected, not freed; guid_1 (not best, + // unlocked) → freed. 100 - 95 = 5 free + 1 freed + 1 candidate = 7. + let ctx = AnalysisContext::new(); + let rec = make_record("Car_0", 15, 100.0, "guid_0"); + for i in 0..4 { + ctx.best_cars.insert((format!("Track{i}"), 15, "Dry".to_string()), vec![rec.clone()]); + } + let car_list = vec![make_car("Car_0", 15, "guid_0"), make_car("Car_1", 15, "guid_1")]; + let mut owned_configs = HashMap::new(); + owned_configs.insert("guid_0".to_string(), vec!["111".to_string()]); + let counts = compute_car_counts(&ctx, car_list, owned_configs, HashMap::new()); + + let unlocked: HashMap = + vec![("guid_0".to_string(), 1), ("guid_1".to_string(), 1)].into_iter().collect(); + + let (_thresholds, projection) = + plan_trash_rules(&counts, &collect_improve_entries(&counts), &unlocked, 4, 100, 95, 20); + + assert_eq!(projection.unlocked_protected, 1); + assert_eq!(projection.unlocked_freed, 1); + assert_eq!(projection.projected_free, Some(7)); + } } diff --git a/src/parsing/mod.rs b/src/parsing/mod.rs index ee587fd..8ceecc9 100644 --- a/src/parsing/mod.rs +++ b/src/parsing/mod.rs @@ -11,6 +11,7 @@ use std::sync::Arc; use dashmap::DashMap; use futures::future::join_all; +use indicatif::{ProgressBar, ProgressStyle}; use serde_json::Value; use tokio::sync::Semaphore; use tokio::task; @@ -89,13 +90,20 @@ pub fn parse_car_times( // Read file content let file_content = match fs::read_to_string(&local_file_path) { Ok(content) => content, + // File not found is expected for most cars (no timing data recorded) — silent skip. + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), Err(e) => { - // File not found is expected for some cars — treat as non-fatal - eprintln!("Warning: could not read {local_file_path}: {e}"); + log::warn!("could not read {local_file_path}: {e}"); return Ok(()); } }; + // An empty file means "no timing data" (the API reports this as an empty + // body) — same as a missing file, not a parse error. + if file_content.trim().is_empty() { + return Ok(()); + } + // Parse JSON data let json_data: Value = match serde_json::from_str(&file_content) { Ok(v) => v, @@ -106,7 +114,7 @@ pub fn parse_car_times( // Process each configuration variant (111, 233, 323, etc.) if let Some(configurations) = json_data["data"].as_object() { - for (raw_config, entry) in configurations { + for (raw_config, config_data) in configurations { // Strip version prefix (e.g., "v2c_" from "v2c_233", "v2b_" from "v2b_111") // at read time. Downstream code only ever sees bare digit strings. // Generic approach: find the last '_' and take what follows it, @@ -121,13 +129,20 @@ pub fn parse_car_times( .map(|(_, suffix)| suffix.to_string()) .unwrap_or_else(|| raw_config.clone()); // Process timing data for each track - if let Some(times) = entry["times"].as_object() { + if let Some(times) = config_data["times"].as_object() { for (track_id, details) in times { // Parse track ID to separate base track name from surface condition. // Use rsplit_once to split on the LAST "_a" so track names that // contain "_a" (e.g. "airplaneHangars") are handled correctly. if let Some((cleaned_track_id, track_type)) = track_id.rsplit_once("_a") { - let mut time = details["t"].as_f64().unwrap_or(0.0); + let Some(mut time) = details["t"].as_f64() else { + log::warn!( + "skipping {}/{}: missing or non-numeric time field", + car.rid, + track_id + ); + continue; + }; let down_votes = details["down"].as_array(); let no_down_votes = match down_votes { @@ -302,12 +317,12 @@ pub async fn parse_all_times( match result { // Outer JoinError from tokio::spawn Err(e) => { - eprintln!("Warning: parsing task failed: {e}"); + log::warn!("parsing task failed: {e}"); parse_errors += 1; } // parse_car_times or semaphore returned an error Ok(Err(e)) => { - eprintln!("Warning: {e}"); + log::warn!("{e}"); parse_errors += 1; } // parse_car_times succeeded @@ -315,7 +330,7 @@ pub async fn parse_all_times( } } if parse_errors > 0 { - eprintln!("Warning: {parse_errors} car(s) had parse errors during analysis"); + log::warn!("{parse_errors} car(s) had parse errors during analysis"); } // If every car failed to parse, the data is unusable — propagate the error. @@ -328,6 +343,15 @@ pub async fn parse_all_times( }); } + // Missing timing files are silent (expected for most cars), so a wrong or + // empty --times-dir would otherwise produce empty CSVs without any hint. + if ctx.best_cars.is_empty() && !car_list.is_empty() { + log::warn!( + "no timing data found in {times_dir} for {} car(s) — all output CSVs will be empty", + car_list.len() + ); + } + Ok(car_list) } @@ -340,38 +364,56 @@ const CAR_TIMING_API_BASE: &str = "https://api.topdrivesrecords.com/car/"; /// Fetches timing data files for all cars using controlled concurrency. /// +/// Displays a single-line progress counter (completed/total cars) that stays +/// on the bottom line while downloads run. The counter tracks completions, +/// not cars seen, since downloads run concurrently under the semaphore. +/// /// Returns the first error encountered, or `Ok(())` if all downloads succeeded. pub async fn fetch_times(cars_db_path: &str, times_dir: &str) -> Result<(), AppError> { + let car_list = get_car_list(cars_db_path)?; + + let progress = ProgressBar::new(car_list.len() as u64); + progress.set_style( + ProgressStyle::with_template("{msg} {pos}/{len}") + .expect("static progress template is valid"), + ); + progress.set_message("Downloading track times for car"); + let semaphore = Arc::new(Semaphore::new(CONCURRENCY_LIMIT)); let mut handles = vec![]; - for car in get_car_list(cars_db_path)? { + for car in car_list { let permit = semaphore .clone() .acquire_owned() .await .map_err(|e| AppError::Network(format!("Semaphore closed: {e}")))?; let times_dir = times_dir.to_string(); + let progress = progress.clone(); handles.push(tokio::spawn(async move { let _permit = permit; // held until end of block - fetch_car(&car.rid, ×_dir).await + let result = fetch_car(&car.rid, ×_dir).await; + progress.inc(1); + result })); } let results = join_all(handles).await; + // Clear the counter before error logging so warnings don't garble the line. + progress.finish_and_clear(); let mut first_error: Option = None; for result in results { match result { Ok(Ok(())) => {} Ok(Err(e)) => { - eprintln!("{e}"); + log::warn!("{e}"); if first_error.is_none() { first_error = Some(e); } } Err(e) => { let msg = AppError::Network(format!("Task join error: {e}")); - eprintln!("{msg}"); + log::warn!("{msg}"); if first_error.is_none() { first_error = Some(msg); } @@ -382,12 +424,23 @@ pub async fn fetch_times(cars_db_path: &str, times_dir: &str) -> Result<(), AppE } /// Downloads timing data for a single car from the remote API. +/// +/// An empty response means the records site knows the car but no times have +/// been submitted yet (typical for freshly released prize cars) — treated as +/// a skip, not a failure, so the overall fetch is not aborted. async fn fetch_car(rid: &str, times_dir: &str) -> Result<(), AppError> { let file_name = rid.to_owned() + ".json"; let url = format!("{CAR_TIMING_API_BASE}{rid}"); let local_file_path = format!("{times_dir}/{file_name}"); - println!("Fetching {url}."); - top_drives::get_file(&url, &local_file_path).await + log::info!("Fetching {url}."); + match top_drives::get_file(&url, &local_file_path).await { + Ok(()) => Ok(()), + Err(AppError::EmptyResponse { .. }) => { + log::info!("no records on the site yet for {rid} — skipped"); + Ok(()) + } + Err(e) => Err(e), + } } // --------------------------------------------------------------------------- @@ -494,6 +547,44 @@ mod tests { assert_eq!(records.len(), 1, "Same car_rid should not appear twice in tie list"); } + #[test] + fn test_update_best_car_same_car_different_config_slower_not_added() { + // A slower config of the same car is simply discarded (existing rule). + let ctx = AnalysisContext::new(); + + let mut record_323 = make_record("Car_A", 85, 100.0, "guid_a"); + record_323.normalized_config = "323".to_string(); + + let mut record_332 = make_record("Car_A", 85, 100.4, "guid_a"); + record_332.normalized_config = "332".to_string(); + + update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record_323); + update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record_332); + + let records = ctx.best_cars.get(&("laguna".to_string(), 85, "Dry".to_string())).unwrap(); + assert_eq!(records.len(), 1); + assert_eq!(records[0].normalized_config, "323"); + } + + #[test] + fn test_update_best_car_same_car_different_config_faster_replaces() { + // A strictly faster config of the same car replaces the entry. + let ctx = AnalysisContext::new(); + + let mut record_323 = make_record("Car_A", 85, 100.0, "guid_a"); + record_323.normalized_config = "323".to_string(); + + let mut record_332 = make_record("Car_A", 85, 99.0, "guid_a"); + record_332.normalized_config = "332".to_string(); + + update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record_323); + update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record_332); + + let records = ctx.best_cars.get(&("laguna".to_string(), 85, "Dry".to_string())).unwrap(); + assert_eq!(records.len(), 1); + assert_eq!(records[0].normalized_config, "332", "Faster config replaces the slower one"); + } + // ======================================================================= // parse_car_times tests // ======================================================================= @@ -536,6 +627,55 @@ mod tests { assert!(ctx.best_cars.contains_key(&("Monaco Streets".to_string(), 85, "Wet".to_string()))); } + #[test] + fn test_parse_car_times_skips_missing_or_non_numeric_time() { + let ctx = AnalysisContext::new(); + let car = Car { + rid: "TestCar_2020".to_string(), + brand: "TestBrand".to_string(), + only_name: "TestModel".to_string(), + year: 2020, + rq: 85, + prize: false, + guid: "guid_test".to_string(), + }; + + let dir = tempfile::tempdir().unwrap(); + let times_dir = dir.path().to_str().unwrap(); + let file_path = dir.path().join("TestCar_2020.json"); + + // "t" missing entirely, null, and a string — all must be skipped + // without panicking or inserting a record. + let json_content = r#"{ + "data": { + "111": { + "times": { + "laguna_a00": { "u": "Player1" }, + "mnCity_a01": { "t": null, "u": "Player2" }, + "testBowl_a02": { "t": "112.45", "u": "Player3" }, + "butte_a01": { "t": 50.0, "u": "Player4" } + } + } + } + }"#; + fs::write(&file_path, json_content).unwrap(); + + let my_cars: HashSet = HashSet::new(); + parse_car_times(car, &my_cars, &ctx.best_cars, times_dir).unwrap(); + + // Only the valid butte entry survives. + assert!(!ctx.best_cars.contains_key(&("Laguna Seca".to_string(), 85, "Dry".to_string()))); + assert!(!ctx.best_cars.contains_key(&( + "Monaco Streets".to_string(), + 85, + "Wet".to_string() + ))); + assert!( + ctx.best_cars.contains_key(&("Butte".to_string(), 85, "Wet".to_string())), + "valid time entry should still be parsed" + ); + } + #[test] fn test_parse_car_times_filters_downvoted() { let ctx = AnalysisContext::new(); @@ -630,6 +770,30 @@ mod tests { assert_eq!(ctx.best_cars.len(), 0, "No entries for missing file"); } + #[test] + fn test_parse_car_times_empty_file_treated_as_missing() { + let ctx = AnalysisContext::new(); + let car = Car { + rid: "EmptyCar_2020".to_string(), + brand: "TestBrand".to_string(), + only_name: "TestModel".to_string(), + year: 2020, + rq: 85, + prize: false, + guid: "guid_test".to_string(), + }; + + let dir = tempfile::tempdir().unwrap(); + let times_dir = dir.path().to_str().unwrap(); + fs::write(dir.path().join("EmptyCar_2020.json"), "").unwrap(); + + let my_cars: HashSet = HashSet::new(); + // Empty file must be Ok(()) like a missing file, not a parse error. + parse_car_times(car, &my_cars, &ctx.best_cars, times_dir).unwrap(); + + assert_eq!(ctx.best_cars.len(), 0, "No entries for empty file"); + } + // ======================================================================= // Track name / type tests (from lib) // ======================================================================= diff --git a/src/records.rs b/src/records.rs index 626fa33..14ef553 100644 --- a/src/records.rs +++ b/src/records.rs @@ -42,6 +42,8 @@ use clap::{Parser, Subcommand}; use top_drives::AppError; +use crate::commands::TrashParams; + // --------------------------------------------------------------------------- // Constants (CLI defaults) // --------------------------------------------------------------------------- @@ -104,8 +106,16 @@ enum Commands { #[arg(long, default_value_t = 10)] min_count: usize, }, - /// Generate trash.csv (owned cars not best on any track) - Trash, + /// Generate trash.csv (owned cars not best on any track, plus low-value upgrade fodder) + Trash { + #[command(flatten)] + trash: TrashParams, + }, + /// Generate protect.csv and storage.csv (unlocked cars: keep vs. trash recommendation) + Protect { + #[command(flatten)] + trash: TrashParams, + }, /// Generate wishlist.csv, add.csv, and remove.csv Wishlist { /// Minimum number of tracks a car must be best on to qualify (default: 10) @@ -123,11 +133,20 @@ enum Commands { /// Maximum number of cars on the wishlist (default: 300) #[arg(long, default_value_t = 300)] max_wishlist: usize, + #[command(flatten)] + trash: TrashParams, }, /// Generate details.csv with all timing data Details, /// Show best car for a specific track/condition/RQ combination - Track { track: String, condition: String, rq: u8 }, + Track { + /// Human-readable track name (e.g. "Laguna Seca", not the raw code "laguna") + track: String, + /// Human-readable surface condition (e.g. "Dry", "Wet"; not the raw code "00") + condition: String, + /// RQ level (car's RQ up to 113) + rq: u8, + }, /// Generate improve.csv (owned cars not in final config, sorted by best tracks) Improve, } @@ -150,6 +169,10 @@ enum Commands { /// ``` #[tokio::main] async fn main() { + // Default to warn level so library/bin warnings stay visible without RUST_LOG; + // RUST_LOG can still silence or raise verbosity. + #[cfg(feature = "bin-logging")] + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init(); if let Err(e) = run().await { eprintln!("Error: {e}"); std::process::exit(1); @@ -177,9 +200,25 @@ async fn run() -> Result<(), AppError> { ) .await?; } - Commands::Trash => { - commands::trash::run_trash(&cli.my_cars, &cli.cars_db, &cli.times_dir, &cli.output_dir) - .await?; + Commands::Trash { trash } => { + commands::trash::run_trash( + &cli.my_cars, + &cli.cars_db, + &cli.times_dir, + &cli.output_dir, + &trash, + ) + .await?; + } + Commands::Protect { trash } => { + commands::protect::run_protect( + &cli.my_cars, + &cli.cars_db, + &cli.times_dir, + &cli.output_dir, + &trash, + ) + .await?; } Commands::Wishlist { min_count, max_wishlist } => { commands::wishlist::run_wishlist( @@ -192,7 +231,7 @@ async fn run() -> Result<(), AppError> { ) .await?; } - Commands::All { min_count, max_wishlist } => { + Commands::All { min_count, max_wishlist, trash } => { commands::all::run_all( &cli.my_cars, &cli.cars_db, @@ -200,6 +239,7 @@ async fn run() -> Result<(), AppError> { &cli.output_dir, min_count, max_wishlist, + &trash, ) .await?; } @@ -217,7 +257,6 @@ async fn run() -> Result<(), AppError> { &cli.my_cars, &cli.cars_db, &cli.times_dir, - &cli.output_dir, &track, &condition, rq, diff --git a/src/types.rs b/src/types.rs index b1a57dc..ebad412 100644 --- a/src/types.rs +++ b/src/types.rs @@ -29,6 +29,16 @@ pub enum AppError { reason: String, }, + /// The server responded with 200 but an empty body. + /// + /// The records API uses this to signal "known resource, no data submitted yet" + /// (typically newly released prize cars). Deterministic — retrying cannot help. + #[error("Empty response from {url} (no data available on the server)")] + EmptyResponse { + /// The URL that returned the empty body + url: String, + }, + /// A required data file is missing #[error("Missing data file: {0}")] MissingFile(String), @@ -132,7 +142,7 @@ pub fn normalize_to_final_build(config: &str) -> String { digits[i] = b - b'0'; } - let sum: i32 = digits.iter().map(|&d| d as i32).sum(); + let sum: u8 = digits.iter().sum(); if sum >= 8 { // Already a final build (sum == 8) or maxed (sum > 8) — return as-is. return config.to_string(); @@ -152,8 +162,55 @@ pub fn normalize_to_final_build(config: &str) -> String { /// Returns `true` if the config is a final build (digit sum == 8) or maxed (sum > 8). /// /// Configs with sum < 8 are non-final and still have upgrade steps remaining. -/// Uses [`normalize_to_final_build`] internally: if normalization returns the -/// original string unchanged, the config is already at or beyond a final build. +/// Malformed configs (not exactly 3 ASCII digits) are treated as non-final so +/// they surface in improve.csv instead of being silently classified as finished. +/// +/// Checks the digits directly — no allocation, unlike going through +/// [`normalize_to_final_build`]. pub fn is_final_build(config: &str) -> bool { - normalize_to_final_build(config) == config + let bytes = config.as_bytes(); + if bytes.len() != 3 || !bytes.iter().all(|&b| b.is_ascii_digit()) { + return false; + } + bytes.iter().map(|&b| b - b'0').sum::() >= 8 +} + +#[cfg(test)] +mod tests { + use super::is_final_build; + + #[test] + fn final_builds_have_digit_sum_eight() { + assert!(is_final_build("323")); + assert!(is_final_build("332")); + assert!(is_final_build("233")); + assert!(is_final_build("521")); + assert!(is_final_build("800")); + } + + #[test] + fn maxed_builds_beyond_eight_are_final() { + assert!(is_final_build("879")); + assert!(is_final_build("999")); + assert!(is_final_build("990")); + } + + #[test] + fn unfinished_builds_below_eight_are_not_final() { + assert!(!is_final_build("111")); + assert!(!is_final_build("221")); + assert!(!is_final_build("610")); + assert!(!is_final_build("070")); + } + + #[test] + fn malformed_configs_are_not_final() { + assert!(!is_final_build("")); + assert!(!is_final_build("12")); + assert!(!is_final_build("1234")); + assert!(!is_final_build("abc")); + assert!(!is_final_build("3a3")); + assert!(!is_final_build(" 33")); + assert!(!is_final_build("33 ")); + } } diff --git a/src/user_data.rs b/src/user_data.rs index f3037db..512207f 100644 --- a/src/user_data.rs +++ b/src/user_data.rs @@ -30,6 +30,19 @@ pub struct MyCarsData { /// Multiple copies of the same car model share the same guid. /// `owned` HashSet deduplicates, so this counts duplicates. pub copy_count: HashMap, + /// GUIDs of deck cards that are NOT locked (guid → number of unlocked copies). + /// Union of garage and storage cards; used for the garage-space projection. + pub unlocked: HashMap, + /// Unlocked cards in the garage (`state != 0`, `locked == false`; guid → + /// copy count). Candidates for protect.csv. + pub garage_unlocked: HashMap, + /// Cards in the storage (`state == 0`, any lock state; guid → copy count). + /// Candidates for storage.csv recommendations. + pub storage: HashMap, + /// Total garage slot capacity (from `user.garageSlots`), 0 if absent. + pub garage_slots: usize, + /// Occupied garage slots (from `user.garageSlotsUsed`), 0 if absent. + pub garage_slots_used: usize, } /// Loads and parses `my_cars.json` once, extracting both owned and wishlist data. @@ -62,30 +75,55 @@ pub fn load_my_cars_data(path: &str) -> Result { let mut owned: HashSet = HashSet::new(); let mut owned_configs: HashMap> = HashMap::new(); let mut copy_count: HashMap = HashMap::new(); + let mut unlocked: HashMap = HashMap::new(); + let mut garage_unlocked: HashMap = HashMap::new(); + let mut storage: HashMap = HashMap::new(); if let Some(deck) = json.get("playerDeck").and_then(|d| d.as_array()) { for card in deck { - if card.get("locked").and_then(|v| v.as_bool()).unwrap_or(false) { - if let Some(guid) = card.get("cardId").and_then(|v| v.as_str()) { - owned.insert(guid.to_string()); - *copy_count.entry(guid.to_string()).or_insert(0) += 1; - // Extract actual tune config: engineMajor + weightMajor + chassisMajor. - // Skip cards with incomplete tune data to avoid false "000" entries - // that would incorrectly appear as needing upgrade. - let engine = card.get("engineMajor").and_then(|v| v.as_u64()); - let weight = card.get("weightMajor").and_then(|v| v.as_u64()); - let chassis = card.get("chassisMajor").and_then(|v| v.as_u64()); - if let (Some(e), Some(w), Some(c)) = (engine, weight, chassis) { - let config = format!("{e}{w}{c}"); - owned_configs.entry(guid.to_string()).or_default().push(config); - } else { - eprintln!("Warning: locked card {guid} has incomplete tune data — skipped"); - } + let Some(guid) = card.get("cardId").and_then(|v| v.as_str()) else { + continue; + }; + let guid = guid.to_string(); + let locked = card.get("locked").and_then(|v| v.as_bool()).unwrap_or(false); + // state 0 = storage ("Lager"), anything else = garage. + let in_storage = card.get("state").and_then(|v| v.as_i64()) == Some(0); + if locked { + owned.insert(guid.clone()); + *copy_count.entry(guid.clone()).or_insert(0) += 1; + // Extract actual tune config: engineMajor + weightMajor + chassisMajor. + // Skip cards with incomplete tune data to avoid false "000" entries + // that would incorrectly appear as needing upgrade. + let engine = card.get("engineMajor").and_then(|v| v.as_u64()); + let weight = card.get("weightMajor").and_then(|v| v.as_u64()); + let chassis = card.get("chassisMajor").and_then(|v| v.as_u64()); + if let (Some(e), Some(w), Some(c)) = (engine, weight, chassis) { + let config = format!("{e}{w}{c}"); + owned_configs.entry(guid.clone()).or_default().push(config); + } else { + log::warn!("locked card {guid} has incomplete tune data — skipped"); } + } else { + *unlocked.entry(guid.clone()).or_insert(0) += 1; + if !in_storage { + *garage_unlocked.entry(guid.clone()).or_insert(0) += 1; + } + } + if in_storage { + *storage.entry(guid).or_insert(0) += 1; } } } + let garage_slots = + json.get("user").and_then(|u| u.get("garageSlots")).and_then(|v| v.as_u64()).unwrap_or(0) + as usize; + let garage_slots_used = json + .get("user") + .and_then(|u| u.get("garageSlotsUsed")) + .and_then(|v| v.as_u64()) + .unwrap_or(0) as usize; + // Extract wishlist GUIDs from wishlists[0].cards let wishlist: HashSet = json .get("wishlists") @@ -95,7 +133,17 @@ pub fn load_my_cars_data(path: &str) -> Result { .map(|cards| cards.iter().filter_map(|c| c.as_str().map(|s| s.to_string())).collect()) .unwrap_or_default(); - Ok(MyCarsData { owned, wishlist, owned_configs, copy_count }) + Ok(MyCarsData { + owned, + wishlist, + owned_configs, + copy_count, + unlocked, + garage_unlocked, + storage, + garage_slots, + garage_slots_used, + }) } #[cfg(test)] @@ -111,4 +159,54 @@ mod tests { other => panic!("Expected MissingFile error, got: {other}"), } } + + #[test] + fn test_load_my_cars_data_parses_deck_and_garage() { + let json = r#"{ + "user": { "garageSlots": 100, "garageSlotsUsed": 90 }, + "playerDeck": [ + { "cardId": "g1", "state": 1, "locked": true, "engineMajor": 3, "weightMajor": 2, "chassisMajor": 3 }, + { "cardId": "g2", "state": 0, "locked": false }, + { "cardId": "g2", "state": 0, "locked": false }, + { "cardId": "g3", "state": 1, "locked": false }, + { "cardId": "g4", "state": 0, "locked": true } + ], + "wishlists": [ { "cards": ["g9"] } ] + }"#; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("my_cars.json"); + std::fs::write(&path, json).unwrap(); + + let data = load_my_cars_data(path.to_str().unwrap()).unwrap(); + + assert!(data.owned.contains("g1")); + assert!(data.owned.contains("g4"), "locked storage cards still count as owned"); + assert_eq!( + data.owned_configs.get("g1").map(|c| c.as_slice()), + Some(&["323".to_string()][..]) + ); + assert!(!data.owned.contains("g2"), "unlocked cards must not count as owned"); + assert_eq!(data.unlocked.get("g2"), Some(&2), "two unlocked copies"); + assert_eq!(data.unlocked.get("g3"), Some(&1)); + assert_eq!(data.garage_unlocked.get("g3"), Some(&1), "unlocked garage card"); + assert!(!data.garage_unlocked.contains_key("g2"), "storage card is not garage"); + assert_eq!(data.storage.get("g2"), Some(&2), "two storage copies"); + assert_eq!(data.storage.get("g4"), Some(&1), "locked storage card"); + assert!(data.wishlist.contains("g9")); + assert_eq!(data.garage_slots, 100); + assert_eq!(data.garage_slots_used, 90); + } + + #[test] + fn test_load_my_cars_data_without_garage_info() { + let json = r#"{ "playerDeck": [] }"#; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("my_cars.json"); + std::fs::write(&path, json).unwrap(); + + let data = load_my_cars_data(path.to_str().unwrap()).unwrap(); + assert_eq!(data.garage_slots, 0); + assert_eq!(data.garage_slots_used, 0); + assert!(data.unlocked.is_empty()); + } } diff --git a/tests/http_tests.rs b/tests/http_tests.rs index 2ac3eef..34c6b9f 100644 --- a/tests/http_tests.rs +++ b/tests/http_tests.rs @@ -31,22 +31,23 @@ async fn test_get_file_success() { } #[tokio::test] -async fn test_get_file_empty_response_retries_and_fails() { +async fn test_get_file_empty_response_fails_immediately_without_retry() { let mut server = Server::new_async().await; - // Return 200 with empty body — triggers retry, then fails - let m = server - .mock("GET", "/data.json") - .with_status(200) - .with_body("") - .expect(4) // 1 initial + 3 retries - .create(); + // 200 with empty body means "no data available yet" — permanent, so exactly + // one request must be made (no retries) and a distinguishable error returned. + let m = server.mock("GET", "/data.json").with_status(200).with_body("").expect(1).create(); let dir = tempfile::tempdir().unwrap(); let out_path = dir.path().join("output.txt"); let url = format!("{}/data.json", server.url()); let result = get_file(&url, out_path.to_str().unwrap()).await; - assert!(result.is_err(), "Empty response should fail after retries"); + let err = result.expect_err("Empty response should fail immediately"); + assert!( + matches!(err, top_drives::AppError::EmptyResponse { .. }), + "Empty response should yield AppError::EmptyResponse, got: {err}" + ); + assert!(!out_path.exists(), "No file should be written for an empty response"); m.assert(); } diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 6bc80a5..18514e6 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -240,6 +240,7 @@ fn test_load_my_cars_data_with_fixture() { let json = r#"{ "playerDeck": [ { "cardId": "guid_a", "locked": true, "engineMajor": 3, "weightMajor": 2, "chassisMajor": 3 }, + { "cardId": "guid_a", "locked": true, "engineMajor": 2, "weightMajor": 3, "chassisMajor": 3 }, { "cardId": "guid_b", "locked": false, "engineMajor": 2, "weightMajor": 3, "chassisMajor": 3 }, { "cardId": "guid_c", "locked": true, "engineMajor": 1, "weightMajor": 1, "chassisMajor": 1 } ], @@ -251,15 +252,19 @@ fn test_load_my_cars_data_with_fixture() { let data = load_my_cars_data(path.to_str().unwrap()).unwrap(); - // Only locked cards are owned + // Only locked cards are owned (set deduplicates duplicate guids) assert_eq!(data.owned.len(), 2); assert!(data.owned.contains("guid_a")); assert!(data.owned.contains("guid_c")); assert!(!data.owned.contains("guid_b")); - // owned_configs only for locked cards with complete tune data - assert_eq!(data.owned_configs.len(), 2); - assert_eq!(data.owned_configs.get("guid_a"), Some(&vec!["323".to_string()])); + // copy_count tracks duplicates: guid_a has two copies, guid_c one + assert_eq!(data.copy_count.get("guid_a"), Some(&2)); + assert_eq!(data.copy_count.get("guid_c"), Some(&1)); + assert!(!data.copy_count.contains_key("guid_b")); + + // owned_configs collects one entry per copy with complete tune data + assert_eq!(data.owned_configs.get("guid_a"), Some(&vec!["323".to_string(), "233".to_string()])); assert_eq!(data.owned_configs.get("guid_c"), Some(&vec!["111".to_string()])); // Unlocked card should not be in owned_configs assert!(!data.owned_configs.contains_key("guid_b")); @@ -289,6 +294,11 @@ fn test_load_my_cars_data_incomplete_tune_data() { // All locked cards are owned assert_eq!(data.owned.len(), 3); + // copy_count counts every locked card, even without complete tune data + assert_eq!(data.copy_count.get("guid_full"), Some(&1)); + assert_eq!(data.copy_count.get("guid_partial"), Some(&1)); + assert_eq!(data.copy_count.get("guid_empty"), Some(&1)); + // Only the card with complete tune data has a config entry assert_eq!(data.owned_configs.len(), 1); assert_eq!(data.owned_configs.get("guid_full"), Some(&vec!["233".to_string()]));