diff --git a/.gitignore b/.gitignore index 133e4dd..929aee3 100644 --- a/.gitignore +++ b/.gitignore @@ -5,8 +5,5 @@ cars_repo.json tracks_repo.json times/ *.csv -ca/ -captures/ -capture.toml AGENTS.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cfcfe3..03aff7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,11 +32,6 @@ 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 a456d84..6574a9e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,6 @@ # 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" @@ -17,21 +11,6 @@ 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" @@ -88,45 +67,6 @@ 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" @@ -137,29 +77,6 @@ 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" @@ -172,29 +89,6 @@ 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" @@ -207,7 +101,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec 0.8.0", + "bit-vec", ] [[package]] @@ -216,77 +110,12 @@ 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" @@ -306,8 +135,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -317,17 +144,6 @@ 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" @@ -368,15 +184,6 @@ 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" @@ -392,45 +199,6 @@ 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" @@ -457,67 +225,12 @@ 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" @@ -532,84 +245,6 @@ 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" @@ -621,18 +256,6 @@ 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" @@ -642,29 +265,6 @@ 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" @@ -681,26 +281,6 @@ 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" @@ -713,16 +293,6 @@ 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" @@ -759,12 +329,6 @@ 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" @@ -853,31 +417,6 @@ 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" @@ -910,7 +449,6 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", - "rand_core 0.10.1", "wasip2", "wasip3", ] @@ -1006,43 +544,6 @@ 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" @@ -1074,12 +575,10 @@ dependencies = [ "http", "hyper", "hyper-util", - "log", "rustls", "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.9", ] [[package]] @@ -1098,21 +597,6 @@ 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" @@ -1259,19 +743,6 @@ 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" @@ -1290,52 +761,6 @@ 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" @@ -1348,12 +773,6 @@ 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" @@ -1393,41 +812,6 @@ 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" @@ -1440,22 +824,6 @@ 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" @@ -1484,7 +852,7 @@ dependencies = [ "hyper-util", "log", "pin-project-lite", - "rand 0.9.4", + "rand", "regex", "serde_json", "serde_urlencoded", @@ -1492,26 +860,6 @@ 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" @@ -1529,50 +877,6 @@ 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" @@ -1582,21 +886,6 @@ 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" @@ -1615,7 +904,7 @@ version = "0.10.79" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" dependencies = [ - "bitflags 2.11.1", + "bitflags", "cfg-if", "foreign-types", "libc", @@ -1652,12 +941,6 @@ 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" @@ -1681,16 +964,6 @@ 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" @@ -1703,33 +976,12 @@ 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" @@ -1739,12 +991,6 @@ 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" @@ -1780,10 +1026,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set", - "bit-vec 0.8.0", - "bitflags 2.11.1", + "bit-vec", + "bitflags", "num-traits", - "rand 0.9.4", + "rand", "rand_chacha", "rand_xorshift", "regex-syntax", @@ -1826,18 +1072,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha", - "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", + "rand_core", ] [[package]] @@ -1847,7 +1082,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.5", + "rand_core", ] [[package]] @@ -1859,33 +1094,13 @@ 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 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", + "rand_core", ] [[package]] @@ -1894,7 +1109,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", + "bitflags", ] [[package]] @@ -1980,22 +1195,13 @@ 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 2.11.1", + "bitflags", "errno", "libc", "linux-raw-sys", @@ -2008,8 +1214,6 @@ 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", @@ -2032,7 +1236,6 @@ 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", @@ -2071,12 +1274,6 @@ 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" @@ -2089,7 +1286,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.1", + "bitflags", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -2155,15 +1352,6 @@ 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" @@ -2176,48 +1364,12 @@ 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" @@ -2301,7 +1453,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.11.1", + "bitflags", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -2316,12 +1468,6 @@ 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" @@ -2355,45 +1501,6 @@ 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" @@ -2415,25 +1522,11 @@ 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" @@ -2465,22 +1558,6 @@ 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" @@ -2494,62 +1571,13 @@ 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", @@ -2557,9 +1585,7 @@ dependencies = [ "serde_json", "tempfile", "thiserror", - "time", "tokio", - "toml", "top_drives", ] @@ -2584,7 +1610,7 @@ version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28f0d049ccfaa566e14e9663d304d8577427b368cb4710a20528690287a738b" dependencies = [ - "bitflags 2.11.1", + "bitflags", "bytes", "futures-util", "http", @@ -2614,23 +1640,10 @@ 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" @@ -2638,36 +1651,6 @@ 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]] @@ -2676,30 +1659,6 @@ 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" @@ -2712,12 +1671,6 @@ 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" @@ -2754,35 +1707,12 @@ 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" @@ -2908,7 +1838,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.1", + "bitflags", "hashbrown 0.15.5", "indexmap", "semver", @@ -2924,34 +1854,6 @@ 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" @@ -2996,15 +1898,6 @@ 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" @@ -3078,15 +1971,6 @@ 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" @@ -3151,7 +2035,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.1", + "bitflags", "indexmap", "log", "serde", @@ -3187,34 +2071,6 @@ 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" @@ -3323,31 +2179,3 @@ 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 264c0f7..2b8f724 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,16 +7,9 @@ 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" @@ -27,26 +20,13 @@ 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 = ["bin-logging", "test-utils"] -bin-logging = ["dep:env_logger"] +default = [] test-utils = [] [dev-dependencies] -top_drives = { path = ".", features = ["test-utils"], default-features = false } +top_drives = { path = ".", features = ["test-utils"] } tempfile = "3.10.1" proptest = "1" mockito = "1" diff --git a/README.md b/README.md index 0496117..301e13c 100644 --- a/README.md +++ b/README.md @@ -112,48 +112,18 @@ 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, 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. +Generates `trash.csv` — owned cars that are not best on any track, have RQ < 80, and are not prize cars. Sorted by RQ. **Output File:** ``` -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 +trash.csv — Owned cars safe to remove ``` #### 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. Prize cars are excluded from the ideal wishlist; prize cars still on your actual wishlist appear in `remove.csv`. +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. **Output Files:** ``` @@ -176,7 +146,7 @@ cargo run --bin records -- wishlist --max-wishlist 500 ```bash cargo run --bin records -- all ``` -Generates all 10 CSV files in a single parse pass. Equivalent to running `best`, `trash`, `wishlist`, `improve`, and `protect` together, but more efficient. +Generates all 7 CSV files in a single parse pass. Equivalent to running `best`, `trash`, `wishlist`, and `improve` together, but more efficient. **Output Files:** ``` @@ -187,13 +157,11 @@ 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 `--min-count N` (default 10), `--max-wishlist M` (default 300), and the trash criteria flags: +Supports both `--min-count N` (default 10) and `--max-wishlist M` (default 300): ```bash -cargo run --bin records -- all --min-count 5 --max-wishlist 500 --trash-base-count 4 --garage-min-free 20 +cargo run --bin records -- all --min-count 5 --max-wishlist 500 ``` #### Detailed Performance Data @@ -234,10 +202,9 @@ 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) | `[--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]` | +| `trash` | Generate `trash.csv` (owned cars safe to remove) | None | | `wishlist` | Generate `wishlist.csv`, `add.csv`, `remove.csv` | `[--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]` | +| `all` | Generate all 7 CSV files in a single pass | `[--min-count N] [--max-wishlist M]` | | `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 | @@ -248,8 +215,6 @@ 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 deleted file mode 100644 index 2bb2955..0000000 --- a/capture.toml.example +++ /dev/null @@ -1,31 +0,0 @@ -# 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 deleted file mode 100644 index 6c032ef..0000000 --- a/run_capture.cmd +++ /dev/null @@ -1,23 +0,0 @@ -@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 deleted file mode 100644 index 85bc8d5..0000000 --- a/src/capture/main.rs +++ /dev/null @@ -1,13 +0,0 @@ -#[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 deleted file mode 100644 index 78ae50e..0000000 --- a/src/capture/windows.rs +++ /dev/null @@ -1,902 +0,0 @@ -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 3658a8d..5957cff 100644 --- a/src/commands/all.rs +++ b/src/commands/all.rs @@ -1,14 +1,11 @@ //! 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, improve.csv, -//! protect.csv, and storage.csv. +//! trash.csv, details.csv, wishlist.csv, add.csv, remove.csv, and improve.csv. use top_drives::AppError; -use crate::commands::{ - load_my_cars_or_warn, run_analysis, write_trash_with_rules, GarageInfo, TrashParams, -}; +use crate::commands::{load_my_cars_or_warn, run_analysis}; use crate::output; /// Executes the all command: generates all output files in a single analysis pass. @@ -19,27 +16,24 @@ 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 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?; + 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)?; - 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_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, + )?; 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, protect.csv, storage.csv" + "Written: best.csv, very_best.csv, trash.csv, details.csv, wishlist.csv, add.csv, remove.csv, improve.csv" ); Ok(()) } diff --git a/src/commands/best.rs b/src/commands/best.rs index c96ccc1..1aa1d19 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 1b1b47a..90ccc66 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, &counts)?; + let (ctx, _counts) = run_analysis(&my_data, cars_db_path, times_dir).await?; + write_details_csv(output_dir, &ctx)?; println!("Written: details.csv"); Ok(()) } diff --git a/src/commands/improve.rs b/src/commands/improve.rs index 1da9803..c533f7e 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 fd6f635..85f38a5 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -8,60 +8,16 @@ 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; // --------------------------------------------------------------------------- @@ -77,8 +33,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)) => { - log::warn!("{msg}"); - log::warn!("Analysis will run without ownership data."); + eprintln!("Warning: {msg}"); + eprintln!(" Analysis will run without ownership data."); Ok(MyCarsData::default()) } Err(e) => Err(e), @@ -89,119 +45,23 @@ pub fn load_my_cars_or_warn(path: &str) -> Result { // Analysis orchestration // --------------------------------------------------------------------------- -/// 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. +/// Common analysis setup: loads user data, parses all timing files, and computes car counts. /// /// Returns the analysis context (for stdout commands) and the aggregated car counts -/// (for CSV output commands). +/// (for CSV output commands). The car list is stored inside `CarCounts`. 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 MyCarsData { owned, owned_configs, copy_count, .. } = my_data; - let owned = Arc::new(owned); + let owned = Arc::new(my_data.owned.clone()); let car_list = parse_all_times(owned, &ctx, cars_db_path, times_dir).await?; - let counts = compute_car_counts(&ctx, car_list, owned_configs, copy_count); + let counts = compute_car_counts( + &ctx, + car_list, + my_data.owned_configs.clone(), + my_data.copy_count.clone(), + ); 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 deleted file mode 100644 index b42dec4..0000000 --- a/src/commands/protect.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! 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 e4fc372..9b77189 100644 --- a/src/commands/track.rs +++ b/src/commands/track.rs @@ -9,16 +9,19 @@ use crate::output::print_best_car; /// Executes the track command: shows best car for a specific track/condition/RQ. /// -/// Prints to stdout rather than writing a CSV file, so no output directory is needed. +/// 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. 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 812ee41..7fcfc6e 100644 --- a/src/commands/trash.rs +++ b/src/commands/trash.rs @@ -1,16 +1,13 @@ //! Trash command implementation. //! //! Generates trash.csv — owned cars that are not best on any track in their -//! 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. +//! 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. use top_drives::AppError; -use crate::commands::{ - load_my_cars_or_warn, run_analysis, write_trash_with_rules, GarageInfo, TrashParams, -}; +use crate::commands::{load_my_cars_or_warn, run_analysis}; +use crate::output::write_trash_csv; /// Executes the trash command: generates trash.csv. pub async fn run_trash( @@ -18,14 +15,10 @@ 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 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)?; + let (_ctx, counts) = run_analysis(&my_data, cars_db_path, times_dir).await?; + write_trash_csv(output_dir, &counts, &my_data.owned)?; println!("Written: trash.csv"); Ok(()) } diff --git a/src/commands/wishlist.rs b/src/commands/wishlist.rs index ce72dc8..e02801e 100644 --- a/src/commands/wishlist.rs +++ b/src/commands/wishlist.rs @@ -17,10 +17,15 @@ pub async fn run_wishlist( max_wishlist: usize, ) -> Result<(), AppError> { let my_data = load_my_cars_or_warn(my_cars_path)?; - 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)?; + 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, + )?; println!("Written: wishlist.csv, add.csv, remove.csv"); Ok(()) } diff --git a/src/context.rs b/src/context.rs index fc06be3..a8064a1 100644 --- a/src/context.rs +++ b/src/context.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use dashmap::DashMap; -use crate::types::{is_final_build, BestCarRecord, Car, TripletKey}; +use crate::types::{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,55 +62,6 @@ 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. @@ -118,30 +69,18 @@ pub fn resolve_tied_configs<'a>( /// `HashMap` inserts. /// /// Groups by `(car_rid, normalized_config)` so that the same car with different -/// final builds is counted as separate entries. Same-car ties on a track are -/// attributed to a single config (see [`resolve_tied_configs`]). +/// final builds is counted as separate entries. 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 resolve_tied_configs(entry.value(), &owned_final) { + for best_car in entry.value() { 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; @@ -155,10 +94,10 @@ pub fn compute_car_counts( } // Build guid → Car lookup once, reused by trash/wishlist output functions. - // 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(); + let car_map: HashMap = + car_list.iter().map(|c| (c.guid.clone(), c.clone())).collect(); - CarCounts { by_rid, guid_to_rid, car_map, owned_configs, copy_count } + CarCounts { by_rid, guid_to_rid, car_map, car_list, owned_configs, copy_count } } #[cfg(test)] @@ -170,128 +109,6 @@ 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(); @@ -341,7 +158,7 @@ mod tests { // car_map should have the car assert!(counts.car_map.contains_key("guid_test")); - assert_eq!(counts.car_map.len(), 1); + assert_eq!(counts.car_list.len(), 1); } #[test] diff --git a/src/http.rs b/src/http.rs index aca3a3b..f69253f 100644 --- a/src/http.rs +++ b/src/http.rs @@ -9,11 +9,6 @@ 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. @@ -21,13 +16,10 @@ fn retry_delay(attempt: usize) -> std::time::Duration { /// 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 -/// 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). +/// Retries up to `MAX_FETCH_RETRIES` times on transient failures (server errors, +/// connection errors, or empty responses). Permanent HTTP errors (4xx) fail immediately. pub async fn get_file(file_url: &str, local_file_path: &str) -> Result<(), AppError> { - /// Maximum number of retries on transient failures (5xx, connection errors). + /// Maximum number of retries when the server returns an empty response. const MAX_FETCH_RETRIES: usize = 3; // Parse the URL once so malformed URLs are caught before any network I/O. @@ -47,12 +39,15 @@ 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 { - log::warn!( - "HTTP {} from {file_url} (attempt {}/{MAX_FETCH_RETRIES}), retrying...", + eprintln!( + "Warning: HTTP {} from {file_url} (attempt {}/{MAX_FETCH_RETRIES}), retrying...", status, attempt + 1, ); - tokio::time::sleep(retry_delay(attempt)).await; + tokio::time::sleep(std::time::Duration::from_millis( + 500 * (attempt + 1) as u64, + )) + .await; attempt += 1; continue; } @@ -61,9 +56,21 @@ pub async fn get_file(file_url: &str, local_file_path: &str) -> Result<(), AppEr match response.text().await { Ok(content) => { if content.is_empty() { - // "Known resource, no data yet" — deterministic, so - // fail immediately instead of burning retries. - return Err(AppError::EmptyResponse { url: file_url.to_string() }); + 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" + ))); } fs::write(&tmp_path, &content).map_err(AppError::Io)?; return fs::rename(&tmp_path, local_file_path).map_err(AppError::Io); @@ -77,11 +84,14 @@ pub async fn get_file(file_url: &str, local_file_path: &str) -> Result<(), AppEr } Err(error) => { if attempt < MAX_FETCH_RETRIES { - log::warn!( - "failed to fetch {file_url} (attempt {}/{MAX_FETCH_RETRIES}): {error}", + eprintln!( + "Warning: failed to fetch {file_url} (attempt {}/{MAX_FETCH_RETRIES}): {error}", attempt + 1, ); - tokio::time::sleep(retry_delay(attempt)).await; + tokio::time::sleep(std::time::Duration::from_millis( + 500 * (attempt + 1) as u64, + )) + .await; attempt += 1; continue; } diff --git a/src/lib.rs b/src/lib.rs index 052e245..165f6ec 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, resolve_tied_configs, AnalysisContext, CarCounts}; +pub use context::{compute_car_counts, 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 de045bb..d03c81b 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::{Path, PathBuf}; +use std::path::PathBuf; use top_drives::{ - compare_cars, escape_csv_field, is_final_build, resolve_tied_configs, AnalysisContext, - AppError, BestCarRecord, Car, CarCounts, TripletKey, + compare_cars, escape_csv_field, is_final_build, AnalysisContext, AppError, BestCarRecord, Car, + CarCounts, TripletKey, }; // --------------------------------------------------------------------------- @@ -25,25 +25,16 @@ 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 { - 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), - ) + 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)) } // --------------------------------------------------------------------------- @@ -55,7 +46,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: &Path, + path: &PathBuf, write_fn: impl FnOnce(&mut fs::File) -> Result<(), AppError>, ) -> Result<(), AppError> { let tmp_path = PathBuf::from(format!("{}.tmp", path.display())); @@ -165,216 +156,25 @@ 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 **any** of: +/// A car is trash if **either**: /// 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, **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. +/// best on any track — even if other configs of the same car model are best. /// -/// 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`]. +/// 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. /// /// 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> { @@ -382,57 +182,30 @@ 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; } - // 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; - } + let config = + counts.owned_configs.get(guid).map(|configs| configs.join("/")).unwrap_or_default(); Some(TrashEntry { car, config }) }) .collect(); @@ -457,182 +230,6 @@ 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 // --------------------------------------------------------------------------- @@ -641,48 +238,17 @@ pub fn write_storage_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. 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. +/// for consistent output ordering. /// /// # 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, - 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(); - +pub fn write_details_csv(output_dir: &str, ctx: &AnalysisContext) -> Result<(), AppError> { // Collect data from DashMap — no global lock needed - 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(); + let mut sorted_data: Vec<(TripletKey, Vec)> = + ctx.best_cars.iter().map(|entry| (entry.key().clone(), entry.value().clone())).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))); @@ -720,19 +286,13 @@ pub fn write_details_csv( /// /// 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; on equal -/// counts the first record seen in `by_rid` iteration order wins (deterministic -/// but arbitrary, since `by_rid` is a `HashMap`). +/// the one with the highest count is kept as the representative entry. /// /// **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, @@ -758,9 +318,6 @@ pub fn compute_ideal_wishlist( .collect(); for (record, count) in counts.by_rid.values() { - if record.prize { - continue; - } if *count < min_count { continue; } @@ -861,15 +418,23 @@ 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((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, - }); + 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, + }); + } + } } // Fallback: use car_map for cars not in best data (not best on any track) @@ -889,7 +454,9 @@ pub fn write_wishlist_files( }) .collect(); if dropped_count > 0 { - log::warn!("{dropped_count} wishlist car(s) not found in cars database and omitted from remove.csv"); + eprintln!( + "Warning: {dropped_count} wishlist car(s) not found in cars database and omitted from remove.csv" + ); } write_sorted_csv( @@ -898,10 +465,10 @@ pub fn write_wishlist_files( "RQ,Brand,Model,Year,FinalBuild,Count", &to_remove, |a, b| { - 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), - ) + 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)) }, |entry, file| { writeln!( @@ -924,84 +491,6 @@ 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). /// @@ -1017,10 +506,73 @@ pub fn collect_improve_entries(counts: &CarCounts) -> Vec> { /// 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 { - let entries = collect_improve_entries(counts); + /// 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 count = entries.len(); // Sort by count descending (most tracks first), then by RQ/target/brand/model @@ -1033,17 +585,19 @@ pub fn write_improve_csv(output_dir: &str, counts: &CarCounts) -> Result = content.lines().collect(); @@ -1386,7 +940,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let output_dir = dir.path().to_str().unwrap(); - write_trash_csv(output_dir, &counts, &owned, None, &[]).unwrap(); + write_trash_csv(output_dir, &counts, &owned).unwrap(); let content = fs::read_to_string(dir.path().join("trash.csv")).unwrap(); let lines: Vec<&str> = content.lines().collect(); @@ -1410,43 +964,6 @@ 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 // ======================================================================= @@ -1501,6 +1018,7 @@ 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] @@ -2022,580 +1540,4 @@ 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 8ceecc9..ee587fd 100644 --- a/src/parsing/mod.rs +++ b/src/parsing/mod.rs @@ -11,7 +11,6 @@ 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; @@ -90,20 +89,13 @@ 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) => { - log::warn!("could not read {local_file_path}: {e}"); + // File not found is expected for some cars — treat as non-fatal + eprintln!("Warning: 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, @@ -114,7 +106,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, config_data) in configurations { + for (raw_config, entry) 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, @@ -129,20 +121,13 @@ 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) = config_data["times"].as_object() { + if let Some(times) = entry["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 Some(mut time) = details["t"].as_f64() else { - log::warn!( - "skipping {}/{}: missing or non-numeric time field", - car.rid, - track_id - ); - continue; - }; + let mut time = details["t"].as_f64().unwrap_or(0.0); let down_votes = details["down"].as_array(); let no_down_votes = match down_votes { @@ -317,12 +302,12 @@ pub async fn parse_all_times( match result { // Outer JoinError from tokio::spawn Err(e) => { - log::warn!("parsing task failed: {e}"); + eprintln!("Warning: parsing task failed: {e}"); parse_errors += 1; } // parse_car_times or semaphore returned an error Ok(Err(e)) => { - log::warn!("{e}"); + eprintln!("Warning: {e}"); parse_errors += 1; } // parse_car_times succeeded @@ -330,7 +315,7 @@ pub async fn parse_all_times( } } if parse_errors > 0 { - log::warn!("{parse_errors} car(s) had parse errors during analysis"); + eprintln!("Warning: {parse_errors} car(s) had parse errors during analysis"); } // If every car failed to parse, the data is unusable — propagate the error. @@ -343,15 +328,6 @@ 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) } @@ -364,56 +340,38 @@ 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 car_list { + for car in get_car_list(cars_db_path)? { 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 - let result = fetch_car(&car.rid, ×_dir).await; - progress.inc(1); - result + fetch_car(&car.rid, ×_dir).await })); } 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)) => { - log::warn!("{e}"); + eprintln!("{e}"); if first_error.is_none() { first_error = Some(e); } } Err(e) => { let msg = AppError::Network(format!("Task join error: {e}")); - log::warn!("{msg}"); + eprintln!("{msg}"); if first_error.is_none() { first_error = Some(msg); } @@ -424,23 +382,12 @@ 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}"); - 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), - } + println!("Fetching {url}."); + top_drives::get_file(&url, &local_file_path).await } // --------------------------------------------------------------------------- @@ -547,44 +494,6 @@ 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 // ======================================================================= @@ -627,55 +536,6 @@ 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(); @@ -770,30 +630,6 @@ 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 14ef553..626fa33 100644 --- a/src/records.rs +++ b/src/records.rs @@ -42,8 +42,6 @@ use clap::{Parser, Subcommand}; use top_drives::AppError; -use crate::commands::TrashParams; - // --------------------------------------------------------------------------- // Constants (CLI defaults) // --------------------------------------------------------------------------- @@ -106,16 +104,8 @@ enum Commands { #[arg(long, default_value_t = 10)] min_count: usize, }, - /// 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 trash.csv (owned cars not best on any track) + Trash, /// Generate wishlist.csv, add.csv, and remove.csv Wishlist { /// Minimum number of tracks a car must be best on to qualify (default: 10) @@ -133,20 +123,11 @@ 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 { - /// 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, - }, + Track { track: String, condition: String, rq: u8 }, /// Generate improve.csv (owned cars not in final config, sorted by best tracks) Improve, } @@ -169,10 +150,6 @@ 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); @@ -200,25 +177,9 @@ async fn run() -> Result<(), AppError> { ) .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::Trash => { + commands::trash::run_trash(&cli.my_cars, &cli.cars_db, &cli.times_dir, &cli.output_dir) + .await?; } Commands::Wishlist { min_count, max_wishlist } => { commands::wishlist::run_wishlist( @@ -231,7 +192,7 @@ async fn run() -> Result<(), AppError> { ) .await?; } - Commands::All { min_count, max_wishlist, trash } => { + Commands::All { min_count, max_wishlist } => { commands::all::run_all( &cli.my_cars, &cli.cars_db, @@ -239,7 +200,6 @@ async fn run() -> Result<(), AppError> { &cli.output_dir, min_count, max_wishlist, - &trash, ) .await?; } @@ -257,6 +217,7 @@ 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 ebad412..b1a57dc 100644 --- a/src/types.rs +++ b/src/types.rs @@ -29,16 +29,6 @@ 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), @@ -142,7 +132,7 @@ pub fn normalize_to_final_build(config: &str) -> String { digits[i] = b - b'0'; } - let sum: u8 = digits.iter().sum(); + let sum: i32 = digits.iter().map(|&d| d as i32).sum(); if sum >= 8 { // Already a final build (sum == 8) or maxed (sum > 8) — return as-is. return config.to_string(); @@ -162,55 +152,8 @@ 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. -/// 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`]. +/// Uses [`normalize_to_final_build`] internally: if normalization returns the +/// original string unchanged, the config is already at or beyond a final build. pub fn is_final_build(config: &str) -> bool { - 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 ")); - } + normalize_to_final_build(config) == config } diff --git a/src/user_data.rs b/src/user_data.rs index 512207f..f3037db 100644 --- a/src/user_data.rs +++ b/src/user_data.rs @@ -30,19 +30,6 @@ 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. @@ -75,55 +62,30 @@ 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 { - 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"); + 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"); + } } - } 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") @@ -133,17 +95,7 @@ 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, - unlocked, - garage_unlocked, - storage, - garage_slots, - garage_slots_used, - }) + Ok(MyCarsData { owned, wishlist, owned_configs, copy_count }) } #[cfg(test)] @@ -159,54 +111,4 @@ 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 34c6b9f..2ac3eef 100644 --- a/tests/http_tests.rs +++ b/tests/http_tests.rs @@ -31,23 +31,22 @@ async fn test_get_file_success() { } #[tokio::test] -async fn test_get_file_empty_response_fails_immediately_without_retry() { +async fn test_get_file_empty_response_retries_and_fails() { let mut server = Server::new_async().await; - // 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(); + // 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(); 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; - 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"); + assert!(result.is_err(), "Empty response should fail after retries"); m.assert(); } diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 18514e6..6bc80a5 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -240,7 +240,6 @@ 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 } ], @@ -252,19 +251,15 @@ fn test_load_my_cars_data_with_fixture() { let data = load_my_cars_data(path.to_str().unwrap()).unwrap(); - // Only locked cards are owned (set deduplicates duplicate guids) + // Only locked cards are owned assert_eq!(data.owned.len(), 2); assert!(data.owned.contains("guid_a")); assert!(data.owned.contains("guid_c")); assert!(!data.owned.contains("guid_b")); - // 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()])); + // 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()])); 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")); @@ -294,11 +289,6 @@ 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()]));