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<Mutex<HashMap>> 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)
This commit is contained in:
Jan-Peter von Hunnius 2026-05-08 16:19:01 +02:00
parent 9d57b18a4e
commit a9a46ee551
24 changed files with 3541 additions and 1470 deletions

1
.gitignore vendored
View file

@ -4,3 +4,4 @@ my_cars.json
cars_repo.json
tracks_repo.json
times/
*.csv

248
Cargo.lock generated
View file

@ -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"

View file

@ -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"

View file

@ -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
}
```

View file

@ -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<Mutex<HashMap<TripletKey, Vec<BestCarRecord>>>>,
}
```
**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<String, (BestCarRecord, usize)>,
pub guid_to_rid: HashMap<String, String>,
pub car_map: HashMap<String, Car>,
pub car_list: Vec<Car>,
}
```
##### `MyCarsData`
**Parsed user data from `my_cars.json`**
```rust
#[derive(Debug)]
pub struct MyCarsData {
pub owned: HashSet<String>, // Owned car GUIDs
pub wishlist: HashSet<String>, // 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<MyCarsData, AppError>`
**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<Car>) -> Result<CarCounts, AppError>`
**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<Car>`
**Loads complete car database from repository file**
```rust
fn get_car_list() -> Vec<Car>
```
**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<String>`
**Loads user's car collection from game export**
```rust
fn get_my_car_list() -> Vec<String>
```
**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<Mutex<HashMap<TripletKey, Vec<BestCarRecord>>>>
```
**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 | `<track> <condition> <rq>` |
#### Key Internal Functions
##### `run_analysis(owned: &HashSet<String>) -> 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<String>, 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<Car>`
**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**

View file

@ -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<T, AppError>` 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<Mutex<HashMap<TripletKey, Vec<BestCarRecord>>>>
struct AnalysisContext {
best_cars: Arc<Mutex<HashMap<TripletKey, Vec<BestCarRecord>>>>,
}
```
**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<String, (BestCarRecord, usize)>,
guid_to_rid: HashMap<String, String>,
car_map: HashMap<String, Car>,
car_list: Vec<Car>,
}
```
**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

View file

@ -235,15 +235,10 @@ const MAX_RQ: u8 = 113;
**Car Database Processing:**
```rust
// Parse cars_repo.json
let cars_data: Vec<Value> = 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<Car> = 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<TripletKey, Vec<BestCarRecord>>`): Contains all best records across RQ ranges
- Individual timing files: Loaded and parsed sequentially via `spawn_blocking`
- User collection: Small, loaded once
**Optimization Strategies**:

396
plans/code-review.md Normal file
View file

@ -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<Mutex>`. Consider having `parse_car_times` accept `&Arc<Mutex<...>>` 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<T, E>` 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<HashMap>` 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<Mutex<HashMap<TripletKey, Vec<BestCarRecord>>>>
```
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

3
rustfmt.toml Normal file
View file

@ -0,0 +1,3 @@
edition = "2021"
max_width = 100
use_small_heuristics = "Max"

32
src/commands/all.rs Normal file
View file

@ -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(())
}

19
src/commands/best.rs Normal file
View file

@ -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(())
}

18
src/commands/details.rs Normal file
View file

@ -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(())
}

48
src/commands/fetch.rs Normal file
View file

@ -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(())
}

63
src/commands/mod.rs Normal file
View file

@ -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<MyCarsData, AppError> {
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<String>,
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))
}

22
src/commands/track.rs Normal file
View file

@ -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)
}

17
src/commands/trash.rs Normal file
View file

@ -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(())
}

25
src/commands/wishlist.rs Normal file
View file

@ -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(())
}

View file

@ -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<DashMap>` 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<Mutex<...>>` for concurrent access from spawned tasks.
pub best_cars: Arc<Mutex<HashMap<TripletKey, Vec<BestCarRecord>>>>,
/// Best car records for each track/RQ/surface combination.
/// Wrapped in `Arc` so it can be shared across async tasks.
pub best_cars: Arc<DashMap<TripletKey, Vec<BestCarRecord>>>,
}
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<Car>) -> 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<Car>) -> Result<CarCounts, AppError> {
let mut by_rid: HashMap<String, (BestCarRecord, usize)> = HashMap::new();
let mut guid_to_rid: HashMap<String, String> = 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<Car>) -> 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<String>,
@ -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<MyCarsData, AppError> {
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<String> = 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}"),
}
}
}

649
src/output/mod.rs Normal file
View file

@ -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<T, G>(
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<usize> = (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<String>) -> 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<BestCarRecord>)> = 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<String>,
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<String>,
wishlist: &HashSet<String>,
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<String> = 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<String> = 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<Car> = (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<String> = 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<String> = 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<String> = 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<String> = HashSet::new();
let wishlist = compute_ideal_wishlist(&counts, &no_owned, 10, 300);
assert_eq!(wishlist.len(), 1, "No owned → wishlist includes the car");
}
}

47
src/parsing/car_list.rs Normal file
View file

@ -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<Car>` 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<Vec<Car>, 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}"),
}
}
}

652
src/parsing/mod.rs Normal file
View file

@ -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<String>,
best_cars: &DashMap<TripletKey, Vec<BestCarRecord>>,
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<TripletKey, Vec<BestCarRecord>>,
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<HashSet<String>>` to avoid cloning the entire owned-car set on
/// every call.
pub async fn parse_all_times(
my_cars: Arc<HashSet<String>>,
ctx: &AnalysisContext,
cars_db_path: &str,
times_dir: &str,
) -> Result<Vec<Car>, 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, &times_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, &times_dir).await
}));
}
let results = join_all(handles).await;
let mut first_error: Option<AppError> = 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<String> = 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<String> = 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<String> = 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<String> = 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);
}
}

View file

@ -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<String>) -> (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<String>) -> (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<T, G>(
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<usize> = (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<String>) -> 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<String>,
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<String>,
wishlist: &HashSet<String>,
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<BestCarRecord>)> = {
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<String>, 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<std::sync::Mutex<HashMap<TripletKey, Vec<BestCarRecord>>>>,
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<String>, ctx: &AnalysisContext) -> Vec<Car> {
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<String> = 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<Car>` 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<Car> {
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<Value> = 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<Car> = 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"));
}
}

362
tests/integration_tests.rs Normal file
View file

@ -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<Car> = 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);
}

169
tests/proptest_tests.rs Normal file
View file

@ -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<Value = Car> {
(
"[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::<bool>(), // 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('"'));
}
}