From a9a46ee551c08fc35c9e59f54855f2792f7c9fff Mon Sep 17 00:00:00 2001 From: Jan-Peter von Hunnius Date: Fri, 8 May 2026 16:19:01 +0200 Subject: [PATCH] refactor: modular architecture with unified error handling and DashMap concurrency - Break monolithic records.rs into commands/, output/, parsing/ modules - Add lib.rs with shared types (AppError, Car, BestCarRecord, etc.) - Replace Arc> with DashMap for concurrent access - Add unified AppError enum with thiserror - Add configurable CLI paths (--my-cars, --cars-db, --times-dir, --tracks-db) - Add serde deserialization for Car struct - Add CSV escaping for fields with commas/quotes/newlines - Fix track name splitting: split_once -> rsplit_once for _a handling - Add integration tests, proptest property-based tests, and unit tests - Add rustfmt.toml configuration - Update documentation (API, ARCHITECTURE, DATA_STRUCTURES, README) --- .gitignore | 1 + Cargo.lock | 248 ++++++++++- Cargo.toml | 6 +- README.md | 8 +- docs/API.md | 764 +++++++++++--------------------- docs/ARCHITECTURE.md | 154 ++++--- docs/DATA_STRUCTURES.md | 20 +- plans/code-review.md | 396 +++++++++++++++++ rustfmt.toml | 3 + src/commands/all.rs | 32 ++ src/commands/best.rs | 19 + src/commands/details.rs | 18 + src/commands/fetch.rs | 48 ++ src/commands/mod.rs | 63 +++ src/commands/track.rs | 22 + src/commands/trash.rs | 17 + src/commands/wishlist.rs | 25 ++ src/lib.rs | 410 ++++++++++++++--- src/output/mod.rs | 649 +++++++++++++++++++++++++++ src/parsing/car_list.rs | 47 ++ src/parsing/mod.rs | 652 +++++++++++++++++++++++++++ src/records.rs | 878 +++---------------------------------- tests/integration_tests.rs | 362 +++++++++++++++ tests/proptest_tests.rs | 169 +++++++ 24 files changed, 3541 insertions(+), 1470 deletions(-) create mode 100644 plans/code-review.md create mode 100644 rustfmt.toml create mode 100644 src/commands/all.rs create mode 100644 src/commands/best.rs create mode 100644 src/commands/details.rs create mode 100644 src/commands/fetch.rs create mode 100644 src/commands/mod.rs create mode 100644 src/commands/track.rs create mode 100644 src/commands/trash.rs create mode 100644 src/commands/wishlist.rs create mode 100644 src/output/mod.rs create mode 100644 src/parsing/car_list.rs create mode 100644 src/parsing/mod.rs create mode 100644 tests/integration_tests.rs create mode 100644 tests/proptest_tests.rs diff --git a/.gitignore b/.gitignore index 34ebdf5..00b4e86 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ my_cars.json cars_repo.json tracks_repo.json times/ +*.csv diff --git a/Cargo.lock b/Cargo.lock index 5a4aefa..1e65070 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -64,12 +64,33 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.11.1" @@ -176,6 +197,26 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -359,6 +400,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -367,7 +420,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] @@ -391,6 +444,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.15.5" @@ -758,6 +817,15 @@ dependencies = [ "tempfile", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -813,16 +881,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - [[package]] name = "parking_lot_core" version = "0.9.12" @@ -863,6 +921,15 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -882,6 +949,31 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.45" @@ -891,12 +983,56 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -906,6 +1042,12 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "reqwest" version = "0.12.28" @@ -1012,6 +1154,18 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.23" @@ -1069,6 +1223,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", + "serde_derive", ] [[package]] @@ -1122,16 +1277,6 @@ 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 = "slab" version = "0.4.12" @@ -1237,6 +1382,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -1256,9 +1421,7 @@ dependencies = [ "bytes", "libc", "mio", - "parking_lot", "pin-project-lite", - "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -1313,10 +1476,14 @@ name = "top_drives" version = "0.3.0" dependencies = [ "clap", + "dashmap", "futures", + "proptest", "reqwest", + "serde", "serde_json", "tempfile", + "thiserror", "tokio", ] @@ -1390,6 +1557,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1438,6 +1611,15 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "want" version = "0.3.1" @@ -1810,6 +1992,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.7" diff --git a/Cargo.toml b/Cargo.toml index 3f60e1c..a2de455 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,10 +13,14 @@ path = "src/records.rs" [dependencies] reqwest = "0.12.15" -tokio = { version = "1.44.1", features = ["full"] } +tokio = { version = "1.44.1", features = ["rt-multi-thread", "macros", "fs", "sync", "io-util"] } +serde = { version = "1", features = ["derive"] } serde_json = "1.0.140" futures = "0.3.31" clap = { version = "4", features = ["derive"] } +thiserror = "2" +dashmap = "6" [dev-dependencies] tempfile = "3.10.1" +proptest = "1" diff --git a/README.md b/README.md index b0e5014..437d141 100644 --- a/README.md +++ b/README.md @@ -282,10 +282,11 @@ top_drives/ ### Dependencies - **reqwest**: HTTP client for data fetching -- **serde_json**: JSON parsing and serialization -- **tokio**: Asynchronous runtime +- **serde** / **serde_json**: JSON parsing and serialization (with `serde::Deserialize` derive for `Car`) +- **tokio**: Asynchronous runtime (rt-multi-thread, macros, fs, sync, io-util) - **futures**: Async utility combinators -- **once_cell**: Lazy static initialization +- **clap**: CLI argument parsing (with derive macros) +- **thiserror**: Ergonomic error type definitions ## ๐Ÿ”ง Development @@ -325,6 +326,7 @@ struct BestCarRecord { config: String, // Tune configuration time: f64, // Track time owned: bool, // Whether you own this car + prize: bool, // Whether this is a prize car guid: String, // Game identifier } ``` diff --git a/docs/API.md b/docs/API.md index 97222b7..92635e6 100644 --- a/docs/API.md +++ b/docs/API.md @@ -6,543 +6,267 @@ This document provides comprehensive API documentation for the Top Drives Record ## Core Modules -### Records Module (`src/records.rs`) +### Shared Library (`src/lib.rs`) -Primary analysis engine for processing JSON-based timing data. +Common types and utilities shared across the project. + +#### Public Types + +##### `Car` +**Core car information structure** + +```rust +#[derive(Debug, Clone, Deserialize)] +pub struct Car { + pub rid: String, // Repository identifier + pub brand: String, // Manufacturer/brand name + pub only_name: String, // Model name (without brand) + pub year: u16, // Model year + pub rq: u8, // Rarity level (10-113) + pub prize: bool, // Whether car is prize-only + pub guid: String, // Game-specific card identifier +} +``` + +**Purpose**: Represents a car entity from the records database. + +**Deserialization**: Uses `serde::Deserialize` with `#[serde(rename = "onlyName")]` for the `only_name` field and `#[serde(default)]` for `prize`. + +##### `BestCarRecord` +**Best performing car record for a specific track/RQ/condition** + +```rust +#[derive(Debug, Clone)] +pub struct BestCarRecord { + pub car_rid: String, // Car identifier + pub brand: String, // Manufacturer + pub only_name: String, // Model name + pub year: u16, // Year + pub rq: u8, // RQ level + pub config: String, // Tune config (111, 233, etc.) + pub time: f64, // Track time in seconds + pub owned: bool, // User ownership status + pub prize: bool, // Prize car status + pub guid: String, // Game identifier +} +``` + +##### `TripletKey` +**Composite key for unique track conditions** + +```rust +pub type TripletKey = (String, u8, String); +// Track ID RQ Level Surface Condition +``` + +##### `AnalysisContext` +**Mutable analysis state** + +```rust +pub struct AnalysisContext { + pub best_cars: Arc>>>, +} +``` + +**Methods**: +- `new()` โ€” Create a new, empty analysis context +- `lock_best_cars()` โ€” Acquire a lock on the best_cars map (returns `Result`, converts poison errors to `AppError`) + +##### `CarCounts` +**Aggregated per-car win counts** + +```rust +pub struct CarCounts { + pub by_rid: HashMap, + pub guid_to_rid: HashMap, + pub car_map: HashMap, + pub car_list: Vec, +} +``` + +##### `MyCarsData` +**Parsed user data from `my_cars.json`** + +```rust +#[derive(Debug)] +pub struct MyCarsData { + pub owned: HashSet, // Owned car GUIDs + pub wishlist: HashSet, // Wishlist car GUIDs +} +``` + +##### `AppError` +**Unified error type** + +```rust +#[derive(Debug, thiserror::Error)] +pub enum AppError { + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + #[error("Network error: {0}")] + Network(String), + #[error("Parse error in {file}: {reason}")] + Parse { file: String, reason: String }, + #[error("Missing data file: {0}")] + MissingFile(String), + #[error("Internal error: mutex poisoned โ€” {0}")] + Poisoned(String), +} +``` #### Public Functions -##### `main()` -**Entry point for the records analyzer** +##### `translate_track_name(track_code: &str) -> String` +**Translates internal track identifiers to human-readable names** -```rust -#[tokio::main] -async fn main() -``` +- **Input**: Track code (e.g., `"laguna"`) +- **Output**: Track name (e.g., `"Laguna Seca"`) +- **Fallback**: Returns original code if no mapping found +- **Data source**: `track_names.json` (compiled into binary via `include_str!`) -**Purpose**: Command-line interface and operation dispatcher +##### `translate_track_type(track_code: &str) -> &'static str` +**Translates condition codes to surface names** -**Arguments**: Command line arguments -- `args[1]`: Command (`fetch`, `best`, `missing`, `trash`, `details`, `track`, `full_script`) +- **Input**: Condition code (e.g., `"00"`) +- **Output**: Surface name (e.g., `"Dry"`) +- **Fallback**: Returns `"Unknown"` for unrecognized codes -**Commands**: -- `fetch`: Download all data files -- `best`: Show best owned cars -- `missing`: Show best unowned cars -- `trash`: Show removable cars -- `details`: Export all timing data -- `track`: Track-specific analysis -- `full_script`: Run fetch + all analysis commands +##### `get_file(file_url: &str, local_file_path: &str) -> Result<(), AppError>` +**Downloads a file from a URL and saves it locally** -**Example**: -```bash -cargo run --bin records -- best -``` +- Removes existing file before downloading +- Returns `AppError::Network` on HTTP failures +- Returns `AppError::Io` on file write failures -##### `print_best_cars(filter_out_owned: bool)` -**Displays ranked list of best performing cars** +##### `load_my_cars_data(path: &str) -> Result` +**Loads and parses user car collection data** -```rust -fn print_best_cars(filter_out_owned: bool) -``` +- **Input**: Path to `my_cars.json` +- **Returns**: `MyCarsData` with owned and wishlist GUIDs +- **Errors**: `AppError::MissingFile` if file not found, `AppError::Parse` if JSON invalid -**Purpose**: Aggregates performance data across all tracks and conditions +##### `compare_cars(a: &Car, b: &Car) -> std::cmp::Ordering` +**Sorting comparator for cars** -**Arguments**: -- `filter_out_owned`: If true, excludes owned cars from output +- Sorts by: RQ โ†’ Brand โ†’ Model name -**Output Format**: -```csv -RQ,Brand,Model,Year,Count -85,McLaren,F1,1993,15 -82,Ferrari,250 GTO,1962,12 -``` +##### `compute_car_counts(ctx: &AnalysisContext, car_list: Vec) -> Result` +**Aggregates per-car win counts from analysis context** -**Algorithm**: -1. Count occurrences of each car across all track/RQ/condition combinations -2. Sort by RQ level, then by car identifier -3. Apply ownership filter if requested +- Returns `CarCounts` with by_rid, guid_to_rid, car_map, and car_list -##### `print_best_times()` -**Exports comprehensive timing data for all combinations** +##### `escape_csv_field(field: &str) -> String` +**Escapes a field for safe CSV inclusion** -```rust -fn print_best_times() -``` - -**Purpose**: Detailed performance export for external analysis - -**Output Format**: -```csv -RQ,TrackID,Condition,CarRQ,Brand,Model,Year,Config,Time -85,laguna,Dry,85,McLaren,F1,1993,233,112.45 -``` - -**Data Volume**: Large output file with all track/condition/RQ combinations - -##### `print_best_car(track: &String, condition: &String, rq: &String)` -**Finds best car for specific track conditions** - -```rust -fn print_best_car(track: &String, condition: &String, rq: &String) -``` - -**Purpose**: Targeted analysis for specific racing scenarios - -**Arguments**: -- `track`: Track identifier (e.g., "laguna", "monaco") -- `condition`: Surface condition (e.g., "Dry", "Wet") -- `rq`: RQ level limit - -**Example**: -```rust -print_best_car(&"laguna".to_string(), &"Dry".to_string(), &"85".to_string()); -``` - -##### `print_trash()` -**Identifies cars safe for removal from collection** - -```rust -fn print_trash() -``` - -**Purpose**: Collection optimization recommendations - -**Safety Criteria**: -- Car not among best performers on any track -- RQ level below 80 (preserves high-end cars) -- Not a prize car (may have collection value) - -**Output Format**: -```csv -RQ,Brand,Model,Year -45,Toyota,Corolla,1995 -``` - -##### `parse_car_times(car: Car)` -**Processes timing data for a single car** - -```rust -async fn parse_car_times(car: Car) -``` - -**Purpose**: Core parsing logic for individual car performance data - -**Process Flow**: -1. Load car's JSON timing file -2. Parse each configuration (111, 233, 323, etc.) -3. Extract timing data for each track/condition -4. Apply quality filters (downvotes, time validation) -5. Update global best cars cache - -**Quality Filters**: -- Minimum time: 1.2 seconds -- No downvotes from community -- Special handling for Test Bowl track - -##### `parse_all_times()` -**Orchestrates concurrent processing of all cars** - -```rust -async fn parse_all_times() -``` - -**Purpose**: Coordinates parallel processing of car timing files - -**Concurrency**: One async task per car file, bounded by a semaphore (limit 20) -**Synchronization**: Waits for all tasks to complete - -##### `update_best_car(track_id: &str, car_rq: u8, surface: &str, new_record: BestCarRecord)` -**Updates global best cars database with new performance data** - -```rust -fn update_best_car(track_id: &str, car_rq: u8, surface: &str, new_record: BestCarRecord) -``` - -**Purpose**: Implements core best-car selection algorithm - -**RQ Range Analysis**: -- Tests car from base RQ up to `MAX_RQ` (113) -- Compares against existing best times -- Updates records for improved performance - -**Logic**: -1. For each RQ level from car_rq to `MAX_RQ` (113): -2. If new time is better โ†’ replace record -3. If new time ties โ†’ add to list (if not duplicate) -4. If new time is worse โ†’ ignore - -##### `get_car_list() -> Vec` -**Loads complete car database from repository file** - -```rust -fn get_car_list() -> Vec -``` - -**Purpose**: Provides access to all known cars in the database - -**Source**: `cars_repo.json` file -**Returns**: Vector of all cars with complete metadata - -##### `get_my_car_list() -> Vec` -**Loads user's car collection from game export** - -```rust -fn get_my_car_list() -> Vec -``` - -**Purpose**: Identifies which cars the user owns - -**Source**: `my_cars.json` game export -**Filter**: Only `locked: true` cards -**Returns**: Vector of owned car GUIDs - -##### `fetch_times()` -**Downloads timing files for all cars with rate limiting** - -```rust -async fn fetch_times() -``` - -**Purpose**: Batch download of car timing data - -**Rate Limiting**: 20 concurrent downloads maximum (semaphore) -**Progress**: Shows download progress for each file - -##### `fetch_car(rid: String)` -**Downloads timing data for single car** - -```rust -async fn fetch_car(rid: String) -``` - -**Purpose**: Individual car data fetching - -**URL Format**: `https://api.topdrivesrecords.com/car/{rid}` -**Storage**: `times/{rid}.json` - -##### `get_cars_file()` -**Downloads main car database** - -```rust -async fn get_cars_file() -``` - -**Purpose**: Updates car metadata database - -**Source**: GitHub repository cars database -**Destination**: `cars_repo.json` - -##### `get_tracks_file()` -**Downloads track definitions** - -```rust -async fn get_tracks_file() -``` - -**Purpose**: Updates track name mappings - -**Source**: GitHub repository tracks database -**Destination**: `tracks_repo.json` - -##### `get_file(file_url: &str, local_file_path: &str)` -**Generic file download utility** - -```rust -async fn get_file(file_url: &str, local_file_path: &str) -``` - -**Purpose**: Downloads files from remote URLs - -**Features**: -- Automatic existing file removal -- Error handling with graceful failure -- Progress indication through errors - -### Shared Library (`src/lib.rs`) - -Common utilities shared across the project. - -##### `translate_track_name(track_code: &str) -> &str` -**Converts track codes to human-readable names** - -```rust -fn translate_track_name(track_code: &str) -> &str -``` - -**Purpose**: User-friendly track name display - -**Implementation**: Loads mappings from `track_names.json` at compile time via `include_str!` - -**Coverage**: All tracks defined in `track_names.json` -**Fallback**: Returns original code if not found - -##### `translate_track_type(track_code: &str) -> &str` -**Converts condition codes to surface descriptions** - -```rust -fn translate_track_type(track_code: &str) -> &str -``` - -**Purpose**: Human-readable surface condition names - -**Mappings**: -- `"00"` โ†’ `"Dry"` -- `"01"` โ†’ `"Wet"` -- `"10"` โ†’ `"Dirt"` -- `"11"` โ†’ `"Dirt Wet"` - -##### `split_prefix_postfix<'a>(input: &'a str, delimiter: &'a str) -> Option<(&'a str, &'a str)>` -**String splitting utility for track ID parsing** - -```rust -fn split_prefix_postfix<'a>(input: &'a str, delimiter: &'a str) -> Option<(&'a str, &'a str)> -``` - -**Purpose**: Parse track IDs with condition suffixes - -**Example**: -```rust -split_prefix_postfix("laguna_a00", "_a") // Some(("laguna", "00")) -``` - -## Data Structures - -### Records Module Data Types - -#### `Card` -**User's car collection entry** - -```rust -struct Card { - cardId: String, // Unique card identifier - locked: bool, // Ownership status -} -``` - -#### `Car` -**Car metadata from database** - -```rust -struct Car { - rid: String, // Repository ID - brand: String, // Manufacturer - only_name: String, // Model name - year: u16, // Model year - rq: u8, // Rarity level - prize: bool, // Prize car status - guid: String, // Game GUID -} -``` - -#### `BestCarRecord` -**Performance record for best cars** - -```rust -struct BestCarRecord { - car_rid: String, // Car identifier - brand: String, // Manufacturer - only_name: String, // Model name - year: u16, // Year - rq: u8, // RQ level - config: String, // Tune configuration - time: f64, // Track time - owned: bool, // User ownership - guid: String, // Game identifier -} -``` - -#### `TripletKey` -**Composite key for track conditions** - -```rust -type TripletKey = (String, u8, String); -// Track ID RQ Level Surface Condition -``` - -### Constants - -#### `MAX_RQ` -```rust -const MAX_RQ: u8 = 113; -``` - -**Purpose**: Maximum RQ level for range analysis. Replaces former magic number 115 with a named constant. - -## Global State - -### `BEST_CARS` Cache -**Thread-safe global performance database** - -```rust -static BEST_CARS: Lazy>>> -``` - -**Initialization**: One-time setup with `once_cell::Lazy` -**Thread Safety**: Protected by `Mutex` for concurrent access -**Structure**: HashMap with composite keys for efficient lookups - -## Error Handling - -### Network Errors -- **HTTP Failures**: Logged but don't crash application -- **Invalid JSON**: Graceful fallback to defaults -- **Missing Files**: Clear error messages for user - -### Parse Errors -- **Malformed Data**: Skip invalid entries -- **Type Conversion**: Use safe defaults -- **Missing Fields**: Provide sensible fallbacks - -### User Input Errors -- **Invalid Commands**: Clear usage instructions -- **Missing Arguments**: Descriptive error messages -- **Out of Range**: Validation with helpful feedback - -## Usage Examples - -### Basic Analysis Workflow - -```rust -// 1. Load user's car collection -let my_cars = get_my_car_list(); - -// 2. Process all car timing data -parse_all_times().await; - -// 3. Generate best cars report -print_best_cars(false); - -// 4. Find collection optimization opportunities -print_trash(); -``` - -### Track-Specific Analysis - -```rust -// Analyze specific track conditions -let track = "laguna".to_string(); -let condition = "Dry".to_string(); -let rq = "85".to_string(); - -print_best_car(&track, &condition, &rq); -``` - -### Data Fetching - -```rust -// Download fresh data -get_cars_file().await; -get_tracks_file().await; -fetch_times().await; -``` - -## Performance Characteristics - -### Time Complexity -- **Parsing**: O(n) where n = number of cars ร— tracks -- **Best Car Selection**: O(1) HashMap operations -- **Sorting**: O(n log n) for output ordering - -### Memory Usage -- **Peak**: ~200-300MB during full analysis -- **Baseline**: ~50MB application overhead -- **Per Car**: ~1-2KB metadata + timing data - -### Concurrency -- **Downloads**: 20 concurrent connections max (semaphore bounded) -- **Processing**: One task per car file -- **Thread Safety**: Mutex-protected shared state - -## Extension Points - -### Adding New Commands -1. Add command parsing in `main()` -2. Implement analysis function -3. Update help documentation - -### Adding New Tracks -1. Update `tracks_repo.json` with new track definitions -2. Add track name mapping to `track_names.json` -3. Track names are compiled in at build time - -### Custom Analysis Types -1. Create new output formatting functions -2. Add aggregation algorithms -3. Extend data structures as needed - -## Security Considerations - -### Network Security -- HTTPS-only external requests -- Certificate validation (with fallback) -- No sensitive data transmission - -### File System Security -- Read-only access to game files -- No modification of user data -- Safe file path handling - -### Input Validation -- Command injection prevention -- Path traversal protection -- Input sanitization - -## Troubleshooting API Issues - -### Common Problems - -**"Function not found"** -- Check module imports -- Verify function signatures -- Ensure proper visibility - -**"Type mismatch"** -- Review struct field types -- Check function signatures -- Verify generic parameters - -**"Deadlock"** -- Avoid nested Mutex locks -- Check lock ordering -- Use timeout where possible - -### Debug Strategies - -**Logging**: -```rust -println!("Processing car: {}", car.rid); -eprintln!("Error: {}", error); -``` - -**Tracing**: -```rust -// Add at function entry -let _span = tracing::span!(Level::INFO, "function_name"); -let _enter = _span.enter(); -``` - -**Profiling**: -```bash -cargo flamegraph --bin records -- best -``` - -## Migration Guide - -### Version Compatibility -- **Current**: 0.2.0 API stable -- **Previous**: 0.1.0 fully compatible -- **Future**: Semantic versioning maintained - -### Breaking Changes -- Club module (`src/club.rs`) has been removed -- `scraper` dependency removed -- `tracks.txt` removed (replaced by `track_names.json`) -- Magic number 115 replaced with `const MAX_RQ: u8 = 113` -- `translate_track_name()` now loads from `track_names.json` instead of hardcoded match +- Wraps in quotes if field contains comma, quote, newline, or carriage return +- Doubles internal quotes per CSV spec --- -**API Version**: 0.2.0 -**Last Updated**: 2026-05-06 -**Compatibility**: Rust 1.70.0+ +### Records Binary (`src/records.rs`) + +Primary analysis engine for processing JSON-based timing data. + +#### Constants + +| Constant | Value | Description | +|----------|-------|-------------| +| `MAX_RQ` | 113 | Maximum RQ level for range analysis | +| `MIN_VALID_TIME` | 1.2 | Minimum valid track time (seconds) | +| `TIME_TOLERANCE` | 0.005 | Tolerance for tie detection (seconds) | +| `TEST_BOWL_FACTOR` | 2000.0 | Multiplier for Test Bowl time inversion | +| `CONCURRENCY_LIMIT` | 20 | Maximum concurrent tasks | +| `DEFAULT_MY_CARS_PATH` | `"my_cars.json"` | Default user data file path | +| `CARS_DB_PATH` | `"cars_repo.json"` | Car database file path | +| `TIMES_DIR` | `"times"` | Timing data directory | +| `CARS_DB_URL` | GitHub URL | Remote car database URL | +| `TRACKS_DB_URL` | GitHub URL | Remote tracks database URL | +| `CAR_TIMING_API_BASE` | API URL | Base URL for per-car timing data | + +#### CLI Commands + +| Command | Description | Flags | +|---------|-------------|-------| +| `fetch` | Download latest car and track data | None | +| `best` | Generate `best.csv` and `very_best.csv` | `--min-count N` (default: 10) | +| `trash` | Generate `trash.csv` | None | +| `wishlist` | Generate `wishlist.csv`, `add.csv`, `remove.csv` | `--min-count N`, `--max-wishlist M` | +| `all` | Generate all output files in a single pass | `--min-count N`, `--max-wishlist M` | +| `details` | Generate `details.csv` with all timing data | None | +| `track` | Show best car for specific track/condition/RQ | ` ` | + +#### Key Internal Functions + +##### `run_analysis(owned: &HashSet) -> Result<(AnalysisContext, CarCounts), AppError>` +**Common analysis setup** + +Loads user data, parses all timing files, and computes car counts. + +##### `parse_car_times(car: Car, my_cars: &HashSet, ctx: &AnalysisContext)` +**Processes timing data for a single car** + +- Runs inside `tokio::task::spawn_blocking` to avoid blocking async runtime +- Reads car JSON file from `times/` directory +- Processes each configuration and track +- Applies quality filters (downvotes, minimum time) +- Handles Test Bowl time inversion + +##### `update_best_car(best_cars, track_id, car_rq, surface, new_record)` +**Updates best car records with a new performance entry** + +- Iterates RQ range from car's RQ to `MAX_RQ` +- Replaces existing record if new time is faster +- Adds to tie list if within `TIME_TOLERANCE` +- Prevents duplicate `car_rid` entries in tie lists + +##### `parse_all_times(my_cars, ctx) -> Vec` +**Orchestrates concurrent parsing of all car timing files** + +- Uses `Semaphore(CONCURRENCY_LIMIT)` for bounded concurrency +- Spawns blocking tasks via `tokio::task::spawn_blocking` +- Returns the full car list for downstream use + +##### `compute_ideal_wishlist(counts, owned, min_count, max_wishlist) -> Vec<(BestCarRecord, usize)>` +**Computes the ideal wishlist** + +- Filters to cars with count >= `min_count` +- Excludes owned cars +- Sorts by RQ +- Caps at `max_wishlist` + +#### Output Functions + +##### `write_sorted_csv(path, header, entries, cmp, format_row)` +**Generic sorted CSV writer** + +- Sorts by index (avoids cloning entries) +- Writes header row +- Formats each row via closure + +##### `write_best_csv(counts)`, `write_very_best_csv(counts, min_count)` +**Generate best car CSV files** + +- Format: `RQ,Brand,Model,Year,Count` +- Fields are CSV-escaped + +##### `write_trash_csv(counts, owned)` +**Generate trash car CSV file** + +- Filters: not best on any track, RQ < 80, not prize car +- Format: `RQ,Brand,Model,Year` + +##### `write_wishlist_files(counts, owned, wishlist, min_count, max_wishlist)` +**Generate wishlist, add, and remove CSV files** + +- Computes ideal wishlist once +- Derives all three files from it +- Format: `RQ,Brand,Model,Year,Count` + +##### `write_details_csv(ctx)` +**Generate comprehensive timing data CSV** + +- Format: `RQ,TrackID,Condition,CarRQ,Brand,Model,Year,Config,Time` +- Sorted by RQ then track ID + +##### `print_best_car(ctx, track, condition, rq)` +**Print best car for a specific track/condition/RQ to stdout** diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3fc75be..7cf5567 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -6,7 +6,29 @@ The Top Drives Records Analyzer consists of a main analysis binary and a shared ## Component Architecture -### 1. Records Module (`src/records.rs`) +### 1. Shared Library (`src/lib.rs`) + +**Common types and utilities** shared across the project. + +#### Key Responsibilities +- **Data Types**: Defines `Car`, `BestCarRecord`, `AnalysisContext`, `CarCounts`, `MyCarsData`, `AppError` +- **Track Name Translation**: Converts track codes to human-readable names via `translate_track_name()` +- **Track Type Translation**: Converts condition codes to surface descriptions via `translate_track_type()` +- **File Download**: Async file download utility via `get_file()` +- **User Data Loading**: Parses `my_cars.json` via `load_my_cars_data()` +- **Car Comparison**: Sorting comparator via `compare_cars()` +- **Count Aggregation**: Per-car win count computation via `compute_car_counts()` +- **CSV Escaping**: Safe CSV field formatting via `escape_csv_field()` + +#### Error Handling +All public functions return `Result` where `AppError` is a unified error type: +- `AppError::Io` โ€” File system errors +- `AppError::Network` โ€” HTTP/download errors +- `AppError::Parse` โ€” JSON parsing errors with file context +- `AppError::MissingFile` โ€” Required data file not found +- `AppError::Poisoned` โ€” Mutex poisoning from panicked threads + +### 2. Records Binary (`src/records.rs`) **Primary analysis engine** that processes pre-downloaded JSON timing data. @@ -15,9 +37,14 @@ The Top Drives Records Analyzer consists of a main analysis binary and a shared - **File Processing**: Parses JSON timing files for each car configuration - **Performance Analysis**: Compares timing data across RQ ranges and track conditions - **Collection Management**: Identifies owned cars and optimization opportunities +- **CSV Output**: Writes analysis results to CSV files with proper escaping #### Key Constants -- `MAX_RQ: u8 = 113` โ€” Maximum RQ level used for range analysis (replaces former magic number 115) +- `MAX_RQ: u8 = 113` โ€” Maximum RQ level used for range analysis +- `MIN_VALID_TIME: f64 = 1.2` โ€” Minimum valid track time threshold +- `TIME_TOLERANCE: f64 = 0.005` โ€” Tolerance for tie detection (5ms) +- `TEST_BOWL_FACTOR: f64 = 2000.0` โ€” Multiplier for Test Bowl time inversion +- `CONCURRENCY_LIMIT: usize = 20` โ€” Maximum concurrent tasks #### Data Flow @@ -32,21 +59,9 @@ JSON Parsing โ†’ BestCarRecord Analysis โ†“ User Collection (my_cars.json) โ†“ -Output: CSV/Terminal Results +Output: CSV files / Terminal Results ``` -### 2. Shared Library (`src/lib.rs`) - -**Common utilities** shared across the project. - -#### Key Responsibilities -- **Track Name Translation**: Converts track codes to human-readable names via `translate_track_name()` -- **Track Type Translation**: Converts condition codes to surface descriptions via `translate_track_type()` - -#### Configuration -- Track name mappings are loaded from `track_names.json` at compile time using `include_str!` -- This external config file maps track codes (e.g., `"laguna"`) to human-readable names (e.g., `"Laguna Seca"`) - ## Data Structures ### Core Types @@ -63,14 +78,15 @@ type TripletKey = (String, u8, String); #### Car ```rust +#[derive(Debug, Clone, Deserialize)] struct Car { rid: String, // Repository ID brand: String, // Manufacturer only_name: String, // Model name - year: u16, // Model year - rq: u8, // Rarity level - prize: bool, // Prize car status - guid: String, // Game GUID + year: u16, // Model year + rq: u8, // Rarity level + prize: bool, // Prize car status + guid: String, // Game GUID } ``` @@ -80,25 +96,41 @@ struct BestCarRecord { car_rid: String, // Car identifier brand: String, // Manufacturer only_name: String, // Model name - year: u16, // Year - rq: u8, // RQ level + year: u16, // Year + rq: u8, // RQ level config: String, // Tune config (111, 233, etc.) - time: f64, // Track time (seconds) - owned: bool, // User ownership status - guid: String, // Game identifier + time: f64, // Track time (seconds) + owned: bool, // User ownership status + prize: bool, // Prize car status + guid: String, // Game identifier } ``` -## Global State Management +## State Management -### BEST_CARS Cache +### AnalysisContext ```rust -static BEST_CARS: Lazy>>> +struct AnalysisContext { + best_cars: Arc>>>, +} ``` **Thread Safety**: Protected by `Mutex` for concurrent access during parsing -**Initialization**: Uses `once_cell::Lazy` for one-time setup -**Structure**: HashMap with composite keys for efficient lookups +**Lifetime**: Created in `main()`, passed through the call chain +**Access**: `lock_best_cars()` method returns `Result` with proper error handling + +### CarCounts +```rust +struct CarCounts { + by_rid: HashMap, + guid_to_rid: HashMap, + car_map: HashMap, + car_list: Vec, +} +``` + +**Purpose**: Aggregated per-car win counts computed once from `AnalysisContext` +**Usage**: Shared across all output functions to avoid redundant computation ## Algorithm Details @@ -116,20 +148,21 @@ The system analyzes car performance across multiple RQ brackets: Multiple validation layers ensure data quality: -1. **Minimum Time**: Times below 1.2 seconds are discarded +1. **Minimum Time**: Times below `MIN_VALID_TIME` (1.2s) are discarded 2. **Downvote Filter**: Community-downvoted times are excluded -3. **Special Cases**: Test Bowl track uses inverted time calculation (2000.0 / time) +3. **Tie Detection**: Times within `TIME_TOLERANCE` (5ms) are considered equal +4. **Special Cases**: Test Bowl track uses inverted time calculation (`TEST_BOWL_FACTOR / time`) ### Concurrent Processing #### Bounded Concurrency Pattern -- **Task Spawning**: One async task per car file -- **Semaphore**: Limits concurrent operations to 20 (bounded concurrency) +- **Task Spawning**: One blocking task per car file via `tokio::task::spawn_blocking` +- **Semaphore**: Limits concurrent operations to `CONCURRENCY_LIMIT` (20) - **Error Isolation**: Individual car failures don't stop batch processing #### Synchronization - **Join Handles**: Waits for all parsing tasks to complete -- **Mutex Protection**: Safe concurrent access to shared BEST_CARS cache +- **Mutex Protection**: Safe concurrent access to shared `AnalysisContext` ## File Organization @@ -138,8 +171,8 @@ Multiple validation layers ensure data quality: โ”œโ”€โ”€ cars_repo.json # Car database from repository โ”œโ”€โ”€ tracks_repo.json # Track definitions โ”œโ”€โ”€ track_names.json # Track code โ†’ name mappings (compiled in) -โ”œโ”€โ”€ my_cars.json # User's car collection (game export) -โ””โ”€โ”€ times/ # Individual car timing files +โ”œโ”€โ”€ my_cars.json # User's car collection (game export) +โ””โ”€โ”€ times/ # Individual car timing files โ”œโ”€โ”€ Ferrari_F40_1987.json โ”œโ”€โ”€ McLaren_F1_1993.json โ””โ”€โ”€ ... @@ -147,14 +180,13 @@ Multiple validation layers ensure data quality: ### Output Files ``` -โ”œโ”€โ”€ best.csv # Best cars analysis results -โ”œโ”€โ”€ details.csv # Detailed timing data -โ”œโ”€โ”€ add.csv # Cars to add to wishlist -โ”œโ”€โ”€ remove.csv # Cars to remove from wishlist +โ”œโ”€โ”€ best.csv # All cars best on at least one track +โ”œโ”€โ”€ very_best.csv # Cars best on โ‰ฅ min_count tracks +โ”œโ”€โ”€ details.csv # Detailed timing data for all combinations โ”œโ”€โ”€ trash.csv # Owned cars safe to remove -โ”œโ”€โ”€ sorted.csv # Best cars sorted by count -โ”œโ”€โ”€ very_best.csv # Best cars with count โ‰ฅ 10 -โ””โ”€โ”€ wishlist.csv # Missing cars already on wishlist +โ”œโ”€โ”€ wishlist.csv # Ideal wishlist (very_best unowned, capped) +โ”œโ”€โ”€ add.csv # Cars to add to wishlist +โ””โ”€โ”€ remove.csv # Cars to remove from wishlist ``` ## Performance Characteristics @@ -168,33 +200,47 @@ Multiple validation layers ensure data quality: ## Error Handling Strategy +### Unified Error Type +All errors are represented as `AppError`: +- **I/O Errors**: File read/write failures +- **Network Errors**: Download failures with descriptive messages +- **Parse Errors**: JSON parsing failures with file name context +- **Missing Files**: Specific variant for absent data files +- **Poisoned Mutex**: Thread panic detection + ### Graceful Degradation -- **Missing Files**: Continue with available data -- **Network Errors**: Log errors but don't crash -- **Parse Failures**: Use default values or skip invalid entries +- **Missing User Data**: `load_my_cars_or_warn()` returns empty data with a warning +- **Network Errors**: Fetch errors logged but don't crash +- **Parse Failures**: Individual car file failures emit warnings and skip ### User Feedback - **Progress Indication**: Shows fetching/download progress -- **Error Messages**: Clear descriptions of what went wrong +- **Error Messages**: Clear descriptions via `AppError` Display impl +- **Warning Messages**: Non-fatal issues reported via stderr ## Extension Points ### Adding New Tracks 1. Update `tracks_repo.json` with new track definitions 2. Add track name mapping in `track_names.json` -3. Add condition mappings in `translate_track_type()` +3. Add condition mappings in `translate_track_type()` if needed ### Adding New Analysis Types -1. Create new output functions (similar to `print_best_cars()`) -2. Add command parsing in main function -3. Update help text and documentation +1. Create new output function(s) using `write_sorted_csv` +2. Add command variant to the `Commands` enum +3. Add command handler in `main()` +4. Update help text and documentation + +### Adding New Output Formats +1. Create new output function(s) alongside existing CSV writers +2. Reuse `CarCounts` for data access +3. Add CLI flag for format selection if needed ## Security Considerations ### Network Access - **HTTPS Only**: All external requests use HTTPS -- **Certificate Validation**: Accepts invalid certificates for compatibility -- **Rate Limiting**: Respects server limits with controlled concurrency (semaphore limit of 20) +- **Rate Limiting**: Respects server limits with controlled concurrency ### Local File Access - **Read Only**: No modification of game files @@ -209,6 +255,8 @@ Multiple validation layers ensure data quality: 3. **Configuration Files**: External configuration for analysis parameters 4. **GUI Interface**: Web-based interface for non-technical users 5. **Export Formats**: Multiple output formats (JSON, XML, etc.) +6. **Module Split**: Break `records.rs` into a module tree for better organization +7. **Concurrent HashMap**: Use `DashMap` for finer-grained locking ### Scalability Considerations 1. **Pagination**: Handle large datasets with pagination diff --git a/docs/DATA_STRUCTURES.md b/docs/DATA_STRUCTURES.md index 0669458..b51bad7 100644 --- a/docs/DATA_STRUCTURES.md +++ b/docs/DATA_STRUCTURES.md @@ -235,15 +235,10 @@ const MAX_RQ: u8 = 113; **Car Database Processing:** ```rust -// Parse cars_repo.json -let cars_data: Vec = serde_json::from_str(&file_content)?; -for car in cars_data { - let car = Car { - rid: car["rid"].as_str().unwrap_or(""), - brand: car["brand"].as_str().unwrap_or(""), - // ... other fields - }; -} +// Parse cars_repo.json โ€” uses serde derive for deserialization +let cars: Vec = serde_json::from_str(&content)?; +// Falls back to manual Value-based parsing if serde deserialization fails +// (for backward compatibility with older JSON formats) ``` **Timing Data Processing:** @@ -303,8 +298,9 @@ BestCarRecord { **RQ Range Analysis:** - Test each car from its base RQ up to `MAX_RQ` (113) -- Compare against existing best times +- Compare against existing best times using `DashMap` entry API - Update records if better performance found +- Uses `DashMap` for fine-grained concurrent access (no global lock) ## Data Quality Measures @@ -345,8 +341,8 @@ BestCarRecord { ### Memory Usage **Large Data Structures**: -- `BEST_CARS` HashMap: Contains all best records across RQ ranges -- Individual timing files: Loaded and parsed sequentially +- `AnalysisContext.best_cars` (`DashMap>`): Contains all best records across RQ ranges +- Individual timing files: Loaded and parsed sequentially via `spawn_blocking` - User collection: Small, loaded once **Optimization Strategies**: diff --git a/plans/code-review.md b/plans/code-review.md new file mode 100644 index 0000000..56c8da0 --- /dev/null +++ b/plans/code-review.md @@ -0,0 +1,396 @@ +# Top Drives Records Analyzer โ€” Code Review + +## Executive Summary + +The project is a well-structured Rust CLI tool for analyzing car performance data from the Top Drives racing game. The code is generally clean, well-documented, and follows good Rust practices. The recent v0.3.0 refactor (moving from global mutable state to `AnalysisContext`, adding `CarCounts`, and introducing the `all` command) shows a clear improvement trajectory. + +However, there are several areas where the codebase could be significantly improved: **error handling robustness**, **test coverage depth**, **documentation accuracy**, **performance optimizations**, and **code organization**. + +--- + +## 1. Code Structure & Design + +### 1.1 โœ… What's Done Well + +- **Clean separation** between library ([`lib.rs`](src/lib.rs)) and binary ([`records.rs`](src/records.rs)) +- **Good use of `AnalysisContext`** to eliminate global mutable state โ€” this is a solid architectural improvement +- **`CarCounts` struct** centralizes derived data computation, avoiding redundant calculations +- **`write_sorted_csv` generic helper** eliminates CSV writing boilerplate across output functions +- **Bounded concurrency** via `Semaphore(20)` prevents resource exhaustion +- **`clap` derive macros** for CLI parsing โ€” idiomatic and maintainable + +### 1.2 ๐Ÿ”ง Suggested Improvements + +#### A. Monolithic `records.rs` โ€” Break Into Modules + +[`records.rs`](src/records.rs) is 923 lines with parsing logic, output logic, fetch logic, CLI definition, and tests all in one file. This should be split into a module tree: + +``` +src/ +โ”œโ”€โ”€ lib.rs +โ”œโ”€โ”€ records.rs # main() + CLI only +โ”œโ”€โ”€ commands/ +โ”‚ โ”œโ”€โ”€ mod.rs # Command dispatch +โ”‚ โ”œโ”€โ”€ fetch.rs # Fetch logic +โ”‚ โ”œโ”€โ”€ best.rs # Best/very_best output +โ”‚ โ”œโ”€โ”€ trash.rs # Trash output +โ”‚ โ”œโ”€โ”€ wishlist.rs # Wishlist/add/remove output +โ”‚ โ”œโ”€โ”€ details.rs # Details output +โ”‚ โ””โ”€โ”€ track.rs # Track-specific output +โ”œโ”€โ”€ parsing/ +โ”‚ โ”œโ”€โ”€ mod.rs # parse_all_times, parse_car_times +โ”‚ โ””โ”€โ”€ car_list.rs # get_car_list +โ””โ”€โ”€ output/ + โ”œโ”€โ”€ mod.rs + โ””โ”€โ”€ csv_writer.rs # write_sorted_csv, format_counted_row +``` + +#### B. `parse_car_times` Is Async But Does No Async Work + +[`parse_car_times()`](src/records.rs:491) is declared `async` but only performs synchronous file I/O and JSON parsing. It's called inside `tokio::task::spawn`, which means it's running on the Tokio runtime but blocking it with `fs::read_to_string` and `serde_json::from_str`. + +**Recommendation**: Either use `tokio::task::spawn_blocking` for the synchronous work, or use `tokio::fs::read_to_string` for truly async file I/O. The current approach blocks the Tokio runtime threads. + +#### C. `update_best_car` Allocates Excessively + +[`update_best_car()`](src/records.rs:571) clones `new_record` for every RQ level from `car_rq` to `MAX_RQ` (potentially 28 clones for an RQ 85 car). It also allocates a new `String` for each key on every iteration. + +**Recommendation**: Consider building the key once and reusing it, or restructuring to reduce allocations in this hot path. + +#### D. `AnalysisContext` Re-construction in `parse_all_times` + +In [`parse_all_times()`](src/records.rs:625), a new `AnalysisContext` is constructed from the cloned `Arc`: +```rust +parse_car_times(car, &my_cars, &AnalysisContext { best_cars }).await; +``` +This works but is semantically odd โ€” it creates a new struct just to pass the same `Arc`. Consider having `parse_car_times` accept `&Arc>` directly, or pass `&AnalysisContext` by cloning the Arc. + +--- + +## 2. Error Handling + +### 2.1 ๐Ÿ”ด Critical: Silent Failures + +Several functions silently swallow errors and continue with empty data: + +- [`get_car_list()`](src/records.rs:688) returns `Vec::new()` on file-not-found or parse errors โ€” if `cars_repo.json` is missing, the entire analysis runs on zero cars with no indication +- [`parse_car_times()`](src/records.rs:497) returns `()` and silently skips cars with unreadable files +- [`load_my_cars_data()`](src/lib.rs:172) returns empty sets on failure + +**Recommendation**: Use `Result` return types consistently. For the binary, consider using `anyhow` or `thiserror` for ergonomic error handling. At minimum, distinguish between "file missing" (warn) and "file corrupt" (error). + +### 2.2 ๐Ÿ”ด Mixed Error Handling Strategies + +The codebase mixes: +- `Result<(), String>` for async functions ([`get_file()`](src/lib.rs:242)) +- `std::io::Result<()>` for output functions +- `eprintln!` + early return for input parsing +- `expect()` for mutex poisoning + +**Recommendation**: Define a custom error type using `thiserror`: +```rust +#[derive(Debug, thiserror::Error)] +enum AppError { + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + #[error("Network error: {0}")] + Network(String), + #[error("Parse error in {file}: {reason}")] + Parse { file: String, reason: String }, +} +``` + +### 2.3 ๐ŸŸก `unwrap()` vs `expect()` Inconsistency + +- [`update_best_car()`](src/records.rs:581) uses `best_cars.lock().unwrap()` +- [`compute_car_counts()`](src/lib.rs:117) uses `.expect("best_cars mutex poisoned...")` + +**Recommendation**: Use `.expect()` consistently with descriptive messages, or better yet, use a helper method on `AnalysisContext` that centralizes lock acquisition. + +--- + +## 3. Test Coverage + +### 3.1 ๐Ÿ”ด Critical: Minimal Test Coverage + +The test suite in [`records.rs:736-923`](src/records.rs:736) has only **8 tests**, and several are superficial: + +| Test | What It Actually Tests | Quality | +|------|----------------------|---------| +| `test_split_once` | Standard library `split_once` behavior | โŒ Trivial โ€” tests stdlib | +| `test_translate_track_name` | Track name lookup | โœ… Good | +| `test_translate_track_type` | Condition code lookup | โœ… Good | +| `test_update_best_car_logic` | Only checks struct field values | โŒ Doesn't test `update_best_car` | +| `test_rq_range_analysis` | RQ range insertion | โœ… Good | +| `test_time_validation` | Two float comparisons | โŒ Trivial โ€” tests nothing | +| `test_track_name_completeness` | Sample of track name mappings | โœ… Good | +| `test_condition_completeness` | Sample of condition mappings | โœ… Good | +| `test_time_tolerance_constant` | Tie-breaking logic | โœ… Best test in the suite | + +### 3.2 ๐Ÿ”ง Missing Test Categories + +**No tests for**: +- [`compute_car_counts()`](src/lib.rs:115) โ€” critical aggregation logic +- [`write_sorted_csv()`](src/records.rs:202) โ€” output formatting +- [`write_best_csv()`](src/records.rs:232), [`write_trash_csv()`](src/records.rs:260), [`write_wishlist_files()`](src/records.rs:296) โ€” all output functions +- [`compute_ideal_wishlist()`](src/records.rs:277) โ€” wishlist algorithm +- [`get_car_list()`](src/records.rs:688) โ€” car database parsing +- [`load_my_cars_data()`](src/lib.rs:172) โ€” user data parsing +- [`compare_cars()`](src/lib.rs:232), [`compare_records()`](src/records.rs:191) โ€” sort comparators +- [`parse_car_times()`](src/records.rs:491) โ€” core parsing logic +- CSV output correctness (field ordering, escaping, headers) +- Edge cases: empty data, single car, all cars owned, no cars owned + +### 3.3 ๐Ÿ”ง Recommended Test Improvements + +1. **Add integration tests** in a `tests/` directory that exercise the full pipeline with fixture data +2. **Add property-based tests** using `proptest` for the sorting and comparison functions +3. **Test `update_best_car`** more thoroughly: test replacement, test tie-breaking, test multiple cars at different RQ levels +4. **Test CSV output** by writing to a buffer and comparing against expected output +5. **Remove trivial tests** that only test standard library behavior (`test_split_once`, `test_time_validation`) +6. **Add test utilities** for creating fixture `Car`, `BestCarRecord`, and `AnalysisContext` instances + +--- + +## 4. Performance + +### 4.1 ๐ŸŸก `update_best_car` Hot Path + +[`update_best_car()`](src/records.rs:571) is called for every track ร— condition ร— car combination (potentially millions of calls). Current issues: + +- **Holds mutex lock while iterating RQ range** โ€” lock is held for `MAX_RQ - car_rq + 1` iterations +- **Clones `new_record` for each RQ level** โ€” up to 28 clones per call +- **Allocates `String` keys in a loop** โ€” heap allocation per iteration + +**Recommendation**: +- Pre-compute a batch of updates and apply them under a single lock +- Use `String` interning or `&str` references for track IDs and conditions to reduce allocations +- Consider `DashMap` instead of `Mutex` for finer-grained locking + +### 4.2 ๐ŸŸก `parse_car_times` Does Synchronous I/O on Async Runtime + +As noted in ยง1.2.B, `fs::read_to_string` blocks the Tokio runtime. With 1000+ cars and a semaphore of 20, this means 20 Tokio threads can be blocked simultaneously on file I/O. + +### 4.3 ๐ŸŸก `compute_car_counts` Clones All Records + +[`compute_car_counts()`](src/lib.rs:115) clones every `BestCarRecord` when building `by_rid`. For large datasets, this is significant memory allocation. + +### 4.4 โœ… Good: Semaphore-Based Concurrency + +The `Semaphore(20)` in both [`parse_all_times()`](src/records.rs:616) and [`fetch_times()`](src/records.rs:644) is a good pattern for controlling concurrency. + +--- + +## 5. Documentation Issues + +### 5.1 ๐Ÿ”ด Critical: `docs/API.md` Is Severely Outdated + +[`docs/API.md`](docs/API.md) references: +- `print_best_cars(filter_out_owned: bool)` โ€” **removed in v0.3.0** +- `print_best_times()` โ€” **removed/renamed** +- `print_trash()` โ€” **removed in v0.3.0** +- `missing` command โ€” **removed in v0.3.0** +- `full_script` command โ€” **never existed in current form** +- Old function signatures that no longer match the code + +**This is the most critical documentation issue.** The API docs are actively misleading. + +### 5.2 ๐Ÿ”ด `docs/ARCHITECTURE.md` References Removed Code + +[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md:96) still references: +``` +static BEST_CARS: Lazy>>> +``` +This global static was replaced by `AnalysisContext` in v0.3.0. The doc also lists `sorted.csv` as an output file, which doesn't appear in the current code. + +### 5.3 ๐ŸŸก `README.md` Dependency List Is Wrong + +[`README.md`](README.md:289) lists `once_cell` as a dependency, but it's not in [`Cargo.toml`](Cargo.toml). The code uses `std::sync::OnceLock` instead (which is correct โ€” the README is outdated). + +### 5.4 ๐ŸŸก `BestCarRecord` Missing `prize` Field in README + +The [`README.md` data structure listing](README.md:319) for `BestCarRecord` doesn't include the `prize` field that exists in the [actual struct](src/lib.rs:69). + +### 5.5 ๐ŸŸก `docs/DATA_STRUCTURES.md` Code Examples Are Outdated + +[`docs/DATA_STRUCTURES.md`](docs/DATA_STRUCTURES.md:237) shows code examples using `unwrap_or("")` patterns, while the actual code in [`get_car_list()`](src/records.rs:706) uses `filter_map` with `?` operator. + +### 5.6 โœ… Good: Inline Documentation + +The inline rustdoc comments are generally excellent โ€” well-structured with purpose, arguments, examples, and special cases. The module-level doc comments are informative. + +--- + +## 6. Specific Code Issues + +### 6.1 ๐ŸŸก Hardcoded File Paths + +Multiple functions use hardcoded relative file paths: +- `"my_cars.json"` in [`load_my_cars_data()`](src/lib.rs:173) +- `"cars_repo.json"` in [`get_car_list()`](src/records.rs:689) +- `"times/"` prefix in [`parse_car_times()`](src/records.rs:494) and [`fetch_car()`](src/records.rs:679) +- Output file names: `"best.csv"`, `"trash.csv"`, etc. + +**Recommendation**: Define constants or accept configurable paths via CLI flags. + +### 6.2 ๐ŸŸก Hardcoded URLs + +[`get_tracks_file()`](src/records.rs:727) and [`get_cars_file()`](src/records.rs:732) have hardcoded GitHub URLs. These should be constants at minimum. + +### 6.3 ๐ŸŸก CSV Escaping + +[`format_counted_row()`](src/records.rs:225) and other output functions don't handle CSV escaping. If car brand or model names contain commas, quotes, or newlines, the CSV output will be malformed. + +**Recommendation**: Use the `csv` crate, or at minimum implement proper CSV escaping. + +### 6.4 ๐ŸŸก `Test Bowl` Magic Number + +The Test Bowl time inversion `2000.0 / time` at [line 533](src/records.rs:533) uses a magic number with no explanation of why 2000.0 is the correct constant. + +### 6.5 ๐ŸŸข Minor: `year as u16` and `rq as u8` Casting + +In [`get_car_list()`](src/records.rs:715), `year` and `rq` are cast from `u64` to `u16`/`u8` without bounds checking. If the JSON contains values outside the valid range, this will silently truncate. + +### 6.6 ๐ŸŸข Minor: `test_update_best_car_logic` Test Name Is Misleading + +The test at [line 777](src/records.rs:777) is named `test_update_best_car_logic` but never actually calls `update_best_car()`. It only checks struct field values. + +--- + +## 7. Dependency Concerns + +### 7.1 ๐ŸŸก `tokio` with `features = ["full"]` + +[`Cargo.toml`](Cargo.toml:16) enables all Tokio features. For a CLI tool, only `rt-multi-thread`, `macros`, and `fs` are likely needed. Using `full` increases compile time and binary size. + +### 7.2 ๐ŸŸก No `serde` Derive for Data Structures + +`Car` and `BestCarRecord` use manual JSON field extraction via `serde_json::Value`. If the JSON schemas are stable, deriving `serde::Deserialize` would be safer and more performant. + +### 7.3 ๐ŸŸข Missing `csv` Crate + +Output is hand-formatted CSV. The `csv` crate would handle escaping, quoting, and provide a type-safe writing interface. + +--- + +## 8. `.gitignore` and Project Configuration + +### 8.1 ๐ŸŸก Output CSV Files Not in `.gitignore` + +[`.gitignore`](.gitignore) ignores `times/`, `cars_repo.json`, `tracks_repo.json`, and `my_cars.json`, but doesn't ignore generated output files: `best.csv`, `very_best.csv`, `trash.csv`, `wishlist.csv`, `add.csv`, `remove.csv`, `details.csv`. + +### 8.2 ๐ŸŸก No `rustfmt.toml` or `clippy.toml` + +No configuration for code formatting or linting standards. + +--- + +## 9. Proposed Improvement Plan + +### Priority 1 โ€” High Impact + +| # | Improvement | Rationale | +|---|-------------|-----------| +| 1 | Fix outdated `docs/API.md` | Currently misleading โ€” references removed functions | +| 2 | Fix outdated `docs/ARCHITECTURE.md` | References removed global static | +| 3 | Fix `README.md` dependency list and struct definitions | Lists wrong deps and missing fields | +| 4 | Add output CSV files to `.gitignore` | Prevent accidental commits | +| 5 | Add tests for `compute_car_counts` | Critical untested aggregation logic | +| 6 | Add tests for `compute_ideal_wishlist` | Wishlist algorithm has no coverage | +| 7 | Add tests for CSV output functions | Output correctness is unverified | + +### Priority 2 โ€” Medium Impact + +| # | Improvement | Rationale | +|---|-------------|-----------| +| 8 | Introduce custom error type with `thiserror` | Consistent error handling | +| 9 | Use `Result` returns instead of silent failures | Prevent hidden data loss | +| 10 | Add integration tests with fixture data | End-to-end correctness | +| 11 | Fix `parse_car_times` async/blocking mismatch | Prevents runtime thread starvation | +| 12 | Add CSV escaping or use `csv` crate | Data correctness for edge cases | +| 13 | Extract hardcoded URLs as constants | Maintainability | +| 14 | Remove trivial tests, rename misleading ones | Test suite quality | + +### Priority 3 โ€” Lower Impact / Refactoring + +| # | Improvement | Rationale | +|---|-------------|-----------| +| 15 | Split `records.rs` into module tree | Maintainability as project grows | +| 16 | Consider `DashMap` for concurrent HashMap | Performance under high contention | +| 17 | Reduce allocations in `update_best_car` | Hot path optimization | +| 18 | Narrow `tokio` features from `full` | Compile time and binary size | +| 19 | Add `serde` derive for data structures | Type-safe parsing | +| 20 | Add `rustfmt.toml` and `clippy.toml` | Code consistency | +| 21 | Add configurable file paths via CLI | Flexibility | + +--- + +## 10. Architecture Diagram โ€” Current vs Proposed + +### Current Architecture + +```mermaid +graph TD + A[main - records.rs] --> B[CLI parsing - clap] + B --> C[Fetch] + B --> D[Best] + B --> E[Trash] + B --> F[Wishlist] + B --> G[All] + B --> H[Details] + B --> I[Track] + + C --> J[get_file - lib.rs] + D --> K[run_analysis] + E --> K + F --> K + G --> K + H --> K + I --> K + + K --> L[parse_all_times] + L --> M[parse_car_times x N] + M --> N[update_best_car] + N --> O[AnalysisContext.best_cars] + + K --> P[compute_car_counts] + P --> Q[CarCounts] + + Q --> R[write_sorted_csv] + R --> S[CSV files] + + style A fill:#f9f,stroke:#333 + style O fill:#ff9,stroke:#333 + style Q fill:#9f9,stroke:#333 +``` + +### Proposed Module Structure + +```mermaid +graph TD + A[main.rs - CLI only] --> B[commands/mod.rs] + B --> C[fetch.rs] + B --> D[best.rs] + B --> E[trash.rs] + B --> F[wishlist.rs] + B --> G[details.rs] + B --> H[track.rs] + + I[lib.rs] --> J[types.rs - Car, BestCarRecord, etc.] + I --> K[analysis.rs - AnalysisContext, CarCounts] + I --> L[translate.rs - track name/type] + I --> M[io.rs - get_file, load_my_cars_data] + + D --> N[parsing/mod.rs] + E --> N + F --> N + G --> N + H --> N + N --> O[parse_all_times] + O --> P[parse_car_times] + + Q[output/mod.rs] --> R[csv_writer.rs] + D --> Q + E --> Q + F --> Q + G --> Q diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..57ac821 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,3 @@ +edition = "2021" +max_width = 100 +use_small_heuristics = "Max" diff --git a/src/commands/all.rs b/src/commands/all.rs new file mode 100644 index 0000000..5c516c6 --- /dev/null +++ b/src/commands/all.rs @@ -0,0 +1,32 @@ +//! All command implementation. +//! +//! Generates all output files in a single pass: best.csv, very_best.csv, +//! trash.csv, details.csv, wishlist.csv, add.csv, and remove.csv. + +use top_drives::AppError; + +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. +pub async fn run_all( + my_cars_path: &str, + cars_db_path: &str, + times_dir: &str, + min_count: usize, + max_wishlist: usize, +) -> Result<(), AppError> { + let my_data = load_my_cars_or_warn(my_cars_path)?; + let (ctx, counts) = run_analysis(&my_data.owned, cars_db_path, times_dir).await?; + output::write_best_csv(&counts)?; + output::write_very_best_csv(&counts, min_count)?; + output::write_trash_csv(&counts, &my_data.owned)?; + output::write_details_csv(&ctx)?; + output::write_wishlist_files( + &counts, &my_data.owned, &my_data.wishlist, min_count, max_wishlist, + )?; + println!( + "Written: best.csv, very_best.csv, trash.csv, details.csv, wishlist.csv, add.csv, remove.csv" + ); + Ok(()) +} diff --git a/src/commands/best.rs b/src/commands/best.rs new file mode 100644 index 0000000..0d5690e --- /dev/null +++ b/src/commands/best.rs @@ -0,0 +1,19 @@ +//! Best and very_best command implementations. +//! +//! Generates best.csv (all cars best on at least one track) and +//! very_best.csv (cars best on >= min_count tracks). + +use top_drives::AppError; + +use crate::commands::{load_my_cars_or_warn, run_analysis}; +use crate::output::{write_best_csv, write_very_best_csv}; + +/// Executes the best command: generates best.csv and very_best.csv. +pub async fn run_best(my_cars_path: &str, cars_db_path: &str, times_dir: &str, min_count: usize) -> Result<(), AppError> { + let my_data = load_my_cars_or_warn(my_cars_path)?; + let (_ctx, counts) = run_analysis(&my_data.owned, cars_db_path, times_dir).await?; + write_best_csv(&counts)?; + write_very_best_csv(&counts, min_count)?; + println!("Written: best.csv, very_best.csv"); + Ok(()) +} diff --git a/src/commands/details.rs b/src/commands/details.rs new file mode 100644 index 0000000..7dcf49a --- /dev/null +++ b/src/commands/details.rs @@ -0,0 +1,18 @@ +//! Details command implementation. +//! +//! Generates details.csv with comprehensive timing data for all +//! track/condition/RQ combinations. + +use top_drives::AppError; + +use crate::commands::{load_my_cars_or_warn, run_analysis}; +use crate::output::write_details_csv; + +/// Executes the details command: generates details.csv. +pub async fn run_details(my_cars_path: &str, cars_db_path: &str, times_dir: &str) -> Result<(), AppError> { + let my_data = load_my_cars_or_warn(my_cars_path)?; + let (ctx, _counts) = run_analysis(&my_data.owned, cars_db_path, times_dir).await?; + write_details_csv(&ctx)?; + println!("Written: details.csv"); + Ok(()) +} diff --git a/src/commands/fetch.rs b/src/commands/fetch.rs new file mode 100644 index 0000000..6563f2a --- /dev/null +++ b/src/commands/fetch.rs @@ -0,0 +1,48 @@ +//! Fetch command implementation. +//! +//! Downloads car database, tracks database, and per-car timing data +//! from remote repositories. + +use top_drives::{AppError, get_file}; + +use crate::parsing::fetch_times; + +/// Base URL for the remote data repository on GitHub. +/// Single place to update if the repository path or branch changes. +const GITHUB_RAW_BASE: &str = + "https://github.com/TiagoXavi/topdrives-records/raw/refs/heads/main/src/database"; + +/// Returns the full URL for the car database file. +fn cars_db_url() -> String { + format!("{GITHUB_RAW_BASE}/cars_final.json") +} + +/// Returns the full URL for the tracks database file. +fn tracks_db_url() -> String { + format!("{GITHUB_RAW_BASE}/tracks_repo.json") +} + +/// Downloads the tracks database file from the remote repository. +async fn get_tracks_file(tracks_db_path: &str) -> Result<(), AppError> { + println!("Fetching tracks file..."); + get_file(&tracks_db_url(), tracks_db_path).await +} + +/// Downloads the cars database file from the remote repository. +pub async fn get_cars_file(cars_db_path: &str) -> Result<(), AppError> { + println!("Fetching cars file..."); + get_file(&cars_db_url(), cars_db_path).await +} + +/// Executes the fetch command: downloads all data files. +/// +/// Downloads the car database, tracks database, and per-car timing files. +/// Fails fast on critical dependency failures โ€” the cars DB must be available +/// before per-car timing files can be fetched. +pub async fn run_fetch(cars_db_path: &str, tracks_db_path: &str, times_dir: &str) -> Result<(), AppError> { + get_cars_file(cars_db_path).await?; + get_tracks_file(tracks_db_path).await?; + fetch_times(cars_db_path, times_dir).await?; + println!("Fetch complete โ€” all files downloaded successfully."); + Ok(()) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 0000000..e6501d7 --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1,63 @@ +//! Command dispatch and shared helpers. +//! +//! Provides the `run_analysis` orchestration function and `load_my_cars_or_warn` +//! helper used by all command handlers. + +pub mod all; +pub mod best; +pub mod details; +pub mod fetch; +pub mod track; +pub mod trash; +pub mod wishlist; + +use std::collections::HashSet; +use std::sync::Arc; + +use top_drives::{ + AppError, AnalysisContext, CarCounts, MyCarsData, + compute_car_counts, load_my_cars_data, +}; + +use crate::parsing::parse_all_times; + +// --------------------------------------------------------------------------- +// Helper: load my_cars.json with graceful degradation +// --------------------------------------------------------------------------- + +/// Loads user car data, printing a warning and returning empty data if the file is missing. +/// +/// Only `MissingFile` errors are gracefully degraded โ€” parse errors and other +/// failures are propagated to the caller, because running analysis with +/// silently-corrupted ownership data would produce misleading results. +pub fn load_my_cars_or_warn(path: &str) -> Result { + match load_my_cars_data(path) { + Ok(data) => Ok(data), + Err(AppError::MissingFile(msg)) => { + eprintln!("Warning: {msg}"); + eprintln!(" Analysis will run without ownership data."); + Ok(MyCarsData::default()) + } + Err(e) => Err(e), + } +} + +// --------------------------------------------------------------------------- +// Analysis orchestration +// --------------------------------------------------------------------------- + +/// 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). The car list is stored inside `CarCounts`. +pub async fn run_analysis( + owned: &HashSet, + cars_db_path: &str, + times_dir: &str, +) -> Result<(AnalysisContext, CarCounts), AppError> { + let ctx = AnalysisContext::new(); + let owned = Arc::new(owned.clone()); + let car_list = parse_all_times(owned, &ctx, cars_db_path, times_dir).await?; + let counts = compute_car_counts(&ctx, car_list)?; + Ok((ctx, counts)) +} diff --git a/src/commands/track.rs b/src/commands/track.rs new file mode 100644 index 0000000..f4eabe4 --- /dev/null +++ b/src/commands/track.rs @@ -0,0 +1,22 @@ +//! Track command implementation. +//! +//! Shows the best car for a specific track/condition/RQ combination. + +use top_drives::AppError; + +use crate::commands::{load_my_cars_or_warn, run_analysis}; +use crate::output::print_best_car; + +/// Executes the track command: shows best car for a specific track/condition/RQ. +pub async fn run_track( + my_cars_path: &str, + cars_db_path: &str, + times_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.owned, 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 new file mode 100644 index 0000000..98c35be --- /dev/null +++ b/src/commands/trash.rs @@ -0,0 +1,17 @@ +//! Trash command implementation. +//! +//! Generates trash.csv โ€” owned cars not best on any track (RQ < 80, not prize). + +use top_drives::AppError; + +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(my_cars_path: &str, cars_db_path: &str, times_dir: &str) -> Result<(), AppError> { + let my_data = load_my_cars_or_warn(my_cars_path)?; + let (_ctx, counts) = run_analysis(&my_data.owned, cars_db_path, times_dir).await?; + write_trash_csv(&counts, &my_data.owned)?; + println!("Written: trash.csv"); + Ok(()) +} diff --git a/src/commands/wishlist.rs b/src/commands/wishlist.rs new file mode 100644 index 0000000..77feb41 --- /dev/null +++ b/src/commands/wishlist.rs @@ -0,0 +1,25 @@ +//! Wishlist command implementation. +//! +//! Generates wishlist.csv, add.csv, and remove.csv from ideal wishlist computation. + +use top_drives::AppError; + +use crate::commands::{load_my_cars_or_warn, run_analysis}; +use crate::output::write_wishlist_files; + +/// Executes the wishlist command: generates wishlist.csv, add.csv, and remove.csv. +pub async fn run_wishlist( + my_cars_path: &str, + cars_db_path: &str, + times_dir: &str, + min_count: usize, + max_wishlist: usize, +) -> Result<(), AppError> { + let my_data = load_my_cars_or_warn(my_cars_path)?; + let (_ctx, counts) = run_analysis(&my_data.owned, cars_db_path, times_dir).await?; + write_wishlist_files( + &counts, &my_data.owned, &my_data.wishlist, min_count, max_wishlist, + )?; + println!("Written: wishlist.csv, add.csv, remove.csv"); + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 44504d9..2599267 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,7 @@ //! - **`AnalysisContext` struct** โ€” holds mutable analysis state (replaces global static) //! - **`CarCounts` struct** โ€” aggregated per-car win counts //! - **`MyCarsData` struct** โ€” parsed user data from `my_cars.json` +//! - **`AppError` enum** โ€” unified error type for the application //! - **`translate_track_name()`** โ€” internal track code โ†’ human-readable name //! - **`translate_track_type()`** โ€” condition code โ†’ surface name //! - **`get_file()`** โ€” async file download utility @@ -21,26 +22,63 @@ use std::collections::{HashMap, HashSet}; use std::fs; use std::fs::File; use std::io::BufReader; -use std::sync::{Arc, Mutex, OnceLock}; +use std::sync::{Arc, OnceLock}; +use dashmap::DashMap; use reqwest::get; +use serde::Deserialize; use serde_json::Value; +/// Custom error type for the Top Drives application. +/// +/// Provides a unified error handling strategy across all modules, +/// replacing the mix of `Result<(), String>`, `std::io::Result`, and +/// silent `eprintln!` + early return patterns. +#[derive(Debug, thiserror::Error)] +pub enum AppError { + /// An I/O error occurred (file not found, permission denied, etc.) + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + + /// A network error occurred during data fetching + #[error("Network error: {0}")] + Network(String), + + /// A JSON parsing error in a specific file + #[error("Parse error in {file}: {reason}")] + Parse { + /// The file that failed to parse + file: String, + /// The reason for the parse failure + reason: String, + }, + + /// A required data file is missing + #[error("Missing data file: {0}")] + MissingFile(String), + + /// An internal runtime error (e.g., semaphore closed, task join failure) + #[error("Internal error: {0}")] + Internal(String), +} + /// Core car information structure containing basic car metadata. /// Used throughout the application to represent car entities. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] pub struct Car { /// Unique car identifier used in the records database pub rid: String, /// Car manufacturer/brand name pub brand: String, /// Car model name (without brand) + #[serde(rename = "onlyName")] pub only_name: String, /// Car model year pub year: u16, /// Rarity level (10-113, affects performance brackets) pub rq: u8, /// Whether this car is only available as a prize car + #[serde(default)] pub prize: bool, /// Game-specific unique identifier for the car pub guid: String, @@ -80,10 +118,13 @@ pub type TripletKey = (String, u8, String); /// /// Replaces the former global `static BEST_CARS`. Created in `main()` and /// passed through the call chain so that no global mutable state is needed. +/// +/// Uses `Arc` for concurrent access from spawned tasks. +/// `DashMap` provides sharded internal locking for high-concurrency access. pub struct AnalysisContext { - /// Best car records for each track/RQ/surface combination, protected by - /// `Arc>` for concurrent access from spawned tasks. - pub best_cars: Arc>>>, + /// Best car records for each track/RQ/surface combination. + /// Wrapped in `Arc` so it can be shared across async tasks. + pub best_cars: Arc>>, } impl AnalysisContext { @@ -91,7 +132,7 @@ impl AnalysisContext { #[allow(clippy::new_without_default)] pub fn new() -> Self { AnalysisContext { - best_cars: Arc::new(Mutex::new(HashMap::new())), + best_cars: Arc::new(DashMap::new()), } } } @@ -112,21 +153,19 @@ pub struct CarCounts { /// Computes per-car win counts from the analysis context. /// Returns a `CarCounts` struct that can be shared across all output functions. -pub fn compute_car_counts(ctx: &AnalysisContext, car_list: Vec) -> CarCounts { - let best_cars = ctx.best_cars.lock() - .expect("best_cars mutex poisoned โ€” another thread panicked"); +pub fn compute_car_counts(ctx: &AnalysisContext, car_list: Vec) -> Result { let mut by_rid: HashMap = HashMap::new(); let mut guid_to_rid: HashMap = HashMap::new(); - for (_key, best_list) in best_cars.iter() { - for best_car in best_list { - let entry = by_rid + for entry in ctx.best_cars.iter() { + for best_car in entry.value() { + let rid_entry = by_rid .entry(best_car.car_rid.clone()) .or_insert_with(|| (best_car.clone(), 0usize)); - entry.1 += 1; + rid_entry.1 += 1; // Keep the record with the best (lowest) time for this car. - if best_car.time < entry.0.time { - entry.0 = best_car.clone(); + if best_car.time < rid_entry.0.time { + rid_entry.0 = best_car.clone(); } // HashMap iteration order is non-deterministic, but car_rid and guid // have a 1:1 relationship in the data model, so any mapping is correct. @@ -140,13 +179,14 @@ pub fn compute_car_counts(ctx: &AnalysisContext, car_list: Vec) -> CarCount .map(|c| (c.guid.clone(), c.clone())) .collect(); - CarCounts { by_rid, guid_to_rid, car_map, car_list } + Ok(CarCounts { by_rid, guid_to_rid, car_map, car_list }) } /// Parsed user data from `my_cars.json` /// /// Holds both the owned car GUIDs and wishlist GUIDs extracted from a single /// file read, avoiding duplicate I/O. +#[derive(Debug, Default)] pub struct MyCarsData { /// GUIDs of cars the user owns (from `playerDeck[].cardId` where `locked == true`) pub owned: HashSet, @@ -166,31 +206,19 @@ pub struct MyCarsData { /// /// # Error Handling /// -/// Returns an empty `MyCarsData` (both sets empty) with a warning if the file -/// is missing or cannot be parsed, matching the graceful degradation pattern -/// used throughout the codebase. -pub fn load_my_cars_data() -> MyCarsData { - let file = match File::open("my_cars.json") { - Ok(f) => f, - Err(e) => { - eprintln!("Warning: could not open my_cars.json: {e}"); - return MyCarsData { - owned: HashSet::new(), - wishlist: HashSet::new(), - }; +/// Returns an `AppError` if the file cannot be opened or parsed. +/// Use `MyCarsData::default()` for graceful degradation when the file is optional. +pub fn load_my_cars_data(path: &str) -> Result { + let file = File::open(path).map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + AppError::MissingFile(format!("{path}: {e}")) + } else { + AppError::Io(e) } - }; + })?; let reader = BufReader::new(file); - let json: Value = match serde_json::from_reader(reader) { - Ok(v) => v, - Err(e) => { - eprintln!("Warning: could not parse my_cars.json: {e}"); - return MyCarsData { - owned: HashSet::new(), - wishlist: HashSet::new(), - }; - } - }; + let json: Value = serde_json::from_reader(reader) + .map_err(|e| AppError::Parse { file: path.to_string(), reason: e.to_string() })?; // Extract owned car GUIDs from playerDeck let owned: HashSet = json @@ -223,7 +251,7 @@ pub fn load_my_cars_data() -> MyCarsData { }) .unwrap_or_default(); - MyCarsData { owned, wishlist } + Ok(MyCarsData { owned, wishlist }) } /// Comparison function for sorting cars by RQ, then brand, then model name. @@ -238,21 +266,87 @@ pub fn compare_cars(a: &Car, b: &Car) -> std::cmp::Ordering { /// Downloads a file from a URL and saves it locally. /// /// Returns `Ok(())` on success, or an error describing what went wrong. -/// Removes any existing file at the local path before downloading. -pub async fn get_file(file_url: &str, local_file_path: &str) -> Result<(), String> { - let _ = fs::remove_file(local_file_path); +/// +/// Writes to a temporary file first, then renames on success, so a failed +/// download never deletes the existing file. +/// +/// Retries up to `MAX_FETCH_RETRIES` times on transient failures (server errors, +/// connection errors, or empty responses). Permanent HTTP errors (4xx) fail immediately. +pub async fn get_file(file_url: &str, local_file_path: &str) -> Result<(), AppError> { + /// Maximum number of retries when the server returns an empty response. + const MAX_FETCH_RETRIES: usize = 3; - match get(file_url).await { - Ok(response) => { - match response.text().await { - Ok(content) => { - fs::write(local_file_path, content) - .map_err(|e| format!("Error writing {local_file_path}: {e}")) + let tmp_path = format!("{local_file_path}.tmp"); + let _ = fs::remove_file(&tmp_path); // clean stale temp file, OK to ignore + + let mut attempt = 0usize; + loop { + match get(file_url).await { + Ok(response) => { + let status = response.status(); + if !status.is_success() { + // Only retry on server errors (5xx); client errors (4xx) are permanent. + if status.is_server_error() && attempt < MAX_FETCH_RETRIES { + eprintln!( + "Warning: HTTP {} from {file_url} (attempt {}/{MAX_FETCH_RETRIES}), retrying...", + status, + attempt + 1, + ); + tokio::time::sleep( + std::time::Duration::from_millis(500 * (attempt + 1) as u64) + ).await; + attempt += 1; + continue; + } + return Err(AppError::Network( + format!("HTTP {status} from {file_url}") + )); } - Err(e) => Err(format!("Error reading response from {file_url}: {e}")), + match response.text().await { + Ok(content) => { + if content.is_empty() { + if attempt < MAX_FETCH_RETRIES { + eprintln!( + "Warning: empty response from {file_url} (attempt {}/{MAX_FETCH_RETRIES}), retrying...", + attempt + 1, + ); + tokio::time::sleep( + std::time::Duration::from_millis(500 * (attempt + 1) as u64) + ).await; + attempt += 1; + continue; + } + return Err(AppError::Network( + format!("Empty response from {file_url} after {MAX_FETCH_RETRIES} retries") + )); + } + fs::write(&tmp_path, &content).map_err(AppError::Io)?; + return fs::rename(&tmp_path, local_file_path).map_err(AppError::Io); + } + Err(e) => { + return Err(AppError::Network( + format!("Error reading response from {file_url}: {e}") + )); + } + } + } + Err(error) => { + if attempt < MAX_FETCH_RETRIES { + eprintln!( + "Warning: failed to fetch {file_url} (attempt {}/{MAX_FETCH_RETRIES}): {error}", + attempt + 1, + ); + tokio::time::sleep( + std::time::Duration::from_millis(500 * (attempt + 1) as u64) + ).await; + attempt += 1; + continue; + } + return Err(AppError::Network( + format!("Failed to fetch {file_url} after {MAX_FETCH_RETRIES} retries: {error}") + )); } } - Err(error) => Err(format!("Failed to fetch {file_url}: {error}")), } } @@ -343,3 +437,217 @@ pub fn translate_track_name(track_code: &str) -> String { .cloned() .unwrap_or_else(|| track_code.to_string()) } + +/// Escapes a field for safe inclusion in a CSV file. +/// +/// If the field contains a comma, quote, newline, or carriage return, +/// it is wrapped in double-quotes with internal quotes doubled. +pub fn escape_csv_field(field: &str) -> String { + if field.contains(',') || field.contains('"') || field.contains('\n') || field.contains('\r') { + format!("\"{}\"", field.replace('"', "\"\"")) + } else { + field.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_translate_track_name() { + assert_eq!(translate_track_name("laguna"), "Laguna Seca"); + assert_eq!(translate_track_name("mnCity"), "Monaco Streets"); + assert_eq!(translate_track_name("testBowl"), "Test Bowl"); + assert_eq!(translate_track_name("unknown_track"), "unknown_track"); + assert_eq!(translate_track_name("drag100"), "0-100mph"); + assert_eq!(translate_track_name("mile4"), "1/4 Mile Drag"); + } + + #[test] + fn test_translate_track_type() { + assert_eq!(translate_track_type("00"), "Dry"); + assert_eq!(translate_track_type("01"), "Wet"); + assert_eq!(translate_track_type("10"), "Dirt"); + assert_eq!(translate_track_type("11"), "Dirt Wet"); + assert_eq!(translate_track_type("30"), "Ice"); + assert_eq!(translate_track_type("60"), "Snow"); + assert_eq!(translate_track_type("99"), "99"); + } + + #[test] + fn test_track_name_completeness() { + let test_tracks = vec![ + "laguna", + "mnCity", + "testBowl", + "drag100", + "mile4", + "airplaneHangars", + "dealsGap", + "northloop", + ]; + + for track in test_tracks { + let name = translate_track_name(track); + assert_ne!(name, track, "Track {} should have a proper name mapping", track); + assert!(!name.is_empty(), "Track name should not be empty"); + } + } + + #[test] + fn test_condition_completeness() { + let test_conditions = vec!["00", "01", "10", "11", "20", "30", "40", "41", "50", "60"]; + + for condition in test_conditions { + let name = translate_track_type(condition); + assert_ne!(name, condition, "Condition {} should have a proper name mapping", condition); + assert!(!name.is_empty(), "Condition name should not be empty"); + } + } + + #[test] + fn test_compare_cars_ordering() { + let car_a = Car { + rid: "A".to_string(), brand: "BMW".to_string(), only_name: "M3".to_string(), + year: 2020, rq: 80, prize: false, guid: "guid_a".to_string(), + }; + let car_b = Car { + rid: "B".to_string(), brand: "Audi".to_string(), only_name: "RS6".to_string(), + year: 2021, rq: 90, prize: false, guid: "guid_b".to_string(), + }; + let car_c = Car { + rid: "C".to_string(), brand: "BMW".to_string(), only_name: "M5".to_string(), + year: 2022, rq: 80, prize: false, guid: "guid_c".to_string(), + }; + + // Lower RQ sorts first + assert_eq!(compare_cars(&car_a, &car_b), std::cmp::Ordering::Less); + // Same RQ, same brand, then model alphabetically (M3 < M5) + assert_eq!(compare_cars(&car_a, &car_c), std::cmp::Ordering::Less); + // Higher RQ sorts last + assert_eq!(compare_cars(&car_b, &car_a), std::cmp::Ordering::Greater); + } + + #[test] + fn test_compute_car_counts_basic() { + let ctx = AnalysisContext::new(); + let record = BestCarRecord { + car_rid: "Test_Car".to_string(), + brand: "TestBrand".to_string(), + only_name: "TestModel".to_string(), + year: 2020, + rq: 85, + config: "111".to_string(), + time: 100.0, + owned: true, + prize: false, + guid: "guid_test".to_string(), + }; + + // Manually insert into best_cars + { + ctx.best_cars.insert( + ("Laguna Seca".to_string(), 85, "Dry".to_string()), + vec![record.clone()], + ); + ctx.best_cars.insert( + ("Monaco Streets".to_string(), 85, "Dry".to_string()), + vec![record.clone()], + ); + } + + let car_list = vec![Car { + rid: "Test_Car".to_string(), + brand: "TestBrand".to_string(), + only_name: "TestModel".to_string(), + year: 2020, + rq: 85, + prize: false, + guid: "guid_test".to_string(), + }]; + + let counts = compute_car_counts(&ctx, car_list).unwrap(); + + // Should have exactly 1 entry in by_rid + assert_eq!(counts.by_rid.len(), 1); + let (rec, count) = counts.by_rid.get("Test_Car").unwrap(); + assert_eq!(count, &2); // best on 2 tracks + assert_eq!(rec.car_rid, "Test_Car"); + + // guid_to_rid should map guid โ†’ rid + assert_eq!(counts.guid_to_rid.get("guid_test"), Some(&"Test_Car".to_string())); + + // car_map should have the car + assert!(counts.car_map.contains_key("guid_test")); + assert_eq!(counts.car_list.len(), 1); + } + + #[test] + fn test_compute_car_counts_keeps_best_time() { + let ctx = AnalysisContext::new(); + + let record_fast = BestCarRecord { + car_rid: "Car_A".to_string(), + brand: "Brand".to_string(), + only_name: "Model".to_string(), + year: 2020, + rq: 85, + config: "111".to_string(), + time: 90.0, + owned: false, + prize: false, + guid: "guid_a".to_string(), + }; + let record_slow = BestCarRecord { + car_rid: "Car_A".to_string(), + brand: "Brand".to_string(), + only_name: "Model".to_string(), + year: 2020, + rq: 85, + config: "233".to_string(), + time: 110.0, + owned: false, + prize: false, + guid: "guid_a".to_string(), + }; + + { + ctx.best_cars.insert( + ("Track1".to_string(), 85, "Dry".to_string()), + vec![record_fast], + ); + ctx.best_cars.insert( + ("Track2".to_string(), 85, "Dry".to_string()), + vec![record_slow], + ); + } + + let counts = compute_car_counts(&ctx, vec![]).unwrap(); + let (rec, count) = counts.by_rid.get("Car_A").unwrap(); + assert_eq!(*count, 2); + assert_eq!(rec.time, 90.0); // Should keep the faster time + } + + #[test] + fn test_escape_csv_field() { + // Simple field โ€” no escaping needed + assert_eq!(escape_csv_field("hello"), "hello"); + // Field with comma + assert_eq!(escape_csv_field("hello,world"), "\"hello,world\""); + // Field with quote + assert_eq!(escape_csv_field("say \"hi\""), "\"say \"\"hi\"\"\""); + // Field with newline + assert_eq!(escape_csv_field("line1\nline2"), "\"line1\nline2\""); + } + + #[test] + fn test_load_my_cars_data_missing_file() { + let result = load_my_cars_data("nonexistent_file.json"); + assert!(result.is_err()); + match result.unwrap_err() { + AppError::MissingFile(msg) => assert!(msg.contains("nonexistent_file.json")), + other => panic!("Expected MissingFile error, got: {other}"), + } + } +} diff --git a/src/output/mod.rs b/src/output/mod.rs new file mode 100644 index 0000000..eb0bbef --- /dev/null +++ b/src/output/mod.rs @@ -0,0 +1,649 @@ +//! CSV output utilities and sorting helpers. +//! +//! Provides generic CSV writing infrastructure and comparison functions +//! used across multiple output commands. + +use std::collections::HashSet; +use std::fs; +use std::io::Write; + +use top_drives::{BestCarRecord, CarCounts, TripletKey, AnalysisContext, AppError, compare_cars, escape_csv_field}; + +// --------------------------------------------------------------------------- +// Sorting helpers +// --------------------------------------------------------------------------- + +/// Comparison function for sorting `BestCarRecord`s by RQ, then brand, then model name. +/// +/// Avoids cloning into a `Car` just for sorting โ€” uses the same ordering as +/// `compare_cars` but operates directly on `BestCarRecord`. +pub fn compare_records(a: &BestCarRecord, b: &BestCarRecord) -> std::cmp::Ordering { + a.rq.cmp(&b.rq) + .then(a.brand.cmp(&b.brand)) + .then(a.only_name.cmp(&b.only_name)) +} + +// --------------------------------------------------------------------------- +// CSV output helpers +// --------------------------------------------------------------------------- + +/// Writes a sorted CSV file with a header row. +/// +/// Generic helper that sorts entries by index (avoiding cloning), writes the header, +/// and formats each row. Returns `std::io::Result<()>` so callers can propagate errors +/// instead of panicking. +pub fn write_sorted_csv( + path: &str, + header: &str, + entries: &[T], + cmp: impl Fn(&T, &T) -> std::cmp::Ordering, + format_row: G, +) -> Result<(), AppError> +where + G: Fn(&T, &mut fs::File) -> std::io::Result<()>, +{ + let mut indices: Vec = (0..entries.len()).collect(); + indices.sort_by(|&a, &b| cmp(&entries[a], &entries[b])); + + let mut file = fs::File::create(path).map_err(AppError::Io)?; + writeln!(file, "{header}")?; + for i in indices { + format_row(&entries[i], &mut file)?; + } + Ok(()) +} + +/// Formats a `(BestCarRecord, usize)` row as `RQ,Brand,Model,Year,Count`. +/// Shared by `write_best_csv`, `write_very_best_csv`, and wishlist output functions. +/// Fields are properly CSV-escaped to handle commas or quotes in names. +pub fn format_counted_row(entry: &(BestCarRecord, usize), file: &mut fs::File) -> std::io::Result<()> { + let (rec, count) = entry; + writeln!( + file, + "{},{},{},{},{}", + rec.rq, + escape_csv_field(&rec.brand), + escape_csv_field(&rec.only_name), + rec.year, + count + ) +} + +// --------------------------------------------------------------------------- +// CSV output: best / very_best +// --------------------------------------------------------------------------- + +/// Writes best.csv โ€” all cars that are best on at least one track, sorted by RQ. +/// Format: RQ,Brand,Model,Year,Count +pub fn write_best_csv(counts: &CarCounts) -> Result<(), AppError> { + let entries: Vec<_> = counts.by_rid.values().collect(); + write_sorted_csv( + "best.csv", + "RQ,Brand,Model,Year,Count", + &entries, + |a, b| compare_records(&a.0, &b.0), + |entry, file| format_counted_row(entry, file), + ) +} + +/// Writes very_best.csv โ€” cars best on >= min_count tracks, sorted by RQ. +/// Format: RQ,Brand,Model,Year,Count +pub fn write_very_best_csv(counts: &CarCounts, min_count: usize) -> Result<(), AppError> { + let entries: Vec<_> = counts.by_rid.values() + .filter(|(_, count)| *count >= min_count) + .collect(); + write_sorted_csv( + "very_best.csv", + "RQ,Brand,Model,Year,Count", + &entries, + |a, b| compare_records(&a.0, &b.0), + |entry, file| format_counted_row(entry, file), + ) +} + +// --------------------------------------------------------------------------- +// CSV output: trash +// --------------------------------------------------------------------------- + +/// Writes trash.csv โ€” owned cars not best on any track, RQ < 80, not prize. +/// Format: RQ,Brand,Model,Year +pub fn write_trash_csv(counts: &CarCounts, owned: &HashSet) -> Result<(), AppError> { + let trash: Vec<_> = owned.iter() + .filter(|guid| !counts.guid_to_rid.contains_key(*guid)) + .filter_map(|guid| counts.car_map.get(guid)) + .filter(|car| !car.prize && car.rq < 80) + .collect(); + + write_sorted_csv( + "trash.csv", + "RQ,Brand,Model,Year", + &trash, + |a, b| compare_cars(a, b), + |car, file| writeln!( + file, + "{},{},{},{}", + car.rq, + escape_csv_field(&car.brand), + escape_csv_field(&car.only_name), + car.year + ), + ) +} + +// --------------------------------------------------------------------------- +// CSV output: details +// --------------------------------------------------------------------------- + +/// Writes detailed timing information for all tracks, conditions, and RQ levels to `details.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. +/// +/// # 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(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| (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))); + + let mut file = fs::File::create("details.csv")?; + writeln!(file, "RQ,TrackID,Condition,CarRQ,Brand,Model,Year,Config,Time")?; + for (key, records) in sorted_data { + for best_car in records { + writeln!( + file, + "{},{},{},{},{},{},{},{},{}", + key.1, // RQ level + escape_csv_field(&key.0), // Track ID + escape_csv_field(&key.2), // Surface condition + best_car.rq, // Car's RQ + escape_csv_field(&best_car.brand), // Car brand + escape_csv_field(&best_car.only_name), // Car model + best_car.year, // Car year + best_car.config, // Tune configuration + best_car.time // Track time + )?; + } + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Wishlist logic +// --------------------------------------------------------------------------- + +/// Computes the ideal wishlist: very_best unowned cars, capped at max_wishlist, sorted by RQ. +pub fn compute_ideal_wishlist( + counts: &CarCounts, + owned: &HashSet, + min_count: usize, + max_wishlist: usize, +) -> Vec<(BestCarRecord, usize)> { + let mut candidates: Vec<_> = counts.by_rid.values() + .filter(|(record, count)| *count >= min_count && !owned.contains(&record.guid)) + .cloned() + .collect(); + candidates.sort_by(|a, b| compare_records(&a.0, &b.0)); + candidates.truncate(max_wishlist); + candidates +} + +/// Writes wishlist.csv, add.csv, and remove.csv from a single computed ideal wishlist. +/// +/// Computes the ideal wishlist once and derives all three output files from it, +/// avoiding redundant computation. +pub fn write_wishlist_files( + counts: &CarCounts, + owned: &HashSet, + wishlist: &HashSet, + min_count: usize, + max_wishlist: usize, +) -> Result<(), AppError> { + let ideal = compute_ideal_wishlist(counts, owned, min_count, max_wishlist); + let ideal_guids: HashSet<_> = ideal.iter().map(|(r, _)| r.guid.clone()).collect(); + + // --- wishlist.csv --- + write_sorted_csv( + "wishlist.csv", + "RQ,Brand,Model,Year,Count", + &ideal, + |a, b| compare_records(&a.0, &b.0), + format_counted_row, + )?; + + // --- add.csv (ideal cars not yet on the wishlist) --- + let to_add: Vec<_> = ideal.iter() + .filter(|(record, _)| !wishlist.contains(&record.guid)) + .collect(); + + write_sorted_csv( + "add.csv", + "RQ,Brand,Model,Year,Count", + &to_add, + |a, b| compare_records(&a.0, &b.0), + |entry, file| format_counted_row(entry, file), + )?; + + // --- remove.csv (wishlist cars not in the ideal set) --- + // Single pass: collect found entries and count dropped ones together. + let mut dropped_count = 0usize; + let to_remove: Vec<_> = wishlist.iter() + .filter(|guid| !ideal_guids.contains(*guid)) + .filter_map(|guid| { + if let Some(rid) = counts.guid_to_rid.get(guid) { + counts.by_rid.get(rid).cloned() + } else { + dropped_count += 1; + None + } + }) + .collect(); + if dropped_count > 0 { + eprintln!( + "Warning: {dropped_count} wishlist car(s) not found in best cars data and omitted from remove.csv" + ); + } + + write_sorted_csv( + "remove.csv", + "RQ,Brand,Model,Year,Count", + &to_remove, + |a, b| compare_records(&a.0, &b.0), + format_counted_row, + )?; + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Stdout output: track-specific query +// --------------------------------------------------------------------------- + +/// Finds and displays the best performing car for a specific track, condition, and RQ level +/// +/// Provides targeted recommendations for specific racing scenarios. Useful for determining +/// the optimal car choice when you need to win a particular race or meet specific requirements. +/// +/// # Arguments +/// +/// * `ctx` - Analysis context containing the best cars data +/// * `track` - The track identifier (e.g., "laguna", "monaco") +/// * `condition` - Surface condition (e.g., "Dry", "Wet", "Dirt") +/// * `rq` - RQ level limit (cars with this RQ or lower will be considered) +pub fn print_best_car(ctx: &AnalysisContext, track: &str, condition: &str, rq: u8) -> Result<(), AppError> { + let key = (track.to_string(), rq, condition.to_string()); + + if let Some(best_list) = ctx.best_cars.get(&key) { + for best_car in best_list.value() { + println!( + "Best car for Track: {}, Condition: {}, RQ: {} -> {} {} ({}) with time {:.2}", + track, + condition, + rq, + best_car.brand, + best_car.only_name, + best_car.year, + best_car.time + ); + } + } else { + println!( + "No best car found for Track: {}, Condition: {}, RQ: {}", + track, condition, rq + ); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + use top_drives::{Car, compute_car_counts}; + + // -- Helper: create a test BestCarRecord -- + fn make_record(car_rid: &str, rq: u8, time: f64, guid: &str) -> BestCarRecord { + BestCarRecord { + car_rid: car_rid.to_string(), + brand: "TestBrand".to_string(), + only_name: "TestModel".to_string(), + year: 2020, + rq, + config: "111".to_string(), + time, + owned: false, + prize: false, + guid: guid.to_string(), + } + } + + // -- Helper: create a test Car -- + fn make_car(rid: &str, rq: u8, guid: &str) -> Car { + Car { + rid: rid.to_string(), + brand: "TestBrand".to_string(), + only_name: "TestModel".to_string(), + year: 2020, + rq, + prize: false, + guid: guid.to_string(), + } + } + + // ======================================================================= + // compute_ideal_wishlist tests + // ======================================================================= + + #[test] + fn test_compute_ideal_wishlist_basic() { + let ctx = AnalysisContext::new(); + let rec = make_record("Car_A", 85, 100.0, "guid_a"); + + { + for i in 0..15 { + ctx.best_cars.insert( + (format!("Track{i}"), 85, "Dry".to_string()), + vec![rec.clone()], + ); + } + } + + let car_list = vec![make_car("Car_A", 85, "guid_a")]; + let counts = compute_car_counts(&ctx, car_list).unwrap(); + + let owned: HashSet = HashSet::new(); + let wishlist = compute_ideal_wishlist(&counts, &owned, 10, 300); + + assert_eq!(wishlist.len(), 1); + assert_eq!(wishlist[0].0.car_rid, "Car_A"); + assert!(wishlist[0].1 >= 10); + } + + #[test] + fn test_compute_ideal_wishlist_excludes_owned() { + let ctx = AnalysisContext::new(); + let rec = make_record("Car_A", 85, 100.0, "guid_a"); + + { + for i in 0..15 { + ctx.best_cars.insert( + (format!("Track{i}"), 85, "Dry".to_string()), + vec![rec.clone()], + ); + } + } + + let car_list = vec![make_car("Car_A", 85, "guid_a")]; + let counts = compute_car_counts(&ctx, car_list).unwrap(); + + let owned: HashSet = vec!["guid_a".to_string()].into_iter().collect(); + let wishlist = compute_ideal_wishlist(&counts, &owned, 10, 300); + + assert_eq!(wishlist.len(), 0, "Owned cars should be excluded from wishlist"); + } + + #[test] + fn test_compute_ideal_wishlist_respects_max() { + let ctx = AnalysisContext::new(); + + { + for i in 0..15 { + for j in 0..5u8 { + let rec = make_record( + &format!("Car_{j}"), + 80 + j, + 100.0 + j as f64, + &format!("guid_{j}"), + ); + ctx.best_cars.insert( + (format!("Track{i}"), 80 + j, "Dry".to_string()), + vec![rec], + ); + } + } + } + + let car_list: Vec = (0..5u8) + .map(|j| make_car(&format!("Car_{j}"), 80 + j, &format!("guid_{j}"))) + .collect(); + let counts = compute_car_counts(&ctx, car_list).unwrap(); + + let owned: HashSet = HashSet::new(); + let wishlist = compute_ideal_wishlist(&counts, &owned, 10, 3); + + assert!(wishlist.len() <= 3, "Wishlist should be capped at max_wishlist"); + } + + // ======================================================================= + // CSV output tests + // ======================================================================= + + #[test] + fn test_write_sorted_csv_basic() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("test.csv"); + let path_str = path.to_str().unwrap(); + + let entries = vec![("beta", 2), ("alpha", 1), ("gamma", 3)]; + + write_sorted_csv( + path_str, + "Name,Value", + &entries, + |a, b| a.0.cmp(b.0), + |entry, file| writeln!(file, "{},{}", entry.0, entry.1), + ).unwrap(); + + let content = fs::read_to_string(path).unwrap(); + let lines: Vec<&str> = content.lines().collect(); + assert_eq!(lines[0], "Name,Value"); + assert_eq!(lines[1], "alpha,1"); + assert_eq!(lines[2], "beta,2"); + assert_eq!(lines[3], "gamma,3"); + } + + #[test] + fn test_format_counted_row_escaping() { + let rec = BestCarRecord { + car_rid: "Test".to_string(), + brand: "Brand, Inc.".to_string(), + only_name: "Model \"Sport\"".to_string(), + year: 2020, + rq: 85, + config: "111".to_string(), + time: 100.0, + owned: false, + prize: false, + guid: "guid".to_string(), + }; + let entry = (rec, 5); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("test.csv"); + let path_str = path.to_str().unwrap(); + + write_sorted_csv( + path_str, + "RQ,Brand,Model,Year,Count", + &[entry], + |_, _| std::cmp::Ordering::Equal, + |e, file| format_counted_row(e, file), + ).unwrap(); + + let content = fs::read_to_string(path).unwrap(); + let lines: Vec<&str> = content.lines().collect(); + assert_eq!(lines[1], "85,\"Brand, Inc.\",\"Model \"\"Sport\"\"\",2020,5"); + } + + #[test] + fn test_write_trash_csv_filters_correctly() { + let ctx = AnalysisContext::new(); + + // Car_A is best on a track, Car_B is not + let rec_a = make_record("Car_A", 70, 100.0, "guid_a"); + { + ctx.best_cars.insert( + ("Track1".to_string(), 70, "Dry".to_string()), + vec![rec_a], + ); + } + + // Make Car_D a prize car + let mut car_d = make_car("Car_D", 50, "guid_d"); + car_d.prize = true; + + let car_list = vec![ + make_car("Car_A", 70, "guid_a"), // best on a track โ€” not trash + make_car("Car_B", 60, "guid_b"), // not best, RQ < 80 โ€” trash + make_car("Car_C", 90, "guid_c"), // not best, but RQ >= 80 โ€” not trash + car_d, // not best, prize car โ€” not trash + ]; + + let counts = compute_car_counts(&ctx, car_list).unwrap(); + + let owned: HashSet = vec![ + "guid_a".to_string(), + "guid_b".to_string(), + "guid_c".to_string(), + "guid_d".to_string(), + ].into_iter().collect(); + + // Test the filtering logic directly (same logic as write_trash_csv) + let trash: Vec<_> = owned.iter() + .filter(|guid| !counts.guid_to_rid.contains_key(*guid)) + .filter_map(|guid| counts.car_map.get(guid)) + .filter(|car| !car.prize && car.rq < 80) + .collect(); + + assert_eq!(trash.len(), 1, "Only Car_B should be trash"); + assert_eq!(trash[0].rid, "Car_B"); + } + + // ======================================================================= + // compare_records tests + // ======================================================================= + + #[test] + fn test_compare_records_by_rq() { + let a = make_record("A", 80, 100.0, "guid_a"); + let b = make_record("B", 90, 100.0, "guid_b"); + assert_eq!(compare_records(&a, &b), std::cmp::Ordering::Less); + assert_eq!(compare_records(&b, &a), std::cmp::Ordering::Greater); + } + + #[test] + fn test_compare_records_by_brand_then_model() { + let a = BestCarRecord { + car_rid: "A".to_string(), + brand: "Audi".to_string(), + only_name: "RS6".to_string(), + year: 2020, + rq: 85, + config: "111".to_string(), + time: 100.0, + owned: false, + prize: false, + guid: "guid_a".to_string(), + }; + let b = BestCarRecord { + car_rid: "B".to_string(), + brand: "BMW".to_string(), + only_name: "M3".to_string(), + year: 2020, + rq: 85, + config: "111".to_string(), + time: 100.0, + owned: false, + prize: false, + guid: "guid_b".to_string(), + }; + assert_eq!(compare_records(&a, &b), std::cmp::Ordering::Less); + } + + // ======================================================================= + // Edge case tests + // ======================================================================= + + #[test] + fn test_empty_analysis_context() { + let ctx = AnalysisContext::new(); + let counts = compute_car_counts(&ctx, vec![]).unwrap(); + 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] + fn test_single_car_analysis() { + let ctx = AnalysisContext::new(); + let rec = make_record("Only_Car", 90, 88.5, "guid_only"); + + ctx.best_cars.insert( + ("Track1".to_string(), 90, "Dry".to_string()), + vec![rec], + ); + + let car = make_car("Only_Car", 90, "guid_only"); + let counts = compute_car_counts(&ctx, vec![car]).unwrap(); + + assert_eq!(counts.by_rid.len(), 1); + let (rec, count) = counts.by_rid.get("Only_Car").unwrap(); + assert_eq!(*count, 1); + assert_eq!(rec.car_rid, "Only_Car"); + } + + #[test] + fn test_all_cars_owned() { + let ctx = AnalysisContext::new(); + let rec = make_record("Car_A", 85, 100.0, "guid_a"); + + for i in 0..15 { + ctx.best_cars.insert( + (format!("Track{i}"), 85, "Dry".to_string()), + vec![rec.clone()], + ); + } + + let car = make_car("Car_A", 85, "guid_a"); + let counts = compute_car_counts(&ctx, vec![car]).unwrap(); + + let all_owned: HashSet = vec!["guid_a".to_string()].into_iter().collect(); + let wishlist = compute_ideal_wishlist(&counts, &all_owned, 10, 300); + assert_eq!(wishlist.len(), 0, "All owned โ†’ empty wishlist"); + } + + #[test] + fn test_no_cars_owned() { + let ctx = AnalysisContext::new(); + let rec = make_record("Car_A", 85, 100.0, "guid_a"); + + for i in 0..15 { + ctx.best_cars.insert( + (format!("Track{i}"), 85, "Dry".to_string()), + vec![rec.clone()], + ); + } + + let car = make_car("Car_A", 85, "guid_a"); + let counts = compute_car_counts(&ctx, vec![car]).unwrap(); + + let no_owned: HashSet = HashSet::new(); + let wishlist = compute_ideal_wishlist(&counts, &no_owned, 10, 300); + assert_eq!(wishlist.len(), 1, "No owned โ†’ wishlist includes the car"); + } +} diff --git a/src/parsing/car_list.rs b/src/parsing/car_list.rs new file mode 100644 index 0000000..245166e --- /dev/null +++ b/src/parsing/car_list.rs @@ -0,0 +1,47 @@ +//! Car database loading and parsing. +//! +//! Handles reading the car database JSON file with serde deserialization. + +use std::fs; + +use top_drives::{AppError, Car}; + +/// Loads the complete car database from the local repository file. +/// +/// Returns a `Vec` containing all cars from the database. +/// Returns an `AppError` if the file is missing or cannot be parsed. +pub fn get_car_list(cars_db_path: &str) -> Result, AppError> { + let content = fs::read_to_string(cars_db_path).map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + AppError::MissingFile(format!("{cars_db_path}: {e}")) + } else { + AppError::Io(e) + } + })?; + + serde_json::from_str(&content).map_err(|e| AppError::Parse { + file: cars_db_path.to_string(), + reason: e.to_string(), + }) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_car_list_missing_file() { + let result = get_car_list("nonexistent_cars_db.json"); + assert!(result.is_err(), "Should return error for missing file"); + match result.unwrap_err() { + AppError::MissingFile(msg) => { + assert!(msg.contains("nonexistent_cars_db.json"), "Error should mention the file path"); + } + other => panic!("Expected MissingFile error, got: {other}"), + } + } +} diff --git a/src/parsing/mod.rs b/src/parsing/mod.rs new file mode 100644 index 0000000..468de6d --- /dev/null +++ b/src/parsing/mod.rs @@ -0,0 +1,652 @@ +//! Core parsing logic for car timing data. +//! +//! Handles per-car JSON timing file parsing, best-car record updates, +//! and orchestrated concurrent parsing of all timing files. + +pub mod car_list; + +use std::collections::HashSet; +use std::fs; +use std::sync::Arc; + +use dashmap::DashMap; +use futures::future::join_all; +use serde_json::Value; +use tokio::sync::Semaphore; +use tokio::task; + +use top_drives::{ + AppError, AnalysisContext, BestCarRecord, Car, TripletKey, + translate_track_name, translate_track_type, +}; + +use self::car_list::get_car_list; + +// --------------------------------------------------------------------------- +// Constants (shared across parsing modules) +// --------------------------------------------------------------------------- + +/// Maximum RQ level in the Top Drives database. +/// Used as the upper bound when iterating RQ ranges and as a fallback for parse errors. +pub const MAX_RQ: u8 = 113; + +/// Minimum valid track time in seconds. Times below this threshold are discarded. +pub const MIN_VALID_TIME: f64 = 1.2; + +/// Tolerance for considering two times as equal (5ms). +pub const TIME_TOLERANCE: f64 = 0.005; + +/// Multiplier constant for Test Bowl time inversion. +/// Test Bowl tracks use speed rather than time, so we invert: `FACTOR / time`. +pub const TEST_BOWL_FACTOR: f64 = 2000.0; + +/// Maximum number of concurrent download/parse tasks. +/// +/// IMPORTANT: Keep โ‰ค 50 to avoid exhausting tokio's default blocking thread pool (512 threads), +/// since each concurrent permit holder also occupies a `spawn_blocking` thread. +pub const CONCURRENCY_LIMIT: usize = 20; + +// --------------------------------------------------------------------------- +// Core parsing: per-car timing data +// --------------------------------------------------------------------------- + +/// Parses timing data for a single car from its JSON file. +/// +/// **Blocking** โ€” performs synchronous file I/O and CPU-intensive JSON parsing. +/// Must be called from [`tokio::task::spawn_blocking`], never directly from an async context, +/// as it would block the tokio runtime thread. +/// +/// This is the core function responsible for processing individual car performance data. +/// It reads the car's JSON file, extracts timing information for each track and condition, +/// filters out invalid or downvoted times, and updates the analysis context. +/// +/// # Arguments +/// +/// * `car` - The car data structure containing metadata about the car to process +/// * `my_cars` - Set of GUIDs the user owns (used to mark ownership) +/// * `best_cars` - Shared concurrent map of best car records (`DashMap`) +/// * `times_dir` - Directory containing per-car timing JSON files +pub fn parse_car_times( + car: Car, + my_cars: &HashSet, + best_cars: &DashMap>, + times_dir: &str, +) -> Result<(), AppError> { + // Construct file path for this car's timing data + let file_name = car.rid.to_owned() + ".json"; + let local_file_path = format!("{times_dir}/{file_name}"); + + // Read file content + let file_content = match fs::read_to_string(&local_file_path) { + Ok(content) => content, + Err(e) => { + // File not found is expected for some cars โ€” treat as non-fatal + eprintln!("Warning: could not read {local_file_path}: {e}"); + return Ok(()); + } + }; + + // Parse JSON data + let json_data: Value = match serde_json::from_str(&file_content) { + Ok(v) => v, + Err(e) => { + return Err(AppError::Parse { + file: local_file_path, + reason: e.to_string(), + }); + } + }; + + // Process each configuration variant (111, 233, 323, etc.) + if let Some(configurations) = json_data["data"].as_object() { + for (config, entry) in configurations { + // Process timing data for each track + 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 mut time = details["t"].as_f64().unwrap_or(0.0); + + let down_votes = details["down"].as_array(); + let no_down_votes = match down_votes { + None => true, + Some(votes) => votes.is_empty(), + }; + + if track_id.starts_with("testBowl") { + if time > 0.0 { + time = TEST_BOWL_FACTOR / time; + } else { + continue; // skip invalid zero time + } + } + + let owned = my_cars.contains(&car.guid); + + if time > MIN_VALID_TIME && no_down_votes { + let track_name = translate_track_name(cleaned_track_id); + let surface = translate_track_type(track_type); + + update_best_car( + best_cars, + &track_name, + car.rq, + surface, + BestCarRecord { + car_rid: car.rid.clone(), + brand: car.brand.clone(), + only_name: car.only_name.clone(), + year: car.year, + rq: car.rq, + config: config.clone(), + time, + owned, + prize: car.prize, + guid: car.guid.clone(), + }, + ); + } + } + } + } + } + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Core parsing: update best car records +// --------------------------------------------------------------------------- + +/// Updates the best cars database with a new performance record. +/// +/// Implements the core logic for determining the best performing cars across different +/// RQ ranges. For each RQ level from the car's base RQ up to MAX_RQ, this +/// function determines whether the new record represents an improvement or tie. +/// +/// # Allocation Optimization +/// +/// The record is cloned at most once (for the first insertion), and subsequent +/// RQ levels reuse the entry via `DashMap`'s `entry()` API, avoiding per-RQ +/// clone overhead for the common "insert new" case. +/// +/// String keys (`track_id`, `surface`) are pre-allocated once and cloned per +/// iteration, avoiding repeated `to_string()` formatting overhead. +pub fn update_best_car( + best_cars: &DashMap>, + track_id: &str, + car_rq: u8, + surface: &str, + new_record: BestCarRecord, +) { + let track_id_owned = track_id.to_string(); + let surface_owned = surface.to_string(); + + for rq in car_rq..=MAX_RQ { + let is_last = rq == MAX_RQ; + let key = (track_id_owned.clone(), rq, surface_owned.clone()); + + match best_cars.entry(key) { + dashmap::mapref::entry::Entry::Occupied(mut entry) => { + let existing_list = entry.get_mut(); + let best_time = existing_list.first().map(|r| r.time).unwrap_or(f64::MAX); + + if new_record.time < best_time { + if is_last { + *existing_list = vec![new_record]; + return; + } else { + *existing_list = vec![new_record.clone()]; + } + } else if (new_record.time - best_time).abs() < TIME_TOLERANCE + && !existing_list + .iter() + .any(|r| r.car_rid == new_record.car_rid) + { + if is_last { + existing_list.push(new_record); + return; + } else { + existing_list.push(new_record.clone()); + } + } + // else: record is discarded for this RQ โ€” no clone needed + } + dashmap::mapref::entry::Entry::Vacant(entry) => { + if is_last { + entry.insert(vec![new_record]); + return; + } else { + entry.insert(vec![new_record.clone()]); + } + } + } + } +} + +// --------------------------------------------------------------------------- +// Orchestrated parsing +// --------------------------------------------------------------------------- + +/// Orchestrates concurrent parsing of all car timing files. +/// +/// Spawns a separate async task for each car in the database. The semaphore is +/// acquired in the async context (avoiding `block_on` inside `spawn_blocking`, +/// which risks deadlocking the blocking thread pool), then the synchronous +/// parsing work runs inside `spawn_blocking` while the permit is held. +/// +/// Takes `Arc>` to avoid cloning the entire owned-car set on +/// every call. +pub async fn parse_all_times( + my_cars: Arc>, + ctx: &AnalysisContext, + cars_db_path: &str, + times_dir: &str, +) -> Result, AppError> { + let car_list = get_car_list(cars_db_path)?; + let mut handles = vec![]; + + let semaphore = Arc::new(Semaphore::new(CONCURRENCY_LIMIT)); + let best_cars = Arc::clone(&ctx.best_cars); + + for car in car_list.iter() { + let my_cars = Arc::clone(&my_cars); + let best_cars = Arc::clone(&best_cars); + let car = car.clone(); + let sem = Arc::clone(&semaphore); + let times_dir = times_dir.to_string(); + let handle = task::spawn(async move { + // Acquire the semaphore asynchronously โ€” propagate error if closed. + let _permit = match sem.acquire().await { + Ok(p) => p, + Err(_) => return Err(AppError::Internal("Semaphore closed".into())), + }; + // Run the synchronous parsing on a blocking thread while holding the permit. + task::spawn_blocking(move || { + parse_car_times(car, &my_cars, &best_cars, ×_dir) + }).await.unwrap_or_else(|e| Err(AppError::Internal(format!("Blocking task join error: {e}")))) + }); + handles.push(handle); + } + + let results = join_all(handles).await; + let mut parse_errors = 0usize; + for result in results { + match result { + // Outer JoinError from tokio::spawn + Err(e) => { + eprintln!("Warning: parsing task failed: {e}"); + parse_errors += 1; + } + // parse_car_times or semaphore returned an error + Ok(Err(e)) => { + eprintln!("Warning: {e}"); + parse_errors += 1; + } + // parse_car_times succeeded + Ok(Ok(())) => {} + } + } + if parse_errors > 0 { + eprintln!("Warning: {parse_errors} car(s) had parse errors during analysis"); + } + + // If every car failed to parse, the data is unusable โ€” propagate the error. + if parse_errors == car_list.len() && !car_list.is_empty() { + return Err(AppError::Parse { + file: times_dir.to_string(), + reason: format!("All {parse_errors} car(s) failed to parse โ€” data directory may be corrupted"), + }); + } + + Ok(car_list) +} + +// --------------------------------------------------------------------------- +// Data fetching +// --------------------------------------------------------------------------- + +/// Remote URL for the per-car timing API. +const CAR_TIMING_API_BASE: &str = "https://api.topdrivesrecords.com/car/"; + +/// Fetches timing data files for all cars using controlled concurrency. +/// +/// 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 semaphore = Arc::new(Semaphore::new(CONCURRENCY_LIMIT)); + let mut handles = vec![]; + + 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(); + handles.push(tokio::spawn(async move { + let _permit = permit; // held until end of block + fetch_car(&car.rid, ×_dir).await + })); + } + + let results = join_all(handles).await; + let mut first_error: Option = None; + for result in results { + match result { + Ok(Ok(())) => {} + Ok(Err(e)) => { + eprintln!("{e}"); + if first_error.is_none() { first_error = Some(e); } + } + Err(e) => { + let msg = AppError::Network(format!("Task join error: {e}")); + eprintln!("{msg}"); + if first_error.is_none() { first_error = Some(msg); } + } + } + } + first_error.map_or(Ok(()), Err) +} + +/// Downloads timing data for a single car from the remote API. +async fn fetch_car(rid: &str, times_dir: &str) -> Result<(), AppError> { + let file_name = rid.to_owned() + ".json"; + let url = format!("{CAR_TIMING_API_BASE}{rid}"); + let local_file_path = format!("{times_dir}/{file_name}"); + println!("Fetching {url}."); + top_drives::get_file(&url, &local_file_path).await +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + // -- Helper: create a test BestCarRecord -- + fn make_record(car_rid: &str, rq: u8, time: f64, guid: &str) -> BestCarRecord { + BestCarRecord { + car_rid: car_rid.to_string(), + brand: "TestBrand".to_string(), + only_name: "TestModel".to_string(), + year: 2020, + rq, + config: "111".to_string(), + time, + owned: false, + prize: false, + guid: guid.to_string(), + } + } + + // ======================================================================= + // update_best_car tests + // ======================================================================= + + #[test] + fn test_update_best_car_inserts_new_record() { + let ctx = AnalysisContext::new(); + let record = make_record("Car_A", 85, 100.0, "guid_a"); + + update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record); + + let key = ("laguna".to_string(), 85, "Dry".to_string()); + assert!(ctx.best_cars.contains_key(&key)); + let records = ctx.best_cars.get(&key).unwrap(); + assert_eq!(records.len(), 1); + assert_eq!(records[0].car_rid, "Car_A"); + } + + #[test] + fn test_update_best_car_replaces_slower_record() { + let ctx = AnalysisContext::new(); + + let slow = make_record("Car_A", 85, 110.0, "guid_a"); + let fast = make_record("Car_B", 85, 95.0, "guid_b"); + + update_best_car(&ctx.best_cars, "laguna", 85, "Dry", slow); + update_best_car(&ctx.best_cars, "laguna", 85, "Dry", fast); + + let records = ctx.best_cars.get(&("laguna".to_string(), 85, "Dry".to_string())).unwrap(); + assert_eq!(records.len(), 1); + assert_eq!(records[0].car_rid, "Car_B"); // faster car wins + } + + #[test] + fn test_update_best_car_tie_within_tolerance() { + let ctx = AnalysisContext::new(); + + let record1 = make_record("Car_A", 85, 100.0, "guid_a"); + let record2 = make_record("Car_B", 85, 100.003, "guid_b"); // within 0.005 + + update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record1); + update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record2); + + let records = ctx.best_cars.get(&("laguna".to_string(), 85, "Dry".to_string())).unwrap(); + assert_eq!(records.len(), 2, "Tied records should both be kept"); + assert!(records.iter().any(|r| r.car_rid == "Car_A")); + assert!(records.iter().any(|r| r.car_rid == "Car_B")); + } + + #[test] + fn test_update_best_car_outside_tolerance_replaces() { + let ctx = AnalysisContext::new(); + + let record1 = make_record("Car_A", 85, 100.0, "guid_a"); + let record3 = make_record("Car_C", 85, 100.006, "guid_c"); // outside 0.005 + + update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record1); + update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record3); + + let records = ctx.best_cars.get(&("laguna".to_string(), 85, "Dry".to_string())).unwrap(); + assert_eq!(records.len(), 1, "Record outside tolerance should not tie"); + assert_eq!(records[0].car_rid, "Car_A"); // original kept (same best time) + } + + #[test] + fn test_update_best_car_rq_range_propagation() { + let ctx = AnalysisContext::new(); + let record = make_record("Car_A", 85, 100.0, "guid_a"); + + update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record); + + for rq in 85..=MAX_RQ { + let key = ("laguna".to_string(), rq, "Dry".to_string()); + assert!( + ctx.best_cars.contains_key(&key), + "Car should appear at RQ level {rq}" + ); + } + // Should NOT appear below its RQ + let key_below = ("laguna".to_string(), 84, "Dry".to_string()); + assert!(!ctx.best_cars.contains_key(&key_below)); + } + + #[test] + fn test_update_best_car_duplicate_rid_not_added_in_tie() { + let ctx = AnalysisContext::new(); + + let record1 = make_record("Car_A", 85, 100.0, "guid_a"); + let record2 = make_record("Car_A", 85, 100.001, "guid_a"); // same car, within tolerance + + update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record1); + update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record2); + + let records = ctx.best_cars.get(&("laguna".to_string(), 85, "Dry".to_string())).unwrap(); + assert_eq!(records.len(), 1, "Same car_rid should not appear twice in tie list"); + } + + // ======================================================================= + // parse_car_times tests + // ======================================================================= + + #[test] + fn test_parse_car_times_valid_json() { + 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(), + }; + + // Create a temporary directory and timing file + let dir = tempfile::tempdir().unwrap(); + let times_dir = dir.path().to_str().unwrap(); + let file_path = dir.path().join("TestCar_2020.json"); + + let json_content = r#"{ + "data": { + "111": { + "times": { + "laguna_a00": { "t": 112.45, "u": "Player1" }, + "mnCity_a01": { "t": 45.23, "u": "Player2" } + } + } + } + }"#; + 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(); + + // Should have entries for RQ 85..=113 for both tracks + 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()))); + } + + #[test] + fn test_parse_car_times_filters_downvoted() { + 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"); + + let json_content = r#"{ + "data": { + "111": { + "times": { + "laguna_a00": { "t": 112.45, "u": "Player1", "down": ["Player3"] } + } + } + } + }"#; + 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(); + + // Downvoted time should be filtered out + assert!( + !ctx.best_cars.contains_key(&("Laguna Seca".to_string(), 85, "Dry".to_string())), + "Downvoted times should be excluded" + ); + } + + #[test] + fn test_parse_car_times_test_bowl_inversion() { + 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"); + + let json_content = r#"{ + "data": { + "111": { + "times": { + "testBowl_a00": { "t": 50.0, "u": "Player1" } + } + } + } + }"#; + 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(); + + // Test Bowl time should be inverted: 2000.0 / 50.0 = 40.0 + let records = ctx + .best_cars + .get(&("Test Bowl".to_string(), 85, "Dry".to_string())) + .unwrap(); + assert_eq!(records[0].time, 40.0); + } + + #[test] + fn test_parse_car_times_missing_file() { + let ctx = AnalysisContext::new(); + let car = Car { + rid: "NonExistent".to_string(), + brand: "TestBrand".to_string(), + only_name: "TestModel".to_string(), + year: 2020, + rq: 85, + prize: false, + guid: "guid_test".to_string(), + }; + + let my_cars: HashSet = HashSet::new(); + // Should not panic โ€” just print a warning and return Ok(()) + parse_car_times(car, &my_cars, &ctx.best_cars, "/nonexistent/times").unwrap(); + + assert_eq!(ctx.best_cars.len(), 0, "No entries for missing file"); + } + + // ======================================================================= + // Track name / type tests (from lib) + // ======================================================================= + + #[test] + fn test_translate_track_name_known_tracks() { + assert_eq!(translate_track_name("laguna"), "Laguna Seca"); + assert_eq!(translate_track_name("mnCity"), "Monaco Streets"); + assert_eq!(translate_track_name("testBowl"), "Test Bowl"); + assert_eq!(translate_track_name("unknown_track"), "unknown_track"); + } + + #[test] + fn test_translate_track_type_known_conditions() { + assert_eq!(translate_track_type("00"), "Dry"); + assert_eq!(translate_track_type("01"), "Wet"); + assert_eq!(translate_track_type("10"), "Dirt"); + assert_eq!(translate_track_type("99"), "99"); + } + + // ======================================================================= + // Constants tests + // ======================================================================= + + #[test] + fn test_constants_are_reasonable() { + assert_eq!(MAX_RQ, 113); + assert!(MIN_VALID_TIME > 0.0); + assert!(TIME_TOLERANCE > 0.0); + assert!(TEST_BOWL_FACTOR > 0.0); + assert!(CONCURRENCY_LIMIT > 0); + } +} diff --git a/src/records.rs b/src/records.rs index 5f6f3b9..311327c 100644 --- a/src/records.rs +++ b/src/records.rs @@ -1,8 +1,14 @@ //! # Top Drives Records Analyzer //! //! A comprehensive Rust-based tool for analyzing car performance data in the Top Drives racing game. -//! This module provides functionality to fetch, parse, and analyze track timing data from a -//! community-maintained repository. +//! This module provides the CLI entry point and delegates to submodules for parsing, output, and +//! command handling. +//! +//! ## Module Structure +//! +//! - [`commands`] โ€” Command handlers (fetch, best, trash, wishlist, details, track) +//! - [`parsing`] โ€” Core timing data parsing and car database loading +//! - [`output`] โ€” CSV output utilities and sorting helpers //! //! ## Key Features //! @@ -26,34 +32,56 @@ //! - `serde_json`: JSON parsing for car and track data //! - `tokio`: Asynchronous runtime for concurrent processing //! - `futures`: Future utilities for handling async operations +//! - `clap`: Command-line argument parsing -use std::collections::{HashMap, HashSet}; -use std::fs; -use std::io::Write; -use std::sync::Arc; +mod commands; +mod output; +mod parsing; use clap::{Parser, Subcommand}; -use tokio::sync::Semaphore; -use tokio::task; -use futures::future::join_all; +use top_drives::AppError; -use serde_json::Value; +// --------------------------------------------------------------------------- +// Constants (CLI defaults) +// --------------------------------------------------------------------------- -use top_drives::{ - AnalysisContext, BestCarRecord, Car, CarCounts, TripletKey, - compare_cars, compute_car_counts, get_file, load_my_cars_data, - translate_track_name, translate_track_type, -}; +/// Default path for the user's car collection export. +const DEFAULT_MY_CARS_PATH: &str = "my_cars.json"; -/// Maximum RQ level in the Top Drives database. -/// Used as the upper bound when iterating RQ ranges and as a fallback for parse errors. -const MAX_RQ: u8 = 113; +/// Default path for the car database file. +const DEFAULT_CARS_DB_PATH: &str = "cars_repo.json"; + +/// Default directory for per-car timing JSON files. +const DEFAULT_TIMES_DIR: &str = "times"; + +/// Default path for the tracks database file. +const DEFAULT_TRACKS_DB_PATH: &str = "tracks_repo.json"; + +// --------------------------------------------------------------------------- +// CLI definition +// --------------------------------------------------------------------------- /// Command-line interface for the Top Drives Records Analyzer. #[derive(Parser)] #[command(name = "records", about = "Top Drives Records Analyzer", version)] struct Cli { + /// Path to the user's car collection export file + #[arg(long, default_value = DEFAULT_MY_CARS_PATH)] + my_cars: String, + + /// Path to the car database file + #[arg(long, default_value = DEFAULT_CARS_DB_PATH)] + cars_db: String, + + /// Directory containing per-car timing JSON files + #[arg(long, default_value = DEFAULT_TIMES_DIR)] + times_dir: String, + + /// Path to the tracks database file + #[arg(long, default_value = DEFAULT_TRACKS_DB_PATH)] + tracks_db: String, + #[command(subcommand)] command: Commands, } @@ -99,16 +127,9 @@ enum Commands { }, } -/// 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). The car list is stored inside `CarCounts`. -async fn run_analysis(owned: &HashSet) -> (AnalysisContext, CarCounts) { - let ctx = AnalysisContext::new(); - let car_list = parse_all_times(owned, &ctx).await; - let counts = compute_car_counts(&ctx, car_list); - (ctx, counts) -} +// --------------------------------------------------------------------------- +// Main entry point +// --------------------------------------------------------------------------- /// Main entry point for the Top Drives Records Analyzer /// @@ -120,804 +141,47 @@ async fn run_analysis(owned: &HashSet) -> (AnalysisContext, CarCounts) { /// ```bash /// cargo run --bin records -- best /// cargo run --bin records -- track "Laguna Seca" "Dry" "85" +/// cargo run --bin records -- --cars-db /data/cars.json --times-dir /data/times best /// ``` #[tokio::main] -async fn main() -> std::io::Result<()> { +async fn main() { + if let Err(e) = run().await { + eprintln!("Error: {e}"); + std::process::exit(1); + } +} + +async fn run() -> Result<(), AppError> { let cli = Cli::parse(); match cli.command { Commands::Fetch => { - let mut errors = 0usize; - if let Err(e) = get_cars_file().await { eprintln!("Error: {e}"); errors += 1; } - if let Err(e) = get_tracks_file().await { eprintln!("Error: {e}"); errors += 1; } - if let Err(e) = fetch_times().await { eprintln!("Error: {e}"); errors += 1; } - if errors == 0 { - println!("Fetch complete โ€” all files downloaded successfully."); - } else { - return Err(std::io::Error::new( - std::io::ErrorKind::Other, - format!("Fetch completed with {errors} error(s). Check above for details."), - )); - } + commands::fetch::run_fetch(&cli.cars_db, &cli.tracks_db, &cli.times_dir).await?; } Commands::Best { min_count } => { - let my_data = load_my_cars_data(); - let (_ctx, counts) = run_analysis(&my_data.owned).await; - write_best_csv(&counts)?; - write_very_best_csv(&counts, min_count)?; - println!("Written: best.csv, very_best.csv"); + commands::best::run_best(&cli.my_cars, &cli.cars_db, &cli.times_dir, min_count).await?; } Commands::Trash => { - let my_data = load_my_cars_data(); - let (_ctx, counts) = run_analysis(&my_data.owned).await; - write_trash_csv(&counts, &my_data.owned)?; - println!("Written: trash.csv"); + commands::trash::run_trash(&cli.my_cars, &cli.cars_db, &cli.times_dir).await?; } Commands::Wishlist { min_count, max_wishlist } => { - let my_data = load_my_cars_data(); - let (_ctx, counts) = run_analysis(&my_data.owned).await; - write_wishlist_files(&counts, &my_data.owned, &my_data.wishlist, min_count, max_wishlist)?; - println!("Written: wishlist.csv, add.csv, remove.csv"); + commands::wishlist::run_wishlist( + &cli.my_cars, &cli.cars_db, &cli.times_dir, min_count, max_wishlist, + ).await?; } Commands::All { min_count, max_wishlist } => { - let my_data = load_my_cars_data(); - let (ctx, counts) = run_analysis(&my_data.owned).await; - write_best_csv(&counts)?; - write_very_best_csv(&counts, min_count)?; - write_trash_csv(&counts, &my_data.owned)?; - write_details_csv(&ctx)?; - write_wishlist_files(&counts, &my_data.owned, &my_data.wishlist, min_count, max_wishlist)?; - println!("Written: best.csv, very_best.csv, trash.csv, details.csv, wishlist.csv, add.csv, remove.csv"); + commands::all::run_all( + &cli.my_cars, &cli.cars_db, &cli.times_dir, min_count, max_wishlist, + ).await?; } Commands::Details => { - let my_data = load_my_cars_data(); - let (ctx, _counts) = run_analysis(&my_data.owned).await; - write_details_csv(&ctx)?; - println!("Written: details.csv"); + commands::details::run_details(&cli.my_cars, &cli.cars_db, &cli.times_dir).await?; } Commands::Track { track, condition, rq } => { - let my_data = load_my_cars_data(); - let (ctx, _counts) = run_analysis(&my_data.owned).await; - print_best_car(&ctx, &track, &condition, rq); + commands::track::run_track( + &cli.my_cars, &cli.cars_db, &cli.times_dir, &track, &condition, rq, + ).await?; } } Ok(()) } - -/// Comparison function for sorting `BestCarRecord`s by RQ, 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`. -fn compare_records(a: &BestCarRecord, b: &BestCarRecord) -> std::cmp::Ordering { - a.rq.cmp(&b.rq) - .then(a.brand.cmp(&b.brand)) - .then(a.only_name.cmp(&b.only_name)) -} - -/// Writes a sorted CSV file with a header row. -/// -/// Generic helper that sorts entries by index (avoiding cloning), writes the header, -/// and formats each row. Returns `std::io::Result<()>` so callers can propagate errors -/// instead of panicking. -fn write_sorted_csv( - path: &str, - header: &str, - entries: &[T], - cmp: impl Fn(&T, &T) -> std::cmp::Ordering, - format_row: G, -) -> std::io::Result<()> -where - G: Fn(&T, &mut fs::File) -> std::io::Result<()>, -{ - let mut indices: Vec = (0..entries.len()).collect(); - indices.sort_by(|&a, &b| cmp(&entries[a], &entries[b])); - - let mut file = fs::File::create(path)?; - writeln!(file, "{header}")?; - for i in indices { - format_row(&entries[i], &mut file)?; - } - Ok(()) -} - -/// Formats a `(BestCarRecord, usize)` row as `RQ,Brand,Model,Year,Count`. -/// Shared by `write_best_csv`, `write_very_best_csv`, and wishlist output functions. -fn format_counted_row(entry: &(BestCarRecord, usize), file: &mut fs::File) -> std::io::Result<()> { - let (rec, count) = entry; - writeln!(file, "{},{},{},{},{}", rec.rq, rec.brand, rec.only_name, rec.year, count) -} - -/// Writes best.csv โ€” all cars that are best on at least one track, sorted by RQ. -/// Format: RQ,Brand,Model,Year,Count -fn write_best_csv(counts: &CarCounts) -> std::io::Result<()> { - let entries: Vec<_> = counts.by_rid.values().collect(); - write_sorted_csv( - "best.csv", - "RQ,Brand,Model,Year,Count", - &entries, - |a, b| compare_records(&a.0, &b.0), - |entry, file| format_counted_row(entry, file), - ) -} - -/// Writes very_best.csv โ€” cars best on >= min_count tracks, sorted by RQ. -/// Format: RQ,Brand,Model,Year,Count -fn write_very_best_csv(counts: &CarCounts, min_count: usize) -> std::io::Result<()> { - let entries: Vec<_> = counts.by_rid.values() - .filter(|(_, count)| *count >= min_count) - .collect(); - write_sorted_csv( - "very_best.csv", - "RQ,Brand,Model,Year,Count", - &entries, - |a, b| compare_records(&a.0, &b.0), - |entry, file| format_counted_row(entry, file), - ) -} - -/// Writes trash.csv โ€” owned cars not best on any track, RQ < 80, not prize. -/// Format: RQ,Brand,Model,Year -fn write_trash_csv(counts: &CarCounts, owned: &HashSet) -> std::io::Result<()> { - let trash: Vec<_> = owned.iter() - .filter(|guid| !counts.guid_to_rid.contains_key(*guid)) - .filter_map(|guid| counts.car_map.get(guid)) - .filter(|car| !car.prize && car.rq < 80) - .collect(); - - write_sorted_csv( - "trash.csv", - "RQ,Brand,Model,Year", - &trash, - |a, b| compare_cars(a, b), - |car, file| writeln!(file, "{},{},{},{}", car.rq, car.brand, car.only_name, car.year), - ) -} - -/// Computes the ideal wishlist: very_best unowned cars, capped at max_wishlist, sorted by RQ. -fn compute_ideal_wishlist( - counts: &CarCounts, - owned: &HashSet, - min_count: usize, - max_wishlist: usize, -) -> Vec<(BestCarRecord, usize)> { - let mut candidates: Vec<_> = counts.by_rid.values() - .filter(|(record, count)| *count >= min_count && !owned.contains(&record.guid)) - .cloned() - .collect(); - candidates.sort_by(|a, b| compare_records(&a.0, &b.0)); - candidates.truncate(max_wishlist); - candidates -} - -/// Writes wishlist.csv, add.csv, and remove.csv from a single computed ideal wishlist. -/// -/// Computes the ideal wishlist once and derives all three output files from it, -/// avoiding redundant computation. -fn write_wishlist_files( - counts: &CarCounts, - owned: &HashSet, - wishlist: &HashSet, - min_count: usize, - max_wishlist: usize, -) -> std::io::Result<()> { - let ideal = compute_ideal_wishlist(counts, owned, min_count, max_wishlist); - let ideal_guids: HashSet<_> = ideal.iter().map(|(r, _)| r.guid.clone()).collect(); - - // --- wishlist.csv --- - write_sorted_csv( - "wishlist.csv", - "RQ,Brand,Model,Year,Count", - &ideal, - |a, b| compare_records(&a.0, &b.0), - |entry, file| format_counted_row(entry, file), - )?; - - // --- add.csv (ideal cars not yet on the wishlist) --- - let to_add: Vec<_> = ideal.iter() - .filter(|(record, _)| !wishlist.contains(&record.guid)) - .collect(); - - write_sorted_csv( - "add.csv", - "RQ,Brand,Model,Year,Count", - &to_add, - |a, b| compare_records(&a.0, &b.0), - |entry, file| format_counted_row(entry, file), - )?; - - // --- remove.csv (wishlist cars not in the ideal set) --- - // Single pass: collect found entries and count dropped ones together. - let mut dropped_count = 0usize; - let to_remove: Vec<_> = wishlist.iter() - .filter(|guid| !ideal_guids.contains(*guid)) - .filter_map(|guid| { - if let Some(rid) = counts.guid_to_rid.get(guid) { - counts.by_rid.get(rid).cloned() - } else { - dropped_count += 1; - None - } - }) - .collect(); - if dropped_count > 0 { - eprintln!( - "Warning: {dropped_count} wishlist car(s) not found in best cars data and omitted from remove.csv" - ); - } - - write_sorted_csv( - "remove.csv", - "RQ,Brand,Model,Year,Count", - &to_remove, - |a, b| compare_records(&a.0, &b.0), - |entry, file| format_counted_row(entry, file), - )?; - - Ok(()) -} - -/// Writes detailed timing information for all tracks, conditions, and RQ levels to `details.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. -/// -/// # Output Format -/// ```csv -/// RQ,TrackID,Condition,CarRQ,Brand,Model,Year,Config,Time -/// 85,laguna,Dry,85,McLaren,F1,1993,233,112.45 -/// 85,monaco,Dry,85,Pagani,Zonda,2002,111,45.23 -/// 82,laguna,Dry,82,Ferrari,250 GTO,1962,111,115.67 -/// ``` -fn write_details_csv(ctx: &AnalysisContext) -> std::io::Result<()> { - // Scope the lock to only the data extraction phase - let sorted_data: Vec<(TripletKey, Vec)> = { - let best_cars = ctx.best_cars.lock() - .expect("best_cars mutex poisoned โ€” another thread panicked"); - - // Collect keys and sort them by RQ and Track ID for consistent output - let mut sorted_keys: Vec<_> = best_cars.keys().collect(); - sorted_keys.sort_by(|a, b| a.1.cmp(&b.1).then(a.0.cmp(&b.0))); - - sorted_keys - .into_iter() - .filter_map(|key| best_cars.get(key).map(|records| (key.clone(), records.clone()))) - .collect() - }; // Lock dropped here - - let mut file = fs::File::create("details.csv")?; - writeln!(file, "RQ,TrackID,Condition,CarRQ,Brand,Model,Year,Config,Time")?; - for (key, records) in sorted_data { - for best_car in records { - writeln!( - file, - "{},{},{},{},{},{},{},{},{}", - key.1, // RQ level - key.0, // Track ID - key.2, // Surface condition - best_car.rq, // Car's RQ - best_car.brand, // Car brand - best_car.only_name, // Car model - best_car.year, // Car year - best_car.config, // Tune configuration - best_car.time // Track time - )?; - } - } - Ok(()) -} - -/// Finds and displays the best performing car for a specific track, condition, and RQ level -/// -/// Provides targeted recommendations for specific racing scenarios. Useful for determining -/// the optimal car choice when you need to win a particular race or meet specific requirements. -/// -/// # Arguments -/// -/// * `ctx` - Analysis context containing the best cars data -/// * `track` - The track identifier (e.g., "laguna", "monaco") -/// * `condition` - Surface condition (e.g., "Dry", "Wet", "Dirt") -/// * `rq` - RQ level limit (cars with this RQ or lower will be considered) -/// -/// # Output Example -/// ``` -/// Best car for Track: laguna, Condition: Dry, RQ: 85 -> McLaren F1 (1993) with time 112.45 -/// ``` -/// -/// # Note -/// If no car is found for the specified combination, it may indicate that no data -/// is available for that specific track/condition/RQ combination. -fn print_best_car(ctx: &AnalysisContext, track: &str, condition: &str, rq: u8) { - let best_cars = ctx.best_cars.lock() - .expect("best_cars mutex poisoned โ€” another thread panicked"); - let key = ( - track.to_string(), - rq, - condition.to_string(), - ); - - if let Some(best_list) = best_cars.get(&key) { - for best_car in best_list { - println!( - "Best car for Track: {}, Condition: {}, RQ: {} -> {} {} ({}) with time {:.2}", - track, - condition, - rq, - best_car.brand, - best_car.only_name, - best_car.year, - best_car.time - ); - } - } else { - println!( - "No best car found for Track: {}, Condition: {}, RQ: {}", - track, condition, rq - ); - } -} - -/// Parses timing data for a single car from its JSON file -/// -/// This is the core function responsible for processing individual car performance data. -/// It reads the car's JSON file, extracts timing information for each track and condition, -/// filters out invalid or downvoted times, and updates the analysis context. -/// -/// # Arguments -/// -/// * `car` - The car data structure containing metadata about the car to process -/// * `my_cars` - Set of GUIDs the user owns (used to mark ownership) -/// * `ctx` - Analysis context containing the shared best cars map -/// -/// # Data Processing -/// -/// 1. **File Reading**: Loads the car's JSON timing file from the times/ directory -/// 2. **Configuration Parsing**: Processes each tune configuration (111, 233, 323, etc.) -/// 3. **Track Processing**: Extracts timing data for each track and surface condition -/// 4. **Validation**: Filters out invalid times and downvoted entries -/// 5. **Time Conversion**: Handles special track calculations (e.g., Test Bowl) -/// 6. **Database Update**: Updates the analysis context with new records -/// -/// # Special Cases -/// -/// - **Test Bowl Track**: Time calculation is inverted (2000.0 / time) due to unique track mechanics -/// - **Downvoted Times**: Entries with downvotes are excluded to maintain data quality -/// - **Minimum Time Threshold**: Times below 1.2 seconds are considered invalid -/// -/// # Performance -/// -/// This function is designed to be called concurrently for multiple cars to improve -/// overall processing speed when analyzing large car databases. -async fn parse_car_times(car: Car, my_cars: &HashSet, ctx: &AnalysisContext) { - // Construct file path for this car's timing data - let file_name = car.rid.to_owned() + ".json"; - let local_file_path = "times/".to_owned() + file_name.as_str(); - - // Read file content, emitting a warning on failure - let file_content = match fs::read_to_string(&local_file_path) { - Ok(content) => content, - Err(e) => { - eprintln!("Warning: could not read {local_file_path}: {e}"); - String::new() - } - }; - - // Parse JSON data, emitting a warning on failure - let json_data: Value = match serde_json::from_str(&file_content) { - Ok(v) => v, - Err(e) => { - if !file_content.is_empty() { - eprintln!("Warning: could not parse {local_file_path}: {e}"); - } - Value::Null - } - }; - - // Process each configuration variant (111, 233, 323, etc.) - if let Some(configurations) = json_data["data"].as_object() { - for (config, entry) in configurations { - // Process timing data for each track - 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 - if let Some((cleaned_track_id, track_type)) = track_id.split_once("_a") { - 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 { - None => true, - Some(votes) => votes.is_empty(), - }; - - if track_id.starts_with("testBowl") { - time = 2000.0 / time; - } - - let owned = my_cars.contains(&car.guid); - let guid = car.guid.clone(); - - if time > 1.2 && no_down_votes { - update_best_car( - &ctx.best_cars, - &translate_track_name(cleaned_track_id), - car.rq, - translate_track_type(track_type), - BestCarRecord { - car_rid: car.rid.clone(), - brand: car.brand.clone(), - only_name: car.only_name.clone(), - year: car.year, - rq: car.rq, - config: config.clone(), - time, - owned, - prize: car.prize, - guid, - }, - ); - } - } - } - } - } - } -} - -/// Updates the best cars database with a new performance record -/// -/// Implements the core logic for determining the best performing cars across different -/// RQ ranges. For each RQ level from the car's base RQ up to MAX_RQ, this -/// function determines whether the new record represents an improvement or tie. -fn update_best_car( - best_cars: &Arc>>>, - track_id: &str, - car_rq: u8, - surface: &str, - new_record: BestCarRecord, -) { - /// Tolerance for considering two times as equal (5ms) - const TIME_TOLERANCE: f64 = 0.005; - - let mut best_cars = best_cars.lock().unwrap(); - - for rq in car_rq..=MAX_RQ { - let key = (track_id.to_string(), rq, surface.to_string()); - - match best_cars.get_mut(&key) { - Some(existing_list) => { - let best_time = existing_list.first().map(|r| r.time).unwrap_or(f64::MAX); - - if new_record.time < best_time { - *existing_list = vec![new_record.clone()]; - } else if (new_record.time - best_time).abs() < TIME_TOLERANCE - && !existing_list - .iter() - .any(|r| r.car_rid == new_record.car_rid) - { - existing_list.push(new_record.clone()); - } - } - None => { - best_cars.insert(key, vec![new_record.clone()]); - } - } - } -} - -/// Orchestrates concurrent parsing of all car timing files -/// -/// Spawns a separate async task for each car in the database, allowing for -/// parallel processing of timing data. -async fn parse_all_times(my_cars: &HashSet, ctx: &AnalysisContext) -> Vec { - let my_cars = Arc::new(my_cars.clone()); - let car_list = get_car_list(); - let mut handles = vec![]; - - let semaphore = Arc::new(Semaphore::new(20)); - - for car in car_list.iter() { - let my_cars = Arc::clone(&my_cars); - let best_cars = Arc::clone(&ctx.best_cars); - let car = car.clone(); - let sem = Arc::clone(&semaphore); - let handle = task::spawn(async move { - let _permit = sem.acquire().await.unwrap(); - parse_car_times(car, &my_cars, &AnalysisContext { best_cars }).await; - }); - handles.push(handle); - } - - let results = join_all(handles).await; - for result in results { - if let Err(e) = result { - eprintln!("Warning: parsing task failed: {e}"); - } - } - - car_list -} - -/// Fetches timing data files for all cars using controlled concurrency. -/// -/// Returns the first error encountered, or `Ok(())` if all downloads succeeded. -async fn fetch_times() -> Result<(), String> { - let semaphore = Arc::new(Semaphore::new(20)); - let mut handles = vec![]; - - for car in get_car_list() { - let permit = semaphore.clone().acquire_owned().await - .map_err(|e| format!("Semaphore closed: {e}"))?; - handles.push(tokio::spawn(async move { - let _permit = permit; // held until end of block - fetch_car(car.rid).await - })); - } - - let results = join_all(handles).await; - let mut first_error: Option = None; - for result in results { - match result { - Ok(Ok(())) => {} - Ok(Err(e)) => { - eprintln!("{e}"); - if first_error.is_none() { first_error = Some(e); } - } - Err(e) => { - let msg = format!("Task join error: {e}"); - eprintln!("{msg}"); - if first_error.is_none() { first_error = Some(msg); } - } - } - } - first_error.map_or(Ok(()), Err) -} - -/// Downloads timing data for a single car from the remote API -async fn fetch_car(rid: String) -> Result<(), String> { - let file_name = rid.to_owned() + ".json"; - let url = "https://api.topdrivesrecords.com/car/".to_owned() + rid.as_str(); - let local_file_path = "times/".to_owned() + file_name.as_str(); - println!("Fetching {}.", url); - get_file(url.as_str(), local_file_path.as_str()).await -} - -/// Loads the complete car database from the local repository file -/// -/// Returns a `Vec` containing all cars from the database. -/// Returns an empty list with a warning if the file is missing or malformed. -fn get_car_list() -> Vec { - let cars_file = "cars_repo.json"; - let content = match fs::read_to_string(cars_file) { - Ok(c) => c, - Err(e) => { - eprintln!("Warning: could not read {cars_file}: {e}"); - return Vec::new(); - } - }; - let cars_data: Vec = match serde_json::from_str(&content) { - Ok(v) => v, - Err(e) => { - eprintln!("Warning: could not parse {cars_file}: {e}"); - return Vec::new(); - } - }; - - let total = cars_data.len(); - let cars: Vec = cars_data.into_iter().filter_map(|car| { - let rid = car["rid"].as_str()?; - let brand = car["brand"].as_str()?; - let only_name = car["onlyName"].as_str()?; - let year = car["year"].as_u64()?; - let rq = car["rq"].as_u64()?; - let prize = car["prize"].as_bool().unwrap_or(false); - let guid = car["guid"].as_str()?; - - Some(Car { rid: rid.to_string(), brand: brand.to_string(), only_name: only_name.to_string(), year: year as u16, rq: rq as u8, prize, guid: guid.to_string() }) - }).collect(); - let skipped = total - cars.len(); - if skipped > 0 { - eprintln!("Warning: {skipped} car(s) skipped due to missing required fields in {cars_file}"); - } - cars -} - -/// Downloads the tracks database file from the remote repository -async fn get_tracks_file() -> Result<(), String> { - println!("Fetching tracks file..."); - get_file("https://github.com/TiagoXavi/topdrives-records/raw/refs/heads/main/src/database/tracks_repo.json","tracks_repo.json").await -} - -/// Downloads the cars database file from the remote repository -async fn get_cars_file() -> Result<(), String> { - println!("Fetching cars file..."); - get_file("https://github.com/TiagoXavi/topdrives-records/raw/refs/heads/main/src/database/cars_final.json", "cars_repo.json").await -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_split_once() { - let result = "laguna_a00".split_once("_a"); - assert_eq!(result, Some(("laguna", "00"))); - - let result = "laguna".split_once("_a"); - assert_eq!(result, None); - - let result = "test_a_data_a_more".split_once("_a"); - assert_eq!(result, Some(("test", "_data_a_more"))); - - let result = "".split_once("_a"); - assert_eq!(result, None); - } - - #[test] - fn test_translate_track_name() { - assert_eq!(translate_track_name("laguna"), "Laguna Seca"); - assert_eq!(translate_track_name("mnCity"), "Monaco Streets"); - assert_eq!(translate_track_name("testBowl"), "Test Bowl"); - assert_eq!(translate_track_name("unknown_track"), "unknown_track"); - assert_eq!(translate_track_name("drag100"), "0-100mph"); - assert_eq!(translate_track_name("mile4"), "1/4 Mile Drag"); - } - - #[test] - fn test_translate_track_type() { - assert_eq!(translate_track_type("00"), "Dry"); - assert_eq!(translate_track_type("01"), "Wet"); - assert_eq!(translate_track_type("10"), "Dirt"); - assert_eq!(translate_track_type("11"), "Dirt Wet"); - assert_eq!(translate_track_type("30"), "Ice"); - assert_eq!(translate_track_type("60"), "Snow"); - assert_eq!(translate_track_type("99"), "99"); - } - - #[test] - fn test_update_best_car_logic() { - let test_record = BestCarRecord { - car_rid: "Test_Car".to_string(), - brand: "TestBrand".to_string(), - only_name: "TestCar".to_string(), - year: 2020, - rq: 85, - config: "111".to_string(), - time: 100.0, - owned: false, - prize: false, - guid: "test_guid".to_string(), - }; - - assert_eq!(test_record.time, 100.0); - assert_eq!(test_record.rq, 85); - assert_eq!(test_record.config, "111"); - } - - #[test] - fn test_rq_range_analysis() { - let ctx = AnalysisContext::new(); - - let test_record = BestCarRecord { - car_rid: "Test_Car".to_string(), - brand: "TestBrand".to_string(), - only_name: "TestCar".to_string(), - year: 2020, - rq: 85, - config: "111".to_string(), - time: 100.0, - owned: false, - prize: false, - guid: "test_guid".to_string(), - }; - - update_best_car(&ctx.best_cars, "laguna", 85, "Dry", test_record); - - let best_cars = ctx.best_cars.lock().unwrap(); - - for rq in 85..=MAX_RQ { - let key = ("laguna".to_string(), rq, "Dry".to_string()); - assert!( - best_cars.contains_key(&key), - "Car should appear at RQ level {}", - rq - ); - } - } - - #[test] - fn test_time_validation() { - let valid_time = 112.45; - let invalid_time = 1.0; - - assert!(valid_time > 1.2, "Valid time should be above threshold"); - assert!(invalid_time < 1.2, "Invalid time should be below threshold"); - } - - #[test] - fn test_track_name_completeness() { - let test_tracks = vec![ - "laguna", - "mnCity", - "testBowl", - "drag100", - "mile4", - "airplaneHangars", - "dealsGap", - "northloop", - ]; - - for track in test_tracks { - let name = translate_track_name(track); - assert_ne!(name, track, "Track {} should have a proper name mapping", track); - assert!(!name.is_empty(), "Track name should not be empty"); - } - } - - #[test] - fn test_condition_completeness() { - let test_conditions = vec!["00", "01", "10", "11", "20", "30", "40", "41", "50", "60"]; - - for condition in test_conditions { - let name = translate_track_type(condition); - assert_ne!(name, condition, "Condition {} should have a proper name mapping", condition); - assert!(!name.is_empty(), "Condition name should not be empty"); - } - } - - #[test] - fn test_time_tolerance_constant() { - let ctx = AnalysisContext::new(); - - let record1 = BestCarRecord { - car_rid: "Car_A".to_string(), - brand: "Brand".to_string(), - only_name: "Model".to_string(), - year: 2020, - rq: 85, - config: "111".to_string(), - time: 100.0, - owned: false, - prize: false, - guid: "guid_a".to_string(), - }; - - let record2 = BestCarRecord { - car_rid: "Car_B".to_string(), - brand: "Brand".to_string(), - only_name: "Model".to_string(), - year: 2020, - rq: 85, - config: "111".to_string(), - time: 100.003, - owned: false, - prize: false, - guid: "guid_b".to_string(), - }; - - let record3 = BestCarRecord { - car_rid: "Car_C".to_string(), - brand: "Brand".to_string(), - only_name: "Model".to_string(), - year: 2020, - rq: 85, - config: "111".to_string(), - time: 100.006, - owned: false, - prize: false, - guid: "guid_c".to_string(), - }; - - update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record1); - update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record2); - update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record3); - - let best_cars = ctx.best_cars.lock().unwrap(); - let key = ("laguna".to_string(), 85, "Dry".to_string()); - let records = best_cars.get(&key).unwrap(); - - assert_eq!(records.len(), 2, "Should have 2 tied records within 0.005 tolerance"); - assert!(records.iter().any(|r| r.car_rid == "Car_A")); - assert!(records.iter().any(|r| r.car_rid == "Car_B")); - assert!(!records.iter().any(|r| r.car_rid == "Car_C")); - } -} diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs new file mode 100644 index 0000000..919eaa5 --- /dev/null +++ b/tests/integration_tests.rs @@ -0,0 +1,362 @@ +//! Integration tests for the Top Drives Records Analyzer. +//! +//! These tests exercise the full analysis pipeline with fixture data, +//! verifying end-to-end correctness from parsing through CSV output. + +use std::fs; +use std::io::Write; + +use top_drives::{ + AnalysisContext, BestCarRecord, Car, + compare_cars, compute_car_counts, escape_csv_field, load_my_cars_data, + translate_track_name, translate_track_type, +}; + +// -- Helpers -- + +fn make_record(car_rid: &str, rq: u8, time: f64, guid: &str) -> BestCarRecord { + BestCarRecord { + car_rid: car_rid.to_string(), + brand: "TestBrand".to_string(), + only_name: "TestModel".to_string(), + year: 2020, + rq, + config: "111".to_string(), + time, + owned: false, + prize: false, + guid: guid.to_string(), + } +} + +fn make_car(rid: &str, rq: u8, guid: &str) -> Car { + Car { + rid: rid.to_string(), + brand: "TestBrand".to_string(), + only_name: "TestModel".to_string(), + year: 2020, + rq, + prize: false, + guid: guid.to_string(), + } +} + +// ======================================================================= +// Integration: AnalysisContext โ†’ CarCounts pipeline +// ======================================================================= + +#[test] +fn test_full_pipeline_empty_input() { + let ctx = AnalysisContext::new(); + let car_list: Vec = vec![]; + let counts = compute_car_counts(&ctx, car_list).unwrap(); + + assert!(counts.by_rid.is_empty()); + assert!(counts.guid_to_rid.is_empty()); + assert!(counts.car_map.is_empty()); +} + +#[test] +fn test_full_pipeline_single_car_multiple_tracks() { + let ctx = AnalysisContext::new(); + let rec = make_record("Ferrari_F40", 95, 88.5, "guid_f40"); + + // Simulate the car being best on 20 tracks + for i in 0..20 { + ctx.best_cars.insert( + (format!("Track{i}"), 95, "Dry".to_string()), + vec![rec.clone()], + ); + } + + let car = make_car("Ferrari_F40", 95, "guid_f40"); + let counts = compute_car_counts(&ctx, vec![car]).unwrap(); + + assert_eq!(counts.by_rid.len(), 1); + let (rec, count) = counts.by_rid.get("Ferrari_F40").unwrap(); + assert_eq!(*count, 20); + assert_eq!(rec.time, 88.5); + + // Verify guid_to_rid mapping + assert_eq!( + counts.guid_to_rid.get("guid_f40"), + Some(&"Ferrari_F40".to_string()) + ); + + // Verify car_map + assert!(counts.car_map.contains_key("guid_f40")); +} + +#[test] +fn test_full_pipeline_multiple_cars_different_rq() { + let ctx = AnalysisContext::new(); + + // Car A: RQ 80, best on 5 tracks + let rec_a = make_record("Car_A", 80, 100.0, "guid_a"); + for i in 0..5 { + ctx.best_cars.insert( + (format!("Track{i}"), 80, "Dry".to_string()), + vec![rec_a.clone()], + ); + } + + // Car B: RQ 90, best on 12 tracks + let rec_b = make_record("Car_B", 90, 95.0, "guid_b"); + for i in 0..12 { + ctx.best_cars.insert( + (format!("TrackB{i}"), 90, "Dry".to_string()), + vec![rec_b.clone()], + ); + } + + // Car C: RQ 100, best on 8 tracks + let rec_c = make_record("Car_C", 100, 85.0, "guid_c"); + for i in 0..8 { + ctx.best_cars.insert( + (format!("TrackC{i}"), 100, "Dry".to_string()), + vec![rec_c.clone()], + ); + } + + let car_list = vec![ + make_car("Car_A", 80, "guid_a"), + make_car("Car_B", 90, "guid_b"), + make_car("Car_C", 100, "guid_c"), + ]; + + let counts = compute_car_counts(&ctx, car_list).unwrap(); + + assert_eq!(counts.by_rid.len(), 3); + + let (_, count_a) = counts.by_rid.get("Car_A").unwrap(); + assert_eq!(*count_a, 5); + + let (_, count_b) = counts.by_rid.get("Car_B").unwrap(); + assert_eq!(*count_b, 12); + + let (_, count_c) = counts.by_rid.get("Car_C").unwrap(); + assert_eq!(*count_c, 8); +} + +// ======================================================================= +// Integration: CSV output correctness +// ======================================================================= + +#[test] +fn test_csv_output_field_ordering() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("output.csv"); + let path_str = path.to_str().unwrap(); + + let rec = BestCarRecord { + car_rid: "Test".to_string(), + brand: "BMW".to_string(), + only_name: "M3".to_string(), + year: 2023, + rq: 85, + config: "111".to_string(), + time: 100.0, + owned: true, + prize: false, + guid: "guid_test".to_string(), + }; + let entries = vec![(rec, 7)]; + + // Write with the standard format + { + let mut file = fs::File::create(path_str).unwrap(); + writeln!(file, "RQ,Brand,Model,Year,Count").unwrap(); + for (rec, count) in &entries { + writeln!( + file, + "{},{},{},{},{}", + rec.rq, + escape_csv_field(&rec.brand), + escape_csv_field(&rec.only_name), + rec.year, + count + ) + .unwrap(); + } + } + + let content = fs::read_to_string(&path).unwrap(); + let lines: Vec<&str> = content.lines().collect(); + assert_eq!(lines[0], "RQ,Brand,Model,Year,Count"); + assert_eq!(lines[1], "85,BMW,M3,2023,7"); +} + +#[test] +fn test_csv_escaping_in_output() { + // Verify that fields containing commas and quotes are properly escaped + let field = escape_csv_field("Alfa Romeo, Inc."); + assert_eq!(field, "\"Alfa Romeo, Inc.\""); + + let field = escape_csv_field("Model \"Sport\""); + assert_eq!(field, "\"Model \"\"Sport\"\"\""); +} + +// ======================================================================= +// Integration: Track name translation pipeline +// ======================================================================= + +#[test] +fn test_track_translation_pipeline() { + // Verify the full chain: raw track code โ†’ translated name + let raw_codes = vec![ + ("laguna", "Laguna Seca"), + ("mnCity", "Monaco Streets"), + ("testBowl", "Test Bowl"), + ("drag100", "0-100mph"), + ("northloop", "North Loop"), + ]; + + for (code, expected) in raw_codes { + assert_eq!(translate_track_name(code), expected); + } + + // Unknown codes should pass through unchanged + assert_eq!(translate_track_name("futureTrack"), "futureTrack"); +} + +#[test] +fn test_condition_translation_pipeline() { + let conditions = vec![ + ("00", "Dry"), + ("01", "Wet"), + ("10", "Dirt"), + ("11", "Dirt Wet"), + ("60", "Snow"), + ]; + + for (code, expected) in conditions { + assert_eq!(translate_track_type(code), expected); + } + + assert_eq!(translate_track_type("99"), "99"); +} + +// ======================================================================= +// Integration: Ownership filtering +// ======================================================================= + +#[test] +fn test_ownership_filtering_in_counts() { + let ctx = AnalysisContext::new(); + + // Two cars: one owned, one not + let mut rec_owned = make_record("Owned_Car", 85, 100.0, "guid_owned"); + rec_owned.owned = true; + let rec_unowned = make_record("Unowned_Car", 90, 95.0, "guid_unowned"); + + for i in 0..10 { + ctx.best_cars.insert( + (format!("Track{i}"), 85, "Dry".to_string()), + vec![rec_owned.clone()], + ); + ctx.best_cars.insert( + (format!("Track{i}"), 90, "Dry".to_string()), + vec![rec_unowned.clone()], + ); + } + + let car_list = vec![ + make_car("Owned_Car", 85, "guid_owned"), + make_car("Unowned_Car", 90, "guid_unowned"), + ]; + + let counts = compute_car_counts(&ctx, car_list).unwrap(); + + // Both should be in by_rid + assert_eq!(counts.by_rid.len(), 2); + + // Verify ownership is reflected in the records + let (owned_rec, _) = counts.by_rid.get("Owned_Car").unwrap(); + assert!(owned_rec.owned); + + let (unowned_rec, _) = counts.by_rid.get("Unowned_Car").unwrap(); + assert!(!unowned_rec.owned); +} + +// ======================================================================= +// Integration: load_my_cars_data with fixture file +// ======================================================================= + +#[test] +fn test_load_my_cars_data_with_fixture() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("my_cars.json"); + + let json = r#"{ + "playerDeck": [ + { "cardId": "guid_a", "locked": true }, + { "cardId": "guid_b", "locked": false }, + { "cardId": "guid_c", "locked": true } + ], + "wishlists": [ + { "cards": ["guid_d", "guid_e"] } + ] + }"#; + fs::write(&path, json).unwrap(); + + let data = load_my_cars_data(path.to_str().unwrap()).unwrap(); + + // 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")); + + // Wishlist + assert_eq!(data.wishlist.len(), 2); + assert!(data.wishlist.contains("guid_d")); + assert!(data.wishlist.contains("guid_e")); +} + +#[test] +fn test_load_my_cars_data_empty_wishlist() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("my_cars.json"); + + let json = r#"{ + "playerDeck": [ + { "cardId": "guid_a", "locked": true } + ] + }"#; + fs::write(&path, json).unwrap(); + + let data = load_my_cars_data(path.to_str().unwrap()).unwrap(); + + assert_eq!(data.owned.len(), 1); + assert!(data.wishlist.is_empty()); +} + +// ======================================================================= +// Integration: Sorting consistency +// ======================================================================= + +#[test] +fn test_sorting_consistency_between_car_and_record() { + // compare_cars and compare_records should produce the same relative ordering + let car_a = Car { + rid: "A".to_string(), + brand: "BMW".to_string(), + only_name: "M3".to_string(), + year: 2020, + rq: 85, + prize: false, + guid: "guid_a".to_string(), + }; + let car_b = Car { + rid: "B".to_string(), + brand: "Audi".to_string(), + only_name: "RS6".to_string(), + year: 2021, + rq: 90, + prize: false, + guid: "guid_b".to_string(), + }; + + // compare_cars sorts by RQ first, so A (85) < B (90) + assert_eq!(compare_cars(&car_a, &car_b), std::cmp::Ordering::Less); +} diff --git a/tests/proptest_tests.rs b/tests/proptest_tests.rs new file mode 100644 index 0000000..a17d0dc --- /dev/null +++ b/tests/proptest_tests.rs @@ -0,0 +1,169 @@ +//! Property-based tests using proptest for sorting and comparison functions. + +use proptest::prelude::*; + +use top_drives::{compare_cars, Car}; + +/// Generate arbitrary Car instances for property testing. +fn car_strategy() -> impl Strategy { + ( + "[a-zA-Z]{2,10}", // rid + "[a-zA-Z]{2,15}", // brand + "[a-zA-Z0-9 ]{2,20}", // only_name + 1950u16..2030, // year + 10u8..113, // rq + any::(), // prize + "[a-zA-Z0-9_]{3,20}", // guid + ) + .prop_map(|(rid, brand, only_name, year, rq, prize, guid)| Car { + rid, + brand, + only_name, + year, + rq, + prize, + guid, + }) +} + +proptest! { + // ======================================================================= + // compare_cars: antisymmetry property + // ======================================================================= + + #[test] + fn compare_cars_antisymmetric( + a in car_strategy(), + b in car_strategy(), + ) { + // If compare_cars(a, b) == Less, then compare_cars(b, a) == Greater + // If compare_cars(a, b) == Equal, then compare_cars(b, a) == Equal + let ab = compare_cars(&a, &b); + let ba = compare_cars(&b, &a); + assert_eq!(ab.reverse(), ba); + } + + // ======================================================================= + // compare_cars: reflexivity (equal elements compare as Equal) + // ======================================================================= + + #[test] + fn compare_cars_reflexive(a in car_strategy()) { + assert_eq!(compare_cars(&a, &a), std::cmp::Ordering::Equal); + } + + // ======================================================================= + // compare_cars: transitivity + // ======================================================================= + + #[test] + fn compare_cars_transitive( + a in car_strategy(), + b in car_strategy(), + c in car_strategy(), + ) { + let ab = compare_cars(&a, &b); + let bc = compare_cars(&b, &c); + let ac = compare_cars(&a, &c); + + // If a <= b and b <= c, then a <= c + if ab != std::cmp::Ordering::Greater && bc != std::cmp::Ordering::Greater { + assert_ne!(ac, std::cmp::Ordering::Greater); + } + // If a >= b and b >= c, then a >= c + if ab != std::cmp::Ordering::Less && bc != std::cmp::Ordering::Less { + assert_ne!(ac, std::cmp::Ordering::Less); + } + } + + // ======================================================================= + // compare_cars: RQ is the primary sort key + // ======================================================================= + + #[test] + fn compare_cars_rq_primary( + brand_a in "[a-zA-Z]{2,10}", + brand_b in "[a-zA-Z]{2,10}", + model_a in "[a-zA-Z]{2,10}", + model_b in "[a-zA-Z]{2,10}", + rq_a in 10u8..112u8, + rq_offset in 1u8..=3u8, + ) { + let rq_b = rq_a.saturating_add(rq_offset).min(113); + let a = Car { + rid: "A".to_string(), + brand: brand_a, + only_name: model_a, + year: 2020, + rq: rq_a, + prize: false, + guid: "guid_a".to_string(), + }; + let b = Car { + rid: "B".to_string(), + brand: brand_b, + only_name: model_b, + year: 2021, + rq: rq_b, + prize: true, + guid: "guid_b".to_string(), + }; + + // Lower RQ always sorts first, regardless of other fields + assert_eq!(compare_cars(&a, &b), std::cmp::Ordering::Less); + } + + // ======================================================================= + // compare_cars: brand is the secondary sort key when RQ is equal + // ======================================================================= + + #[test] + fn compare_cars_brand_secondary( + brand_a in "[a-zA-Z]{2,10}", + model in "[a-zA-Z]{2,10}", + rq in 10u8..113u8, + ) { + let mut brand_b = brand_a.clone(); + brand_b.push('z'); // brand_b > brand_a lexicographically + + let a = Car { + rid: "A".to_string(), + brand: brand_a, + only_name: model, + year: 2020, + rq, + prize: false, + guid: "guid_a".to_string(), + }; + let b = Car { + rid: "B".to_string(), + brand: brand_b, + only_name: "Z".to_string(), + year: 2021, + rq, + prize: true, + guid: "guid_b".to_string(), + }; + + assert_eq!(compare_cars(&a, &b), std::cmp::Ordering::Less); + } + + // ======================================================================= + // escape_csv_field: no double-escaping + // ======================================================================= + + #[test] + fn escape_csv_no_double_escape(field in "[a-zA-Z0-9 ]{0,50}") { + let escaped = top_drives::escape_csv_field(&field); + // Simple fields should not be modified + assert_eq!(escaped, field); + } + + #[test] + fn escape_csv_with_comma(field in "[a-zA-Z]{1,10}") { + let input = format!("{field},test"); + let escaped = top_drives::escape_csv_field(&input); + assert!(escaped.starts_with('"')); + assert!(escaped.ends_with('"')); + } +}