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:
parent
9d57b18a4e
commit
a9a46ee551
24 changed files with 3541 additions and 1470 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -4,3 +4,4 @@ my_cars.json
|
|||
cars_repo.json
|
||||
tracks_repo.json
|
||||
times/
|
||||
*.csv
|
||||
|
|
|
|||
248
Cargo.lock
generated
248
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
```
|
||||
|
|
|
|||
700
docs/API.md
700
docs/API.md
|
|
@ -6,543 +6,267 @@ This document provides comprehensive API documentation for the Top Drives Record
|
|||
|
||||
## Core Modules
|
||||
|
||||
### Records Module (`src/records.rs`)
|
||||
|
||||
Primary analysis engine for processing JSON-based timing data.
|
||||
|
||||
#### Public Functions
|
||||
|
||||
##### `main()`
|
||||
**Entry point for the records analyzer**
|
||||
|
||||
```rust
|
||||
#[tokio::main]
|
||||
async fn main()
|
||||
```
|
||||
|
||||
**Purpose**: Command-line interface and operation dispatcher
|
||||
|
||||
**Arguments**: Command line arguments
|
||||
- `args[1]`: Command (`fetch`, `best`, `missing`, `trash`, `details`, `track`, `full_script`)
|
||||
|
||||
**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
|
||||
|
||||
**Example**:
|
||||
```bash
|
||||
cargo run --bin records -- best
|
||||
```
|
||||
|
||||
##### `print_best_cars(filter_out_owned: bool)`
|
||||
**Displays ranked list of best performing cars**
|
||||
|
||||
```rust
|
||||
fn print_best_cars(filter_out_owned: bool)
|
||||
```
|
||||
|
||||
**Purpose**: Aggregates performance data across all tracks and conditions
|
||||
|
||||
**Arguments**:
|
||||
- `filter_out_owned`: If true, excludes owned cars from output
|
||||
|
||||
**Output Format**:
|
||||
```csv
|
||||
RQ,Brand,Model,Year,Count
|
||||
85,McLaren,F1,1993,15
|
||||
82,Ferrari,250 GTO,1962,12
|
||||
```
|
||||
|
||||
**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
|
||||
|
||||
##### `print_best_times()`
|
||||
**Exports comprehensive timing data for all combinations**
|
||||
|
||||
```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.
|
||||
Common types and utilities shared across the project.
|
||||
|
||||
##### `translate_track_name(track_code: &str) -> &str`
|
||||
**Converts track codes to human-readable names**
|
||||
#### Public Types
|
||||
|
||||
##### `Car`
|
||||
**Core car information structure**
|
||||
|
||||
```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
|
||||
#[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
|
||||
}
|
||||
```
|
||||
|
||||
#### `Car`
|
||||
**Car metadata from database**
|
||||
**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
|
||||
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
|
||||
#[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
|
||||
}
|
||||
```
|
||||
|
||||
#### `BestCarRecord`
|
||||
**Performance record for best cars**
|
||||
##### `TripletKey`
|
||||
**Composite key for unique track conditions**
|
||||
|
||||
```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);
|
||||
pub 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**
|
||||
##### `AnalysisContext`
|
||||
**Mutable analysis state**
|
||||
|
||||
```rust
|
||||
static BEST_CARS: Lazy<Mutex<HashMap<TripletKey, Vec<BestCarRecord>>>>
|
||||
pub struct AnalysisContext {
|
||||
pub best_cars: Arc<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
|
||||
**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`)
|
||||
|
||||
## 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
|
||||
##### `CarCounts`
|
||||
**Aggregated per-car win counts**
|
||||
|
||||
```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();
|
||||
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>,
|
||||
}
|
||||
```
|
||||
|
||||
### Track-Specific Analysis
|
||||
##### `MyCarsData`
|
||||
**Parsed user data from `my_cars.json`**
|
||||
|
||||
```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);
|
||||
#[derive(Debug)]
|
||||
pub struct MyCarsData {
|
||||
pub owned: HashSet<String>, // Owned car GUIDs
|
||||
pub wishlist: HashSet<String>, // Wishlist car GUIDs
|
||||
}
|
||||
```
|
||||
|
||||
### Data Fetching
|
||||
##### `AppError`
|
||||
**Unified error type**
|
||||
|
||||
```rust
|
||||
// Download fresh data
|
||||
get_cars_file().await;
|
||||
get_tracks_file().await;
|
||||
fetch_times().await;
|
||||
#[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),
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Characteristics
|
||||
#### Public Functions
|
||||
|
||||
### 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
|
||||
##### `translate_track_name(track_code: &str) -> String`
|
||||
**Translates internal track identifiers to human-readable names**
|
||||
|
||||
### Memory Usage
|
||||
- **Peak**: ~200-300MB during full analysis
|
||||
- **Baseline**: ~50MB application overhead
|
||||
- **Per Car**: ~1-2KB metadata + timing data
|
||||
- **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!`)
|
||||
|
||||
### Concurrency
|
||||
- **Downloads**: 20 concurrent connections max (semaphore bounded)
|
||||
- **Processing**: One task per car file
|
||||
- **Thread Safety**: Mutex-protected shared state
|
||||
##### `translate_track_type(track_code: &str) -> &'static str`
|
||||
**Translates condition codes to surface names**
|
||||
|
||||
## Extension Points
|
||||
- **Input**: Condition code (e.g., `"00"`)
|
||||
- **Output**: Surface name (e.g., `"Dry"`)
|
||||
- **Fallback**: Returns `"Unknown"` for unrecognized codes
|
||||
|
||||
### Adding New Commands
|
||||
1. Add command parsing in `main()`
|
||||
2. Implement analysis function
|
||||
3. Update help documentation
|
||||
##### `get_file(file_url: &str, local_file_path: &str) -> Result<(), AppError>`
|
||||
**Downloads a file from a URL and saves it locally**
|
||||
|
||||
### 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
|
||||
- Removes existing file before downloading
|
||||
- Returns `AppError::Network` on HTTP failures
|
||||
- Returns `AppError::Io` on file write failures
|
||||
|
||||
### Custom Analysis Types
|
||||
1. Create new output formatting functions
|
||||
2. Add aggregation algorithms
|
||||
3. Extend data structures as needed
|
||||
##### `load_my_cars_data(path: &str) -> Result<MyCarsData, AppError>`
|
||||
**Loads and parses user car collection data**
|
||||
|
||||
## Security Considerations
|
||||
- **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
|
||||
|
||||
### Network Security
|
||||
- HTTPS-only external requests
|
||||
- Certificate validation (with fallback)
|
||||
- No sensitive data transmission
|
||||
##### `compare_cars(a: &Car, b: &Car) -> std::cmp::Ordering`
|
||||
**Sorting comparator for cars**
|
||||
|
||||
### File System Security
|
||||
- Read-only access to game files
|
||||
- No modification of user data
|
||||
- Safe file path handling
|
||||
- Sorts by: RQ → Brand → Model name
|
||||
|
||||
### Input Validation
|
||||
- Command injection prevention
|
||||
- Path traversal protection
|
||||
- Input sanitization
|
||||
##### `compute_car_counts(ctx: &AnalysisContext, car_list: Vec<Car>) -> Result<CarCounts, AppError>`
|
||||
**Aggregates per-car win counts from analysis context**
|
||||
|
||||
## Troubleshooting API Issues
|
||||
- Returns `CarCounts` with by_rid, guid_to_rid, car_map, and car_list
|
||||
|
||||
### Common Problems
|
||||
##### `escape_csv_field(field: &str) -> String`
|
||||
**Escapes a field for safe CSV inclusion**
|
||||
|
||||
**"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**
|
||||
|
|
|
|||
|
|
@ -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,6 +78,7 @@ type TripletKey = (String, u8, String);
|
|||
|
||||
#### Car
|
||||
```rust
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct Car {
|
||||
rid: String, // Repository ID
|
||||
brand: String, // Manufacturer
|
||||
|
|
@ -85,20 +101,36 @@ struct BestCarRecord {
|
|||
config: String, // Tune config (111, 233, etc.)
|
||||
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
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
396
plans/code-review.md
Normal 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
3
rustfmt.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
edition = "2021"
|
||||
max_width = 100
|
||||
use_small_heuristics = "Max"
|
||||
32
src/commands/all.rs
Normal file
32
src/commands/all.rs
Normal 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
19
src/commands/best.rs
Normal 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
18
src/commands/details.rs
Normal 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
48
src/commands/fetch.rs
Normal 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
63
src/commands/mod.rs
Normal 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
22
src/commands/track.rs
Normal 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
17
src/commands/trash.rs
Normal 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
25
src/commands/wishlist.rs
Normal 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(())
|
||||
}
|
||||
402
src/lib.rs
402
src/lib.rs
|
|
@ -10,6 +10,7 @@
|
|||
//! - **`AnalysisContext` struct** — holds mutable analysis state (replaces global static)
|
||||
//! - **`CarCounts` struct** — aggregated per-car win counts
|
||||
//! - **`MyCarsData` struct** — parsed user data from `my_cars.json`
|
||||
//! - **`AppError` enum** — unified error type for the application
|
||||
//! - **`translate_track_name()`** — internal track code → human-readable name
|
||||
//! - **`translate_track_type()`** — condition code → surface name
|
||||
//! - **`get_file()`** — async file download utility
|
||||
|
|
@ -21,26 +22,63 @@ use std::collections::{HashMap, HashSet};
|
|||
use std::fs;
|
||||
use std::fs::File;
|
||||
use std::io::BufReader;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use dashmap::DashMap;
|
||||
use reqwest::get;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
/// Custom error type for the Top Drives application.
|
||||
///
|
||||
/// Provides a unified error handling strategy across all modules,
|
||||
/// replacing the mix of `Result<(), String>`, `std::io::Result`, and
|
||||
/// silent `eprintln!` + early return patterns.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppError {
|
||||
/// An I/O error occurred (file not found, permission denied, etc.)
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
/// A network error occurred during data fetching
|
||||
#[error("Network error: {0}")]
|
||||
Network(String),
|
||||
|
||||
/// A JSON parsing error in a specific file
|
||||
#[error("Parse error in {file}: {reason}")]
|
||||
Parse {
|
||||
/// The file that failed to parse
|
||||
file: String,
|
||||
/// The reason for the parse failure
|
||||
reason: String,
|
||||
},
|
||||
|
||||
/// A required data file is missing
|
||||
#[error("Missing data file: {0}")]
|
||||
MissingFile(String),
|
||||
|
||||
/// An internal runtime error (e.g., semaphore closed, task join failure)
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
/// Core car information structure containing basic car metadata.
|
||||
/// Used throughout the application to represent car entities.
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Car {
|
||||
/// Unique car identifier used in the records database
|
||||
pub rid: String,
|
||||
/// Car manufacturer/brand name
|
||||
pub brand: String,
|
||||
/// Car model name (without brand)
|
||||
#[serde(rename = "onlyName")]
|
||||
pub only_name: String,
|
||||
/// Car model year
|
||||
pub year: u16,
|
||||
/// Rarity level (10-113, affects performance brackets)
|
||||
pub rq: u8,
|
||||
/// Whether this car is only available as a prize car
|
||||
#[serde(default)]
|
||||
pub prize: bool,
|
||||
/// Game-specific unique identifier for the car
|
||||
pub guid: String,
|
||||
|
|
@ -80,10 +118,13 @@ pub type TripletKey = (String, u8, String);
|
|||
///
|
||||
/// Replaces the former global `static BEST_CARS`. Created in `main()` and
|
||||
/// passed through the call chain so that no global mutable state is needed.
|
||||
///
|
||||
/// Uses `Arc<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;
|
||||
|
||||
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}")
|
||||
));
|
||||
}
|
||||
match response.text().await {
|
||||
Ok(content) => {
|
||||
fs::write(local_file_path, content)
|
||||
.map_err(|e| format!("Error writing {local_file_path}: {e}"))
|
||||
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;
|
||||
}
|
||||
Err(e) => Err(format!("Error reading response from {file_url}: {e}")),
|
||||
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
649
src/output/mod.rs
Normal 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
47
src/parsing/car_list.rs
Normal 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
652
src/parsing/mod.rs
Normal 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, ×_dir)
|
||||
}).await.unwrap_or_else(|e| Err(AppError::Internal(format!("Blocking task join error: {e}"))))
|
||||
});
|
||||
handles.push(handle);
|
||||
}
|
||||
|
||||
let results = join_all(handles).await;
|
||||
let mut parse_errors = 0usize;
|
||||
for result in results {
|
||||
match result {
|
||||
// Outer JoinError from tokio::spawn
|
||||
Err(e) => {
|
||||
eprintln!("Warning: parsing task failed: {e}");
|
||||
parse_errors += 1;
|
||||
}
|
||||
// parse_car_times or semaphore returned an error
|
||||
Ok(Err(e)) => {
|
||||
eprintln!("Warning: {e}");
|
||||
parse_errors += 1;
|
||||
}
|
||||
// parse_car_times succeeded
|
||||
Ok(Ok(())) => {}
|
||||
}
|
||||
}
|
||||
if parse_errors > 0 {
|
||||
eprintln!("Warning: {parse_errors} car(s) had parse errors during analysis");
|
||||
}
|
||||
|
||||
// If every car failed to parse, the data is unusable — propagate the error.
|
||||
if parse_errors == car_list.len() && !car_list.is_empty() {
|
||||
return Err(AppError::Parse {
|
||||
file: times_dir.to_string(),
|
||||
reason: format!("All {parse_errors} car(s) failed to parse — data directory may be corrupted"),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(car_list)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data fetching
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Remote URL for the per-car timing API.
|
||||
const CAR_TIMING_API_BASE: &str = "https://api.topdrivesrecords.com/car/";
|
||||
|
||||
/// Fetches timing data files for all cars using controlled concurrency.
|
||||
///
|
||||
/// Returns the first error encountered, or `Ok(())` if all downloads succeeded.
|
||||
pub async fn fetch_times(cars_db_path: &str, times_dir: &str) -> Result<(), AppError> {
|
||||
let semaphore = Arc::new(Semaphore::new(CONCURRENCY_LIMIT));
|
||||
let mut handles = vec![];
|
||||
|
||||
for car in get_car_list(cars_db_path)? {
|
||||
let permit = semaphore.clone().acquire_owned().await
|
||||
.map_err(|e| AppError::Network(format!("Semaphore closed: {e}")))?;
|
||||
let times_dir = times_dir.to_string();
|
||||
handles.push(tokio::spawn(async move {
|
||||
let _permit = permit; // held until end of block
|
||||
fetch_car(&car.rid, ×_dir).await
|
||||
}));
|
||||
}
|
||||
|
||||
let results = join_all(handles).await;
|
||||
let mut first_error: Option<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);
|
||||
}
|
||||
}
|
||||
878
src/records.rs
878
src/records.rs
|
|
@ -1,8 +1,14 @@
|
|||
//! # Top Drives Records Analyzer
|
||||
//!
|
||||
//! A comprehensive Rust-based tool for analyzing car performance data in the Top Drives racing game.
|
||||
//! This module provides functionality to fetch, parse, and analyze track timing data from a
|
||||
//! community-maintained repository.
|
||||
//! This module provides the CLI entry point and delegates to submodules for parsing, output, and
|
||||
//! command handling.
|
||||
//!
|
||||
//! ## Module Structure
|
||||
//!
|
||||
//! - [`commands`] — Command handlers (fetch, best, trash, wishlist, details, track)
|
||||
//! - [`parsing`] — Core timing data parsing and car database loading
|
||||
//! - [`output`] — CSV output utilities and sorting helpers
|
||||
//!
|
||||
//! ## Key Features
|
||||
//!
|
||||
|
|
@ -26,34 +32,56 @@
|
|||
//! - `serde_json`: JSON parsing for car and track data
|
||||
//! - `tokio`: Asynchronous runtime for concurrent processing
|
||||
//! - `futures`: Future utilities for handling async operations
|
||||
//! - `clap`: Command-line argument parsing
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::sync::Arc;
|
||||
mod commands;
|
||||
mod output;
|
||||
mod parsing;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use tokio::sync::Semaphore;
|
||||
use tokio::task;
|
||||
|
||||
use futures::future::join_all;
|
||||
use top_drives::AppError;
|
||||
|
||||
use serde_json::Value;
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants (CLI defaults)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use top_drives::{
|
||||
AnalysisContext, BestCarRecord, Car, CarCounts, TripletKey,
|
||||
compare_cars, compute_car_counts, get_file, load_my_cars_data,
|
||||
translate_track_name, translate_track_type,
|
||||
};
|
||||
/// Default path for the user's car collection export.
|
||||
const DEFAULT_MY_CARS_PATH: &str = "my_cars.json";
|
||||
|
||||
/// Maximum RQ level in the Top Drives database.
|
||||
/// Used as the upper bound when iterating RQ ranges and as a fallback for parse errors.
|
||||
const MAX_RQ: u8 = 113;
|
||||
/// Default path for the car database file.
|
||||
const DEFAULT_CARS_DB_PATH: &str = "cars_repo.json";
|
||||
|
||||
/// Default directory for per-car timing JSON files.
|
||||
const DEFAULT_TIMES_DIR: &str = "times";
|
||||
|
||||
/// Default path for the tracks database file.
|
||||
const DEFAULT_TRACKS_DB_PATH: &str = "tracks_repo.json";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI definition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Command-line interface for the Top Drives Records Analyzer.
|
||||
#[derive(Parser)]
|
||||
#[command(name = "records", about = "Top Drives Records Analyzer", version)]
|
||||
struct Cli {
|
||||
/// Path to the user's car collection export file
|
||||
#[arg(long, default_value = DEFAULT_MY_CARS_PATH)]
|
||||
my_cars: String,
|
||||
|
||||
/// Path to the car database file
|
||||
#[arg(long, default_value = DEFAULT_CARS_DB_PATH)]
|
||||
cars_db: String,
|
||||
|
||||
/// Directory containing per-car timing JSON files
|
||||
#[arg(long, default_value = DEFAULT_TIMES_DIR)]
|
||||
times_dir: String,
|
||||
|
||||
/// Path to the tracks database file
|
||||
#[arg(long, default_value = DEFAULT_TRACKS_DB_PATH)]
|
||||
tracks_db: String,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
|
|
@ -99,16 +127,9 @@ enum Commands {
|
|||
},
|
||||
}
|
||||
|
||||
/// Common analysis setup: loads user data, parses all timing files, and computes car counts.
|
||||
///
|
||||
/// Returns the analysis context (for stdout commands) and the aggregated car counts
|
||||
/// (for CSV output commands). The car list is stored inside `CarCounts`.
|
||||
async fn run_analysis(owned: &HashSet<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
362
tests/integration_tests.rs
Normal 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
169
tests/proptest_tests.rs
Normal 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('"'));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue