diff --git a/CHANGELOG.md b/CHANGELOG.md index 94d29de..bc8021c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.0] - 2026-05-07 + +### Added +- New `all` command that generates all output CSV files in a single parse pass +- New `wishlist` command with `--min-count` and `--max-wishlist` flags for wishlist management +- `--min-count` flag on `best` command to configure very_best threshold (default: 10) +- `--max-wishlist` flag on `wishlist` and `all` commands (default: 300) +- `CarCounts` struct and `compute_car_counts()` function for shared analysis computation +- CSV file output: `best.csv`, `very_best.csv`, `wishlist.csv`, `add.csv`, `remove.csv` +- All output files now include CSV headers + +### Changed +- `best` command now writes to `best.csv` and `very_best.csv` instead of printing to stdout +- `trash` command now writes to `trash.csv` instead of printing to stdout +- All output files are sorted by car RQ (via `compare_cars()`) +- `wishlist` command now generates ideal wishlist, add/remove recommendations + +### Removed +- `missing` command (functionality absorbed into `wishlist`/`add`/`remove`) +- `+` prefix from output (no longer needed with dedicated wishlist commands) +- `-` prefix from output (replaced by dedicated `remove.csv`) +- `print_best_cars()`, `print_wishlist_stale()`, `print_trash()` functions + +## [0.2.0] - 2026-05-07 + ### Added - Comprehensive documentation suite in `docs/` directory - Detailed code comments for all public functions diff --git a/Cargo.lock b/Cargo.lock index caa5da2..5a4aefa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1310,7 +1310,7 @@ dependencies = [ [[package]] name = "top_drives" -version = "0.2.0" +version = "0.3.0" dependencies = [ "clap", "futures", diff --git a/Cargo.toml b/Cargo.toml index 5f1d636..3f60e1c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "top_drives" -version = "0.2.0" +version = "0.3.0" edition = "2021" [lib] diff --git a/README.md b/README.md index 6a5b77c..b0e5014 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # Top Drives Records Analyzer -A comprehensive Rust-based tool for analyzing car performance data in the Top Drives racing game. This tool fetches track times from a community-maintained repository and provides detailed analysis including best cars, missing cars, and optimization recommendations. +A comprehensive Rust-based tool for analyzing car performance data in the Top Drives racing game. This tool fetches track times from a community-maintained repository and provides detailed analysis including best cars, trash identification, and wishlist optimization. ## 🚀 Features - **Best Car Analysis**: Find the best performing cars across all tracks and conditions -- **Missing Cars Detection**: Identify cars missing from your collection that would improve performance -- **Collection Optimization**: Identify cars that can be safely removed from your collection +- **Wishlist Optimization**: Identify cars to add to or remove from your wishlist +- **Collection Cleanup**: Identify cars that can be safely removed from your collection - **Track-Specific Analysis**: Get detailed performance data for specific tracks and conditions - **RQ-Based Filtering**: Filter results by car rarity (RQ) levels - **Multiple Surface Types**: Support for Dry, Wet, Dirt, Snow, and other track conditions @@ -55,14 +55,14 @@ A comprehensive Rust-based tool for analyzing car performance data in the Top Dr cargo run --bin records -- fetch ``` -2. **Basic Analysis**: Find the best cars in your collection +2. **Full Analysis**: Generate all output files in a single pass ```bash - cargo run --bin records -- best + cargo run --bin records -- all ``` -3. **Find Missing Cars**: Discover cars that would improve your performance +3. **Best Cars**: Find the best cars across all tracks ```bash - cargo run --bin records -- missing + cargo run --bin records -- best ``` 4. **Collection Cleanup**: Identify cars safe to remove @@ -70,6 +70,11 @@ A comprehensive Rust-based tool for analyzing car performance data in the Top Dr cargo run --bin records -- trash ``` +5. **Wishlist Management**: Optimize your wishlist + ```bash + cargo run --bin records -- wishlist + ``` + ## 📖 Usage Guide ### Basic Commands @@ -84,33 +89,79 @@ Downloads the latest car database and track information from the community repos ```bash cargo run --bin records -- best ``` -Shows all cars in your collection ranked by their performance across tracks. +Generates `best.csv` (all cars best on at least one track) and `very_best.csv` (cars best on ≥10 tracks). Both files are sorted by RQ. -**Sample Output:** +**Output Files:** ``` -RQ,Brand,Model,Year,Count -85,McLaren,F1,1993,15 -82,Ferrari,250 GTO,1962,12 -80,Lamborghini,Countach,1974,8 +best.csv — All cars best on at least one track +very_best.csv — Cars best on ≥10 tracks (default) ``` -#### Missing Cars Analysis +Use `--min-count N` to change the threshold for `very_best.csv`: ```bash -cargo run --bin records -- missing +cargo run --bin records -- best --min-count 5 ``` -Shows the best cars that you don't own, helping prioritize acquisitions. -#### Collection Optimization +#### Trash Analysis ```bash cargo run --bin records -- trash ``` -Identifies cars in your collection that aren't among the best performers and could be removed. +Generates `trash.csv` — owned cars that are not best on any track, have RQ < 80, and are not prize cars. Sorted by RQ. + +**Output File:** +``` +trash.csv — Owned cars safe to remove +``` + +#### Wishlist Management +```bash +cargo run --bin records -- wishlist +``` +Generates `wishlist.csv` (ideal wishlist: very_best unowned cars, capped at 300), `add.csv` (cars that should be on your wishlist but aren't), and `remove.csv` (cars on your wishlist that shouldn't be). All sorted by RQ. + +**Output Files:** +``` +wishlist.csv — Ideal wishlist (very_best unowned, capped) +add.csv — Cars to add to wishlist +remove.csv — Cars to remove from wishlist +``` + +Use `--min-count N` to change the threshold for very_best cars (default 10): +```bash +cargo run --bin records -- wishlist --min-count 5 +``` + +Use `--max-wishlist M` to change the wishlist cap (default 300): +```bash +cargo run --bin records -- wishlist --max-wishlist 500 +``` + +#### Full Analysis +```bash +cargo run --bin records -- all +``` +Generates all 6 CSV files in a single parse pass. Equivalent to running `best`, `trash`, and `wishlist` together, but more efficient. + +**Output Files:** +``` +best.csv — All cars best on at least one track +very_best.csv — Cars best on ≥10 tracks +trash.csv — Owned cars safe to remove +wishlist.csv — Ideal wishlist (very_best unowned, capped) +add.csv — Cars to add to wishlist +remove.csv — Cars to remove from wishlist +``` + +Supports both `--min-count N` (default 10) and `--max-wishlist M` (default 300): +```bash +cargo run --bin records -- all --min-count 5 --max-wishlist 500 +``` #### Detailed Performance Data ```bash cargo run --bin records -- details ``` -Shows comprehensive timing data for all tracks and conditions. +Generates `details.csv` with comprehensive timing data for all tracks and conditions. **Sample Output:** ``` @@ -123,12 +174,12 @@ RQ,TrackID,Condition,CarRQ,Brand,Model,Year,Config,Time ```bash cargo run --bin records -- track "Laguna Seca" "Dry" "85" ``` -Finds the best car for a specific track, condition, and RQ combination. +Prints the best car for a specific track, condition, and RQ combination to stdout. ### Advanced Usage #### Multiple RQ Analysis -The tool supports analyzing performance across different RQ ranges. Use the `missing` and `best` commands to see how cars perform at various rarity levels. +The tool supports analyzing performance across different RQ ranges. Use the `best` and `wishlist` commands to see how cars perform at various rarity levels. #### Track Conditions The analyzer supports multiple track conditions: @@ -143,37 +194,49 @@ The analyzer supports multiple track conditions: | Command | Description | Arguments | |---------|-------------|-----------| | `fetch` | Download latest car and track data | None | -| `best` | Show best performing cars in collection | None | -| `missing` | Show best cars not in collection | None | -| `trash` | Show cars safe to remove from collection | None | -| `details` | Show detailed timing data for all tracks | None | -| `track` | Show best car for specific track | ` ` | +| `best` | Generate `best.csv` and `very_best.csv` | `[--min-count N]` | +| `trash` | Generate `trash.csv` (owned cars safe to remove) | None | +| `wishlist` | Generate `wishlist.csv`, `add.csv`, `remove.csv` | `[--min-count N] [--max-wishlist M]` | +| `all` | Generate all 6 CSV files in a single pass | `[--min-count N] [--max-wishlist M]` | +| `details` | Print detailed timing data for all tracks (stdout) | None | +| `track` | Print best car for specific track (stdout) | ` ` | + +### Flags + +| Flag | Default | Description | +|------|---------|-------------| +| `--min-count N` | `10` | Minimum number of tracks a car must be best on to qualify as "very best" | +| `--max-wishlist M` | `300` | Maximum number of cars on the ideal wishlist | ## 📅 Daily Workflow -The typical daily workflow uses `full_script` which runs `fetch` followed by all analysis commands in sequence: +The typical daily workflow uses the `all` command, which runs all analysis in a single efficient parse pass: ```bash -cargo run --bin records -- full_script +cargo run --bin records -- fetch +cargo run --bin records -- all ``` -This generates the following output files: +Or combine them: +```bash +cargo run --bin records -- fetch && cargo run --bin records -- all +``` -| File | Description | -|------|-------------| -| `best.csv` | Ranked list of best cars — used to evaluate if a car is worth keeping | -| `details.csv` | Detailed timing data for all tracks/conditions — used to find the best config for a car | -| `add.csv` | Cars to add to wishlist — missing cars not yet wishlisted, prefixed with `+` | -| `remove.csv` | Cars to remove from wishlist — stale wishlist entries, prefixed with `-` | -| `trash.csv` | Owned cars that can be safely removed | -| `sorted.csv` | Best cars sorted by count (descending) | -| `very_best.csv` | Best cars with count ≥ 10 | -| `wishlist.csv` | Missing cars already on wishlist | +This generates the following output files (all sorted by car RQ): + +| File | Format | Description | +|------|--------|-------------| +| `best.csv` | `RQ,Brand,Model,Year,Count` | All cars best on at least one track | +| `very_best.csv` | `RQ,Brand,Model,Year,Count` | Cars best on ≥ min_count tracks | +| `trash.csv` | `RQ,Brand,Model,Year` | Owned cars not best on any track (RQ<80, not prize) | +| `wishlist.csv` | `RQ,Brand,Model,Year,Count` | Ideal wishlist (very_best unowned, capped) | +| `add.csv` | `RQ,Brand,Model,Year,Count` | Cars to add to wishlist | +| `remove.csv` | `RQ,Brand,Model,Year,Count` | Cars to remove from wishlist | ### Workflow Steps 1. **Fetch** fresh data from the community repository -2. **Analyze** your collection against the latest timing data +2. **Analyze** your collection with `all` to generate all CSV files 3. **Review** `best.csv` to see how your cars rank 4. **Check** `add.csv` for cars worth adding to your wishlist 5. **Clean up** `remove.csv` entries from your wishlist diff --git a/TODO.md b/TODO.md deleted file mode 100644 index f523c50..0000000 --- a/TODO.md +++ /dev/null @@ -1,4 +0,0 @@ -1) **Move shell post-processing into Rust** — `full_script` does `sort`, `grep`, file splitting via shell. Add native subcommands (`very-best`, `add`, `remove`) or CLI flags (`--min-count`, `--sort-by`) so the binary produces the final files directly. Modify the script to export to files, nothing else. -2) **Correct `+` prefix semantics** — `+` means "not on wishlist". It is not needed in `best`/`missing` output but is used as "add to wishlist". Since we incorporated the wishlist actions into the application, the prefix can go. -3) **Add `all` subcommand** — `full_script` runs 5 commands sequentially, each parsing all timing files independently. A single `all` command would parse once and write all outputs, cutting runtime ~80%. -4) **Document the daily workflow** — README and USER_GUIDE don't explain `full_script` or the relationship between Rust output and the files (`sorted.csv`, `very_best.csv`, `wishlist.csv`, `add.csv`). diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 2981d68..fcd26b8 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -18,9 +18,10 @@ This guide provides comprehensive instructions for using the Top Drives Records **Export your car collection from Top Drives:** -1. Open Top Drives on your device -2. Go to Settings → Account → Export Data -3. Save the file as `my_cars.json` in the project directory +1. Set up HTTP interception (HTTP Toolkit) +2. Open Top Drives on an emulator (Bluestacks) +3. Intercept transmission of your game data +4. Save to file `my_cars.json` in the project directory ### 2. Initial Data Download @@ -55,9 +56,19 @@ Fetching https://api.topdrivesrecords.com/car/1002.json. cargo run --bin records -- best ``` -**Purpose:** Shows the best performing cars in your collection, ranked by how often they achieve top times. +**Purpose:** Generates `best.csv` (all cars best on at least one track) and `very_best.csv` (cars best on ≥10 tracks). Both files are sorted by RQ. -**Sample Output:** +**Flags:** +- `--min-count N` — Minimum number of tracks a car must be best on to qualify as "very best" (default: 10) + +**Example with custom threshold:** +```bash +cargo run --bin records -- best --min-count 5 +``` + +**Output Files:** + +`best.csv`: ```csv RQ,Brand,Model,Year,Count 85,McLaren,F1,1993,15 @@ -66,6 +77,13 @@ RQ,Brand,Model,Year,Count 75,Porsche,911 Turbo,1975,6 ``` +`very_best.csv`: +```csv +RQ,Brand,Model,Year,Count +85,McLaren,F1,1993,15 +82,Ferrari,250 GTO,1962,12 +``` + **Understanding the output:** - **RQ**: Car's rarity level - **Brand**: Car manufacturer @@ -78,38 +96,18 @@ RQ,Brand,Model,Year,Count - Focus on cars with counts ≥ 5 for your core collection - Compare with your owned cars to identify strengths -#### Missing Cars Analysis - -**Command:** -```bash -cargo run --bin records -- missing -``` - -**Purpose:** Identifies high-performing cars you don't own that would improve your collection. - -**Sample Output:** -```csv -RQ,Brand,Model,Year,Count -90,Pagani,Zonda R,2010,18 -88,Ferrari,F40,1987,15 -87,McLaren,Senna,2018,12 -``` - -**Action items:** -- Prioritize cars with high counts (≥10) -- Consider RQ restrictions in your racing bracket -- Look for cars that perform well across multiple track types - -#### Collection Optimization (Trash Analysis) +#### Trash Analysis **Command:** ```bash cargo run --bin records -- trash ``` -**Purpose:** Identifies cars safe to remove from your collection. +**Purpose:** Generates `trash.csv` — owned cars that are not best on any track, have RQ < 80, and are not prize cars. Sorted by RQ. -**Sample Output:** +**Output File:** + +`trash.csv`: ```csv RQ,Brand,Model,Year 45,Toyota,Corolla,1995 @@ -123,6 +121,84 @@ RQ,Brand,Model,Year - Only suggests cars that never achieve best times - **Always verify before selling in-game** +#### Wishlist Management + +**Command:** +```bash +cargo run --bin records -- wishlist +``` + +**Purpose:** Generates three files for wishlist optimization: +- `wishlist.csv` — Ideal wishlist: very_best unowned cars, capped at 300 +- `add.csv` — Cars that should be on your wishlist but aren't +- `remove.csv` — Cars on your wishlist that shouldn't be + +All files are sorted by RQ. + +**Flags:** +- `--min-count N` — Minimum number of tracks for "very best" qualification (default: 10) +- `--max-wishlist M` — Maximum number of cars on the ideal wishlist (default: 300) + +**Example with custom flags:** +```bash +cargo run --bin records -- wishlist --min-count 5 --max-wishlist 500 +``` + +**Output Files:** + +`wishlist.csv`: +```csv +RQ,Brand,Model,Year,Count +90,Pagani,Zonda R,2010,18 +88,Ferrari,F40,1987,15 +87,McLaren,Senna,2018,12 +``` + +`add.csv`: +```csv +RQ,Brand,Model,Year,Count +90,Pagani,Zonda R,2010,18 +88,Ferrari,F40,1987,15 +``` + +`remove.csv`: +```csv +RQ,Brand,Model,Year,Count +75,BMW,M3,2003,3 +72,Audi,RS6,2002,2 +``` + +**Action items:** +- Add cars from `add.csv` to your in-game wishlist +- Remove cars from `remove.csv` from your in-game wishlist +- Use `wishlist.csv` as your target wishlist + +#### Full Analysis + +**Command:** +```bash +cargo run --bin records -- all +``` + +**Purpose:** Generates all 6 CSV files in a single parse pass. Equivalent to running `best`, `trash`, and `wishlist` together, but more efficient since data is only parsed once. + +**Flags:** +- `--min-count N` — Minimum number of tracks for "very best" qualification (default: 10) +- `--max-wishlist M` — Maximum number of cars on the ideal wishlist (default: 300) + +**Example with custom flags:** +```bash +cargo run --bin records -- all --min-count 5 --max-wishlist 500 +``` + +**Output Files:** +- `best.csv` — All cars best on at least one track +- `very_best.csv` — Cars best on ≥ min_count tracks +- `trash.csv` — Owned cars safe to remove +- `wishlist.csv` — Ideal wishlist (very_best unowned, capped) +- `add.csv` — Cars to add to wishlist +- `remove.csv` — Cars to remove from wishlist + #### Detailed Performance Data **Command:** @@ -130,7 +206,7 @@ RQ,Brand,Model,Year cargo run --bin records -- details ``` -**Purpose:** Comprehensive timing data for all tracks and conditions. +**Purpose:** Generates `details.csv` with comprehensive timing data for all tracks and conditions. **Sample Output:** ```csv @@ -152,7 +228,7 @@ RQ,TrackID,Condition,CarRQ,Brand,Model,Year,Config,Time cargo run --bin records -- track "Laguna Seca" "Dry" "85" ``` -**Purpose:** Find the best car for a specific track, condition, and RQ level. +**Purpose:** Prints the best car for a specific track, condition, and RQ level to stdout. **Sample Output:** ``` @@ -169,42 +245,53 @@ Best car for Track: laguna, Condition: Dry, RQ: 85 -> McLaren F1 (1993) with tim - `Dirt` - Off-road tracks - `Snow` - Winter conditions +### Command Flags Reference + +| Flag | Applicable Commands | Default | Description | +|------|-------------------|---------|-------------| +| `--min-count N` | `best`, `wishlist`, `all` | `10` | Minimum tracks a car must be best on to qualify as "very best" | +| `--max-wishlist M` | `wishlist`, `all` | `300` | Maximum number of cars on the ideal wishlist | + ## Daily Workflow -The recommended daily workflow uses the `full_script` command, which runs `fetch` followed by all analysis commands in a single pipeline: +The recommended daily workflow uses the `all` command, which runs all analysis in a single efficient parse pass: ```bash -cargo run --bin records -- full_script +cargo run --bin records -- fetch +cargo run --bin records -- all ``` -### What `full_script` Does +Or combine them: +```bash +cargo run --bin records -- fetch && cargo run --bin records -- all +``` -1. **Fetches** fresh data from the community repository -2. **Runs** all analysis commands sequentially -3. **Generates** all output CSV files +### What `all` Does + +1. **Parses** all timing data in a single pass +2. **Generates** all 6 output CSV files +3. **Sorts** all files by car RQ ### Output Files -| File | Purpose | Format | -|------|---------|--------| -| `best.csv` | Ranked list of best cars — evaluate if a car is worth keeping | `RQ,Brand,Model,Year,Count` | -| `details.csv` | Detailed timing data for all tracks/conditions — find best config for a car | `RQ,TrackID,Condition,CarRQ,Brand,Model,Year,Config,Time` | -| `add.csv` | Cars to add to wishlist — missing cars not yet wishlisted, prefixed with `+` | `+RQ,Brand,Model,Year` | -| `remove.csv` | Cars to remove from wishlist — stale wishlist entries, prefixed with `-` | `-RQ,Brand,Model,Year` | -| `trash.csv` | Owned cars that can be safely removed | `RQ,Brand,Model,Year` | -| `sorted.csv` | Best cars sorted by count (descending) | `RQ,Brand,Model,Year,Count` | -| `very_best.csv` | Best cars with count ≥ 10 | `RQ,Brand,Model,Year,Count` | -| `wishlist.csv` | Missing cars already on wishlist | `RQ,Brand,Model,Year,Count` | +| File | Format | Description | +|------|--------|-------------| +| `best.csv` | `RQ,Brand,Model,Year,Count` | All cars best on at least one track | +| `very_best.csv` | `RQ,Brand,Model,Year,Count` | Cars best on ≥ min_count tracks | +| `trash.csv` | `RQ,Brand,Model,Year` | Owned cars not best on any track (RQ<80, not prize) | +| `wishlist.csv` | `RQ,Brand,Model,Year,Count` | Ideal wishlist (very_best unowned, capped) | +| `add.csv` | `RQ,Brand,Model,Year,Count` | Cars to add to wishlist | +| `remove.csv` | `RQ,Brand,Model,Year,Count` | Cars to remove from wishlist | ### Typical Daily Routine -1. **Run `full_script`** to get fresh data and full analysis -2. **Review `best.csv`** to see how your cars rank overall -3. **Check `add.csv`** for cars worth adding to your wishlist (prefixed with `+`) -4. **Check `remove.csv`** for wishlist entries to remove (prefixed with `-`) -5. **Review `trash.csv`** for owned cars you can safely sell -6. **Use `details.csv`** to find the optimal tune config for a specific car -7. **Check `very_best.csv`** for the absolute top performers (count ≥ 10) +1. **Run `fetch`** to get fresh data from the community repository +2. **Run `all`** to generate all analysis files in one pass +3. **Review `best.csv`** to see how your cars rank overall +4. **Check `add.csv`** for cars worth adding to your wishlist +5. **Check `remove.csv`** for wishlist entries to remove +6. **Review `trash.csv`** for owned cars you can safely sell +7. **Check `very_best.csv`** for the absolute top performers ## Advanced Usage @@ -232,9 +319,9 @@ cargo run --bin records -- best - Note RQ distribution - Find gaps in coverage -#### Step 2: Identify Priorities +#### Step 2: Identify Wishlist Priorities ```bash -cargo run --bin records -- missing +cargo run --bin records -- wishlist ``` - Focus on high-count cars in your RQ range - Consider track variety needs @@ -271,30 +358,38 @@ Different cars excel on different track types: ### CSV File Usage -The tool generates CSV files for external analysis: +The tool generates CSV files for external analysis. All files are sorted by car RQ. #### best.csv -- Your best performing cars +- All cars that are best on at least one track (owned and unowned) - Import into spreadsheet for sorting/filtering - Track performance across time -#### details.csv -- Comprehensive timing database -- Advanced analysis and research -- Historical performance tracking - -#### add.csv / remove.csv -- Wishlist management -- `+` prefix indicates cars to add -- `-` prefix indicates cars to remove +#### very_best.csv +- Cars best on ≥ min_count tracks (default ≥10) +- These are the most versatile performers +- Use `--min-count N` to adjust the threshold #### trash.csv -- Cars safe to sell from your collection +- Owned cars safe to sell from your collection +- Only includes cars with RQ < 80 that are not best on any track +- Excludes prize cars - Cross-reference with your garage before selling -#### sorted.csv / very_best.csv -- Sorted rankings by versatility -- `very_best.csv` filters to count ≥ 10 for top-tier recommendations +#### wishlist.csv +- Ideal wishlist: very_best cars you don't own, capped at max-wishlist (default 300) +- Sorted by RQ +- Use as your target wishlist + +#### add.csv +- Cars that should be on your wishlist but aren't +- These are very_best unowned cars not currently on your wishlist +- Add these to your in-game wishlist + +#### remove.csv +- Cars currently on your wishlist that shouldn't be +- These are cars on your wishlist that don't qualify as very_best +- Remove these from your in-game wishlist ### Spreadsheet Analysis Tips @@ -310,7 +405,7 @@ The tool generates CSV files for external analysis: #### "Please provide a command" **Problem:** No arguments provided -**Solution:** Use `best`, `missing`, `trash`, `details`, `track`, or `full_script` +**Solution:** Use `best`, `trash`, `wishlist`, `all`, `details`, `track`, or `fetch` #### "Could not read my cars file" **Problem:** `my_cars.json` not found or not readable @@ -348,8 +443,8 @@ The tool generates CSV files for external analysis: ## Tips and Best Practices ### Regular Maintenance -1. **Daily**: Run `full_script` to get latest data and full analysis -2. **Weekly**: Review `missing` cars for new acquisitions +1. **Daily**: Run `fetch` then `all` to get latest data and full analysis +2. **Weekly**: Review `wishlist` output for new acquisitions 3. **Monthly**: Use `trash` to optimize collection ### Data-Driven Decisions diff --git a/src/lib.rs b/src/lib.rs index b15e1d6..44504d9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,12 +8,14 @@ //! - **`BestCarRecord` struct** — best performing car record for a specific track/RQ/condition //! - **`TripletKey` type alias** — composite key `(track_id, rq, surface)` //! - **`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` //! - **`translate_track_name()`** — internal track code → human-readable name //! - **`translate_track_type()`** — condition code → surface name //! - **`get_file()`** — async file download utility //! - **`load_my_cars_data()`** — loads and parses `my_cars.json` //! - **`compare_cars()`** — sorting comparator for `Car` +//! - **`compute_car_counts()`** — aggregates per-car win counts from analysis context use std::collections::{HashMap, HashSet}; use std::fs; @@ -64,6 +66,8 @@ pub struct BestCarRecord { pub time: f64, /// Whether the current user owns this car pub owned: bool, + /// Whether this car is only available as a prize car + pub prize: bool, /// Game-specific unique identifier for the car pub guid: String, } @@ -84,6 +88,7 @@ pub struct AnalysisContext { impl AnalysisContext { /// Create a new, empty analysis context. + #[allow(clippy::new_without_default)] pub fn new() -> Self { AnalysisContext { best_cars: Arc::new(Mutex::new(HashMap::new())), @@ -91,6 +96,53 @@ impl AnalysisContext { } } +/// Aggregated per-car win counts across all track/RQ/condition combinations. +/// Computed once from `AnalysisContext` and shared across all output functions. +pub struct CarCounts { + /// Map from car_rid → (BestCarRecord, count of tracks where this car is best) + pub by_rid: HashMap, + /// Map from guid → car_rid (for O(1) ownership/wishlist lookups) + pub guid_to_rid: HashMap, + /// Map from guid → Car (for O(1) lookups of car metadata by GUID). + /// Built once in `compute_car_counts()` and reused by trash/wishlist output. + pub car_map: HashMap, + /// Full car list from the database (kept for any callers that need iteration). + pub car_list: Vec, +} + +/// Computes per-car win counts from the analysis context. +/// Returns a `CarCounts` struct that can be shared across all output functions. +pub fn compute_car_counts(ctx: &AnalysisContext, car_list: Vec) -> CarCounts { + let best_cars = ctx.best_cars.lock() + .expect("best_cars mutex poisoned — another thread panicked"); + let mut by_rid: HashMap = HashMap::new(); + let mut guid_to_rid: HashMap = HashMap::new(); + + for (_key, best_list) in best_cars.iter() { + for best_car in best_list { + let entry = by_rid + .entry(best_car.car_rid.clone()) + .or_insert_with(|| (best_car.clone(), 0usize)); + 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(); + } + // 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. + guid_to_rid.entry(best_car.guid.clone()) + .or_insert_with(|| best_car.car_rid.clone()); + } + } + + // Build guid → Car lookup once, reused by trash/wishlist output functions. + let car_map: HashMap = car_list.iter() + .map(|c| (c.guid.clone(), c.clone())) + .collect(); + + 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 @@ -185,46 +237,22 @@ pub fn compare_cars(a: &Car, b: &Car) -> std::cmp::Ordering { /// Downloads a file from a URL and saves it locally. /// -/// Generic utility function for fetching files from remote URLs. Used by -/// both `get_cars_file()` and `get_tracks_file()` to download the latest -/// database files from the repository. -/// -/// # Arguments -/// -/// * `file_url` - The remote URL to download from -/// * `local_file_path` - Local file path where the downloaded content should be saved -/// -/// # Behavior -/// -/// 1. Removes any existing file at the local path -/// 2. Downloads the file from the remote URL -/// 3. Saves the content to the local file path -/// 4. Reports any errors that occur during the process -/// -/// # Error Handling -/// -/// Prints error messages for network failures but does not panic, allowing -/// the application to continue with cached data if available. -pub async fn get_file(file_url: &str, local_file_path: &str) { - // Remove existing file to ensure fresh download +/// 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); match get(file_url).await { Ok(response) => { - // Download successful, save to local file match response.text().await { Ok(content) => { - if let Err(e) = fs::write(local_file_path, content) { - eprintln!("Error writing {local_file_path}: {e}"); - } + fs::write(local_file_path, content) + .map_err(|e| format!("Error writing {local_file_path}: {e}")) } - Err(e) => eprintln!("Error reading response from {file_url}: {e}"), + Err(e) => Err(format!("Error reading response from {file_url}: {e}")), } } - Err(error) => { - // Download failed, print error but don't panic - eprintln!("Failed to fetch the file. Status: {}", error.to_string()); - } + Err(error) => Err(format!("Failed to fetch {file_url}: {error}")), } } diff --git a/src/records.rs b/src/records.rs index 1f8601f..5f6f3b9 100644 --- a/src/records.rs +++ b/src/records.rs @@ -18,7 +18,7 @@ //! 1. **Fetch Phase**: Downloads car database and track definitions //! 2. **Parse Phase**: Processes JSON timing files for each car //! 3. **Analysis Phase**: Compares times across RQ ranges and conditions -//! 4. **Output Phase**: Presents results in CSV format or console output +//! 4. **Output Phase**: Writes results to CSV files //! //! ## Dependencies //! @@ -29,6 +29,7 @@ use std::collections::{HashMap, HashSet}; use std::fs; +use std::io::Write; use std::sync::Arc; use clap::{Parser, Subcommand}; @@ -40,8 +41,9 @@ use futures::future::join_all; use serde_json::Value; use top_drives::{ - AnalysisContext, BestCarRecord, Car, TripletKey, - compare_cars, get_file, load_my_cars_data, translate_track_name, translate_track_type, + AnalysisContext, BestCarRecord, Car, CarCounts, TripletKey, + compare_cars, compute_car_counts, get_file, load_my_cars_data, + translate_track_name, translate_track_type, }; /// Maximum RQ level in the Top Drives database. @@ -61,27 +63,53 @@ struct Cli { enum Commands { /// Download latest car and track data from repository Fetch, - /// Show best performing cars in your collection - Best, - /// Show best cars not owned by you (prefix + if not on wishlist) - Missing, - /// Show wishlist cars that are no longer among the best - Wishlist, - /// Identify cars that can be safely removed from collection + /// Generate best.csv and very_best.csv + Best { + /// Minimum number of tracks a car must be best on to appear in very_best.csv (default: 10) + #[arg(long, default_value_t = 10)] + min_count: usize, + }, + /// Generate trash.csv (owned cars not best on any track) Trash, - /// Show detailed timing data for all tracks + /// Generate wishlist.csv, add.csv, and remove.csv + Wishlist { + /// Minimum number of tracks a car must be best on to qualify (default: 10) + #[arg(long, default_value_t = 10)] + min_count: usize, + /// Maximum number of cars on the wishlist (default: 300) + #[arg(long, default_value_t = 300)] + max_wishlist: usize, + }, + /// Generate all output files in a single pass + All { + /// Minimum number of tracks a car must be best on to qualify for very_best/wishlist (default: 10) + #[arg(long, default_value_t = 10)] + min_count: usize, + /// Maximum number of cars on the wishlist (default: 300) + #[arg(long, default_value_t = 300)] + max_wishlist: usize, + }, + /// Generate details.csv with all timing data Details, /// Show best car for a specific track/condition/RQ combination Track { - /// Track name (e.g. "Laguna Seca") track: String, - /// Track condition (e.g. "Dry", "Wet") condition: String, - /// RQ limit (e.g. 85) rq: u8, }, } +/// Common analysis setup: loads user data, parses all timing files, and computes car counts. +/// +/// Returns the analysis context (for stdout commands) and the aggregated car counts +/// (for CSV output commands). The car list is stored inside `CarCounts`. +async fn run_analysis(owned: &HashSet) -> (AnalysisContext, CarCounts) { + let ctx = AnalysisContext::new(); + let car_list = parse_all_times(owned, &ctx).await; + let counts = compute_car_counts(&ctx, car_list); + (ctx, counts) +} + /// Main entry point for the Top Drives Records Analyzer /// /// Parses command line arguments using `clap` and executes the requested analysis operation. @@ -94,172 +122,241 @@ enum Commands { /// cargo run --bin records -- track "Laguna Seca" "Dry" "85" /// ``` #[tokio::main] -async fn main() { +async fn main() -> std::io::Result<()> { let cli = Cli::parse(); match cli.command { Commands::Fetch => { - // Download phase: Get fresh data from all sources - // This is the foundation for all other operations - get_cars_file().await; - get_tracks_file().await; - fetch_times().await; + 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::Best => { - // Standard best cars analysis - // Shows cars you own that perform well across tracks + Commands::Best { min_count } => { let my_data = load_my_cars_data(); - let ctx = AnalysisContext::new(); - let _car_list = parse_all_times(&my_data.owned, &ctx).await; - print_best_cars(&ctx, false, &my_data.wishlist); // false = include owned cars - } - Commands::Missing => { - // Missing cars analysis - // Shows high-performing cars you don't own - // Cars not yet on the wishlist are prefixed with "+" - let my_data = load_my_cars_data(); - let ctx = AnalysisContext::new(); - let _car_list = parse_all_times(&my_data.owned, &ctx).await; - print_best_cars(&ctx, true, &my_data.wishlist); // true = filter out owned cars - } - Commands::Wishlist => { - // Wishlist review - // Shows cars on the wishlist that are no longer among the best - let my_data = load_my_cars_data(); - let ctx = AnalysisContext::new(); - let car_list = parse_all_times(&my_data.owned, &ctx).await; - print_wishlist_stale(&ctx, &my_data.wishlist, &car_list); + 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::Trash => { - // Collection optimization - // Identifies cars that can be safely removed let my_data = load_my_cars_data(); - let ctx = AnalysisContext::new(); - let car_list = parse_all_times(&my_data.owned, &ctx).await; - print_trash(&ctx, &car_list, &my_data.owned); + let (_ctx, counts) = run_analysis(&my_data.owned).await; + write_trash_csv(&counts, &my_data.owned)?; + println!("Written: trash.csv"); + } + 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::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::Details => { - // Comprehensive data export - // Outputs all timing data for external analysis let my_data = load_my_cars_data(); - let ctx = AnalysisContext::new(); - parse_all_times(&my_data.owned, &ctx).await; - print_best_times(&ctx); + let (ctx, _counts) = run_analysis(&my_data.owned).await; + write_details_csv(&ctx)?; + println!("Written: details.csv"); } Commands::Track { track, condition, rq } => { - // Track-specific analysis let my_data = load_my_cars_data(); - let ctx = AnalysisContext::new(); - parse_all_times(&my_data.owned, &ctx).await; + let (ctx, _counts) = run_analysis(&my_data.owned).await; print_best_car(&ctx, &track, &condition, rq); } } + Ok(()) } -/// Identifies and prints cars that can be safely removed from the user's collection -/// -/// Compares the user's owned cars against the list of best performing cars across all tracks. -/// -/// # Arguments -/// -/// * `ctx` - Analysis context containing the best cars data -/// * `car_list` - Pre-loaded list of all cars, to avoid re-reading the data file -/// * `owned` - Set of GUIDs the user owns -/// -/// Only considers cars that are not among the best performers and are below RQ 80 (to avoid -/// removing potentially useful higher-end cars). -/// -/// # Output Format -/// ```csv -/// RQ,Brand,Model,Year -/// 45,Toyota,Corolla,1995 -/// 52,Honda,Civic,1992 -/// ``` -/// -/// # Note -/// Prize cars are excluded from trash recommendations as they may have collection value -/// beyond their performance characteristics. -fn print_trash(ctx: &AnalysisContext, car_list: &[Car], owned: &HashSet) { - // Extract GUIDs of all best performing cars across all tracks and conditions - // Lock is scoped to avoid holding it during file I/O below - let best_cars: HashSet<_> = { - let guard = ctx.best_cars.lock().unwrap(); - guard - .values() - .flat_map(|records| records.iter().map(|record| record.guid.clone())) - .collect() - }; // Lock dropped here - // Build a lookup map for car details by GUID (reusing pre-loaded car_list) - let car_map: HashMap<_, _> = car_list - .iter() - .map(|c| (c.guid.clone(), c)) +/// Comparison function for sorting `BestCarRecord`s by RQ, then brand, then model name. +/// +/// Avoids cloning into a `Car` just for sorting — uses the same ordering as +/// `compare_cars` but operates directly on `BestCarRecord`. +fn compare_records(a: &BestCarRecord, b: &BestCarRecord) -> std::cmp::Ordering { + a.rq.cmp(&b.rq) + .then(a.brand.cmp(&b.brand)) + .then(a.only_name.cmp(&b.only_name)) +} + +/// Writes a sorted CSV file with a header row. +/// +/// Generic helper that sorts entries by index (avoiding cloning), writes the header, +/// and formats each row. Returns `std::io::Result<()>` so callers can propagate errors +/// instead of panicking. +fn write_sorted_csv( + path: &str, + header: &str, + entries: &[T], + cmp: impl Fn(&T, &T) -> std::cmp::Ordering, + format_row: G, +) -> std::io::Result<()> +where + G: Fn(&T, &mut fs::File) -> std::io::Result<()>, +{ + let mut indices: Vec = (0..entries.len()).collect(); + indices.sort_by(|&a, &b| cmp(&entries[a], &entries[b])); + + let mut file = fs::File::create(path)?; + writeln!(file, "{header}")?; + for i in indices { + format_row(&entries[i], &mut file)?; + } + Ok(()) +} + +/// Formats a `(BestCarRecord, usize)` row as `RQ,Brand,Model,Year,Count`. +/// Shared by `write_best_csv`, `write_very_best_csv`, and wishlist output functions. +fn format_counted_row(entry: &(BestCarRecord, usize), file: &mut fs::File) -> std::io::Result<()> { + let (rec, count) = entry; + writeln!(file, "{},{},{},{},{}", rec.rq, rec.brand, rec.only_name, rec.year, count) +} + +/// Writes best.csv — all cars that are best on at least one track, sorted by RQ. +/// Format: RQ,Brand,Model,Year,Count +fn write_best_csv(counts: &CarCounts) -> std::io::Result<()> { + let entries: Vec<_> = counts.by_rid.values().collect(); + write_sorted_csv( + "best.csv", + "RQ,Brand,Model,Year,Count", + &entries, + |a, b| compare_records(&a.0, &b.0), + |entry, file| format_counted_row(entry, file), + ) +} + +/// Writes very_best.csv — cars best on >= min_count tracks, sorted by RQ. +/// Format: RQ,Brand,Model,Year,Count +fn write_very_best_csv(counts: &CarCounts, min_count: usize) -> std::io::Result<()> { + let entries: Vec<_> = counts.by_rid.values() + .filter(|(_, count)| *count >= min_count) + .collect(); + write_sorted_csv( + "very_best.csv", + "RQ,Brand,Model,Year,Count", + &entries, + |a, b| compare_records(&a.0, &b.0), + |entry, file| format_counted_row(entry, file), + ) +} + +/// Writes trash.csv — owned cars not best on any track, RQ < 80, not prize. +/// Format: RQ,Brand,Model,Year +fn write_trash_csv(counts: &CarCounts, owned: &HashSet) -> std::io::Result<()> { + let trash: Vec<_> = owned.iter() + .filter(|guid| !counts.guid_to_rid.contains_key(*guid)) + .filter_map(|guid| counts.car_map.get(guid)) + .filter(|car| !car.prize && car.rq < 80) .collect(); - // Check each car in the user's collection - for my_car in owned { - if !best_cars.contains(my_car) { - if let Some(bad_car) = car_map.get(my_car) { - if !bad_car.prize && bad_car.rq < 80 { - println!( - "{},{},{},{}", - bad_car.rq, bad_car.brand, bad_car.only_name, bad_car.year - ); - } + write_sorted_csv( + "trash.csv", + "RQ,Brand,Model,Year", + &trash, + |a, b| compare_cars(a, b), + |car, file| writeln!(file, "{},{},{},{}", car.rq, car.brand, car.only_name, car.year), + ) +} + +/// Computes the ideal wishlist: very_best unowned cars, capped at max_wishlist, sorted by RQ. +fn compute_ideal_wishlist( + counts: &CarCounts, + owned: &HashSet, + min_count: usize, + max_wishlist: usize, +) -> Vec<(BestCarRecord, usize)> { + let mut candidates: Vec<_> = counts.by_rid.values() + .filter(|(record, count)| *count >= min_count && !owned.contains(&record.guid)) + .cloned() + .collect(); + candidates.sort_by(|a, b| compare_records(&a.0, &b.0)); + candidates.truncate(max_wishlist); + candidates +} + +/// Writes wishlist.csv, add.csv, and remove.csv from a single computed ideal wishlist. +/// +/// Computes the ideal wishlist once and derives all three output files from it, +/// avoiding redundant computation. +fn write_wishlist_files( + counts: &CarCounts, + owned: &HashSet, + wishlist: &HashSet, + min_count: usize, + max_wishlist: usize, +) -> std::io::Result<()> { + let ideal = compute_ideal_wishlist(counts, owned, min_count, max_wishlist); + let ideal_guids: HashSet<_> = ideal.iter().map(|(r, _)| r.guid.clone()).collect(); + + // --- wishlist.csv --- + write_sorted_csv( + "wishlist.csv", + "RQ,Brand,Model,Year,Count", + &ideal, + |a, b| compare_records(&a.0, &b.0), + |entry, file| format_counted_row(entry, file), + )?; + + // --- add.csv (ideal cars not yet on the wishlist) --- + let to_add: Vec<_> = ideal.iter() + .filter(|(record, _)| !wishlist.contains(&record.guid)) + .collect(); + + write_sorted_csv( + "add.csv", + "RQ,Brand,Model,Year,Count", + &to_add, + |a, b| compare_records(&a.0, &b.0), + |entry, file| format_counted_row(entry, file), + )?; + + // --- remove.csv (wishlist cars not in the ideal set) --- + // Single pass: collect found entries and count dropped ones together. + let mut dropped_count = 0usize; + let to_remove: Vec<_> = wishlist.iter() + .filter(|guid| !ideal_guids.contains(*guid)) + .filter_map(|guid| { + if let Some(rid) = counts.guid_to_rid.get(guid) { + counts.by_rid.get(rid).cloned() + } else { + dropped_count += 1; + None } - } + }) + .collect(); + if dropped_count > 0 { + eprintln!( + "Warning: {dropped_count} wishlist car(s) not found in best cars data and omitted from remove.csv" + ); } + + write_sorted_csv( + "remove.csv", + "RQ,Brand,Model,Year,Count", + &to_remove, + |a, b| compare_records(&a.0, &b.0), + |entry, file| format_counted_row(entry, file), + )?; + + Ok(()) } -/// Prints cars on the wishlist that are no longer among the best performers -/// -/// Compares the user's wishlist (from `my_cars.json`) against the current best-car -/// analysis results. Cars that appear on the wishlist but are no longer best for any -/// track/condition/RQ combination are reported with a `-` prefix, suggesting the -/// wishlist may need updating. -/// -/// Cars that are best but not yet on the wishlist are shown by the `missing` command -/// instead, so they are intentionally excluded here to avoid duplication. -/// -/// # Output Format -/// -/// ```csv -/// -RQ,Brand,Model,Year (on wishlist but no longer best) -/// ``` -/// -/// # Arguments -/// -/// * `ctx` - Analysis context containing the best cars data -/// * `wishlist` - Set of car GUIDs loaded from the wishlists section of `my_cars.json` -/// * `car_list` - Pre-loaded list of all cars, to avoid re-reading the data file -fn print_wishlist_stale(ctx: &AnalysisContext, wishlist: &HashSet, car_list: &[Car]) { - // Collect GUIDs of all cars that are currently best for at least one combination - // Lock is scoped to avoid holding it during file I/O below - let current_best_guids: HashSet = { - let best_cars = ctx.best_cars.lock().unwrap(); - best_cars - .values() - .flat_map(|records| records.iter().map(|r| r.guid.clone())) - .collect() - }; // Lock dropped here - - // Build a lookup map for car details by GUID (reusing pre-loaded car_list) - let car_map: HashMap<_, _> = car_list - .iter() - .map(|c| (c.guid.clone(), c)) - .collect(); - - // Cars on wishlist that are no longer best — prefix with "-" - let mut stale: Vec<_> = wishlist - .difference(¤t_best_guids) - .filter_map(|guid| car_map.get(guid)) - .collect(); - stale.sort_by(|a, b| compare_cars(*a, *b)); - - for car in &stale { - println!("-{},{},{},{}", car.rq, car.brand, car.only_name, car.year); - } -} -/// Prints detailed timing information for all tracks, conditions, and RQ levels +/// 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 @@ -272,18 +369,15 @@ fn print_wishlist_stale(ctx: &AnalysisContext, wishlist: &HashSet, car_l /// 85,monaco,Dry,85,Pagani,Zonda,2002,111,45.23 /// 82,laguna,Dry,82,Ferrari,250 GTO,1962,111,115.67 /// ``` -/// -/// # Note -/// This generates a large amount of output data and is primarily used for -/// external analysis or when detailed performance data is required. -fn print_best_times(ctx: &AnalysisContext) { +fn write_details_csv(ctx: &AnalysisContext) -> std::io::Result<()> { // Scope the lock to only the data extraction phase let sorted_data: Vec<(TripletKey, Vec)> = { - let best_cars = ctx.best_cars.lock().unwrap(); + 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))); // Sort by RQ, then Track ID + sorted_keys.sort_by(|a, b| a.1.cmp(&b.1).then(a.0.cmp(&b.0))); sorted_keys .into_iter() @@ -291,9 +385,12 @@ fn print_best_times(ctx: &AnalysisContext) { .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 { - println!( + writeln!( + file, "{},{},{},{},{},{},{},{},{}", key.1, // RQ level key.0, // Track ID @@ -304,75 +401,12 @@ fn print_best_times(ctx: &AnalysisContext) { best_car.year, // Car year best_car.config, // Tune configuration best_car.time // Track time - ); + )?; } } + Ok(()) } -/// Prints a ranked list of the best performing cars across all tracks and conditions -/// -/// Aggregates performance data to show which cars appear most frequently as top performers. -/// Cars are ranked by how often they achieve the best time on different tracks, providing -/// a measure of overall car quality and versatility. -/// -/// # Arguments -/// -/// * `ctx` - Analysis context containing the best cars data -/// * `filter_out_owned` - If true, excludes cars already owned by the user from the output -/// * `wishlist` - Set of car GUIDs on the user's wishlist -/// -/// # Output Format -/// ```csv -/// RQ,Brand,Model,Year,Count -/// 85,McLaren,F1,1993,15 -/// 82,Ferrari,250 GTO,1962,12 -/// 80,Lamborghini,Countach,1974,8 -/// ``` -/// -/// # Note -/// The count represents how many times this car achieved the best time across different -/// track/RQ/condition combinations, indicating overall performance quality. -fn print_best_cars(ctx: &AnalysisContext, filter_out_owned: bool, wishlist: &HashSet) { - // Scope the lock to only the data extraction phase - let car_count: HashMap = { - let best_cars = ctx.best_cars.lock().unwrap(); - let mut counts: HashMap = HashMap::new(); - // Count occurrences of each car across all tracks and conditions - for (_key, best_list) in best_cars.iter() { - for best_car in best_list { - let entry = counts - .entry(best_car.car_rid.clone()) - .or_insert((best_car.clone(), 0)); - entry.1 += 1; // Increment occurrence count - } - } - counts - }; // Lock dropped here - - // Convert to vector and sort by RQ first, then by car RID for consistent ordering - let mut car_count_vec: Vec<_> = car_count.iter().collect(); - car_count_vec.sort_by(|a, b| a.1 .0.rq.cmp(&b.1 .0.rq).then(a.0.cmp(&b.0))); - - for (_car_rid, (car_record, count)) in car_count_vec { - // Apply ownership filter if requested - if !(filter_out_owned && car_record.owned) { - // Check if this car is already on the wishlist (by GUID) - // In "missing" mode: prefix "+" if not on wishlist (suggested addition) - // In "best" mode: prefix "+" if not on wishlist (missing from wishlist) - let on_wishlist = wishlist.contains(&car_record.guid); - let prefix = if !on_wishlist { "+" } else { "" }; - println!( - "{}{},{},{},{},{}", - prefix, - car_record.rq, // Car's RQ level - car_record.brand, // Car brand - car_record.only_name, // Car model - car_record.year, // Car year - count // Number of times this car was best - ); - } - } -} /// 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 @@ -394,7 +428,8 @@ fn print_best_cars(ctx: &AnalysisContext, filter_out_owned: bool, wishlist: &Has /// 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().unwrap(); + let best_cars = ctx.best_cars.lock() + .expect("best_cars mutex poisoned — another thread panicked"); let key = ( track.to_string(), rq, @@ -485,29 +520,22 @@ async fn parse_car_times(car: Car, my_cars: &HashSet, ctx: &AnalysisCont 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 - // Example: "laguna_a00" -> ("laguna", "00") if let Some((cleaned_track_id, track_type)) = track_id.split_once("_a") { - // Extract the raw time value from JSON let mut time = details["t"].as_f64().unwrap_or(0.0); - // Check for community downvotes (quality control) let down_votes = details["down"].as_array(); let no_down_votes = match down_votes { - None => true, // No downvotes = valid - Some(votes) => votes.is_empty(), // Empty array = no downvotes + None => true, + Some(votes) => votes.is_empty(), }; - // Special handling for Test Bowl track - // The track measures distance over time, so we invert the calculation if track_id.starts_with("testBowl") { - time = 2000.0 / time; // Convert to standard time format + time = 2000.0 / time; } - // Determine if user owns this car for output marking let owned = my_cars.contains(&car.guid); let guid = car.guid.clone(); - // Apply quality filters before processing if time > 1.2 && no_down_votes { update_best_car( &ctx.best_cars, @@ -523,6 +551,7 @@ async fn parse_car_times(car: Car, my_cars: &HashSet, ctx: &AnalysisCont config: config.clone(), time, owned, + prize: car.prize, guid, }, ); @@ -533,32 +562,12 @@ async fn parse_car_times(car: Car, my_cars: &HashSet, ctx: &AnalysisCont } } } + /// 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. -/// -/// # Arguments -/// -/// * `best_cars` - Shared mutable reference to the best cars map -/// * `track_id` - The track identifier -/// * `car_rq` - The base RQ level of the car -/// * `surface` - The track surface condition (Dry, Wet, etc.) -/// * `new_record` - The new performance record to evaluate -/// -/// # Algorithm -/// -/// For each RQ level from car's RQ to MAX_RQ: -/// 1. **Better Time**: If new record is faster than current best, replace the list -/// 2. **Tie Time**: If new record matches current best time, add to list (if not duplicate) -/// 3. **Worse Time**: Ignore the new record -/// -/// # Design Rationale -/// -/// The RQ range iteration allows cars to compete at higher RQ levels than their base -/// rating, simulating how cars might perform when upgraded or in different matchups. -/// This provides more comprehensive analysis across the entire performance spectrum. fn update_best_car( best_cars: &Arc>>>, track_id: &str, @@ -571,7 +580,6 @@ fn update_best_car( let mut best_cars = best_cars.lock().unwrap(); - // Check performance across RQ range from car's base RQ up to MAX_RQ for rq in car_rq..=MAX_RQ { let key = (track_id.to_string(), rq, surface.to_string()); @@ -580,62 +588,33 @@ fn update_best_car( let best_time = existing_list.first().map(|r| r.time).unwrap_or(f64::MAX); if new_record.time < best_time { - // New record is faster than current best - // Replace entire list - only keep the fastest car *existing_list = vec![new_record.clone()]; - } else if (new_record.time - best_time).abs() < TIME_TOLERANCE { - // Times are essentially equal (within tolerance) - // This is a tie - add the car only if not already present - // Prevents duplicate entries for the same car - if !existing_list + } 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()); - } + { + existing_list.push(new_record.clone()); } - // Note: If new_record.time > best_time, we ignore it - // The existing best car remains in the list } None => { - // First time we've seen this track/RQ/surface combination - // Initialize the list with this car as the first (and currently best) entry 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. This significantly improves performance -/// when processing large numbers of cars. -/// -/// # Performance -/// -/// Uses Tokio's spawn system with a semaphore to create one task per car, -/// enabling concurrent file I/O and JSON parsing operations while limiting -/// concurrency to 20 parallel tasks. The function waits for all tasks to -/// complete before returning. -/// -/// # Note -/// -/// This function is called by most analysis commands as it's the first step -/// in processing the timing data before generating reports. -/// -/// # Returns -/// -/// Returns the full car list so callers can reuse it without re-reading the file. +/// parallel processing of timing data. async fn parse_all_times(my_cars: &HashSet, ctx: &AnalysisContext) -> Vec { - // Use Arc for efficient O(1) lookups across concurrent tasks let my_cars = Arc::new(my_cars.clone()); let car_list = get_car_list(); let mut handles = vec![]; - // Limit concurrent parsing to 20 to avoid excessive resource usage let semaphore = Arc::new(Semaphore::new(20)); - // Create one async task per car for concurrent processing for car in car_list.iter() { let my_cars = Arc::clone(&my_cars); let best_cars = Arc::clone(&ctx.best_cars); @@ -648,7 +627,6 @@ async fn parse_all_times(my_cars: &HashSet, ctx: &AnalysisContext) -> Ve handles.push(handle); } - // Wait for all parsing tasks to complete and report any failures let results = join_all(handles).await; for result in results { if let Err(e) = result { @@ -658,149 +636,101 @@ async fn parse_all_times(my_cars: &HashSet, ctx: &AnalysisContext) -> Ve car_list } -/// Fetches timing data files for all cars using controlled concurrency + +/// Fetches timing data files for all cars using controlled concurrency. /// -/// Downloads JSON timing files for every car in the database while limiting -/// concurrent connections to avoid overwhelming the server. Uses a semaphore -/// to restrict simultaneous downloads to 20 concurrent requests. -/// -/// # Rate Limiting -/// -/// Uses a semaphore with a limit of 20 concurrent requests to be respectful -/// to the server and avoid being rate-limited or blocked. -/// -/// # Performance -/// -/// Balances download speed with server load by processing cars in parallel -/// but with controlled concurrency limits. -async fn fetch_times() { - // Limit concurrent downloads to 20 to avoid overwhelming the server +/// 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.unwrap(); + let permit = semaphore.clone().acquire_owned().await + .map_err(|e| format!("Semaphore closed: {e}"))?; handles.push(tokio::spawn(async move { - fetch_car(car.rid).await; - drop(permit); // Release the semaphore permit + let _permit = permit; // held until end of block + fetch_car(car.rid).await })); } - // Wait for all downloads to complete - join_all(handles).await; + let results = join_all(handles).await; + let mut first_error: Option = None; + for result in results { + match result { + Ok(Ok(())) => {} + Ok(Err(e)) => { + eprintln!("{e}"); + if first_error.is_none() { first_error = Some(e); } + } + Err(e) => { + let msg = format!("Task join error: {e}"); + eprintln!("{msg}"); + if first_error.is_none() { first_error = Some(msg); } + } + } + } + first_error.map_or(Ok(()), Err) } + /// Downloads timing data for a single car from the remote API -/// -/// Fetches the JSON timing file for a specific car and saves it locally. -/// Called by `fetch_times()` for each car in the database. -/// -/// # Arguments -/// -/// * `rid` - The unique car identifier used in the API URL -/// -/// # File Storage -/// -/// Files are saved in the `times/` directory with the format `{rid}.json` -/// where `rid` is the car's unique identifier. -/// -/// # Logging -/// -/// Prints progress information showing which car's data is being fetched. -async fn fetch_car(rid: String) { +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; + get_file(url.as_str(), local_file_path.as_str()).await } + /// Loads the complete car database from the local repository file /// -/// Reads and parses the `cars_repo.json` file to build a comprehensive -/// list of all cars available in the Top Drives database. This includes -/// all metadata needed for performance analysis. -/// -/// # Returns -/// -/// Returns a `Vec` containing all cars from the database with their -/// complete metadata including RID, brand, model, year, RQ, and prize status. -/// -/// # File Source -/// -/// * **File**: `cars_repo.json` -/// * **Format**: JSON array of car objects -/// * **Fields**: rid, brand, onlyName, year, rq, prize, guid -/// -/// # Error Handling -/// -/// Uses `unwrap()` for simplicity - in a production environment, proper -/// error handling should be implemented for missing or malformed data. +/// Returns a `Vec` containing all cars from the database. +/// Returns an empty list with a warning if the file is missing or malformed. fn get_car_list() -> Vec { - let mut car_list: Vec = Vec::new(); - - // Read the cars database file 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 car_list; + return Vec::new(); } }; let cars_data: Vec = match serde_json::from_str(&content) { Ok(v) => v, Err(e) => { eprintln!("Warning: could not parse {cars_file}: {e}"); - return car_list; + return Vec::new(); } }; - // Parse each car entry and build Car structures - for car in cars_data { - let new_car = Car { - rid: String::from(car["rid"].as_str().unwrap_or("No RID")), - brand: String::from(car["brand"].as_str().unwrap_or("No Brand")), - only_name: String::from(car["onlyName"].as_str().unwrap_or("No Name")), - year: car["year"].as_u64().unwrap_or(1900) as u16, - rq: car["rq"].as_u64().unwrap_or(200) as u8, - prize: car["prize"].as_bool().unwrap_or(false), - guid: String::from(car["guid"].as_str().unwrap_or("No GUID")), - }; + let total = cars_data.len(); + let cars: Vec = cars_data.into_iter().filter_map(|car| { + let rid = car["rid"].as_str()?; + let brand = car["brand"].as_str()?; + let only_name = car["onlyName"].as_str()?; + let year = car["year"].as_u64()?; + let rq = car["rq"].as_u64()?; + let prize = car["prize"].as_bool().unwrap_or(false); + let guid = car["guid"].as_str()?; - car_list.push(new_car); + 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}"); } - - car_list + cars } + /// Downloads the tracks database file from the remote repository -/// -/// Fetches the latest track definitions including track names, conditions, -/// and identifiers. This data is essential for translating internal track -/// codes to human-readable names in the analysis output. -/// -/// # Remote Source -/// -/// * **URL**: GitHub repository tracks database -/// * **Purpose**: Provides track name mappings and condition definitions -/// * **Update Frequency**: Should be fetched periodically for latest tracks -async fn get_tracks_file() { +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; + 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 -/// -/// Fetches the complete car database containing all car metadata including -/// identifiers, names, years, RQ levels, and prize status. This serves as -/// the foundation for all car-related analysis operations. -/// -/// # Remote Source -/// -/// * **URL**: GitHub repository cars database -/// * **Purpose**: Provides comprehensive car metadata for analysis -/// * **Update Frequency**: Should be fetched for latest car additions -async fn get_cars_file() { +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; + get_file("https://github.com/TiagoXavi/topdrives-records/raw/refs/heads/main/src/database/cars_final.json", "cars_repo.json").await } #[cfg(test)] @@ -809,55 +739,42 @@ mod tests { #[test] fn test_split_once() { - // Test normal case let result = "laguna_a00".split_once("_a"); assert_eq!(result, Some(("laguna", "00"))); - // Test no delimiter let result = "laguna".split_once("_a"); assert_eq!(result, None); - // Test multiple delimiters (should split at first occurrence) let result = "test_a_data_a_more".split_once("_a"); assert_eq!(result, Some(("test", "_data_a_more"))); - // Test empty strings let result = "".split_once("_a"); assert_eq!(result, None); } #[test] fn test_translate_track_name() { - // Test 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"); - - // Test unknown track (should return as-is) assert_eq!(translate_track_name("unknown_track"), "unknown_track"); - - // Test drag strips assert_eq!(translate_track_name("drag100"), "0-100mph"); assert_eq!(translate_track_name("mile4"), "1/4 Mile Drag"); } #[test] fn test_translate_track_type() { - // Test surface 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("11"), "Dirt Wet"); assert_eq!(translate_track_type("30"), "Ice"); assert_eq!(translate_track_type("60"), "Snow"); - - // Test unknown condition (should return as-is) assert_eq!(translate_track_type("99"), "99"); } #[test] fn test_update_best_car_logic() { - // Verify test data is constructed correctly let test_record = BestCarRecord { car_rid: "Test_Car".to_string(), brand: "TestBrand".to_string(), @@ -867,17 +784,13 @@ mod tests { 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 demonstrates expected behavior: - // - Better times should replace existing records - // - Tie times should be added to the list - // - Worse times should be ignored } #[test] @@ -893,15 +806,14 @@ mod tests { config: "111".to_string(), time: 100.0, owned: false, + prize: false, guid: "test_guid".to_string(), }; - // Test that car appears in RQ range from its base RQ to MAX_RQ update_best_car(&ctx.best_cars, "laguna", 85, "Dry", test_record); let best_cars = ctx.best_cars.lock().unwrap(); - // Should appear at RQ 85, 90, 95, ..., MAX_RQ for rq in 85..=MAX_RQ { let key = ("laguna".to_string(), rq, "Dry".to_string()); assert!( @@ -914,13 +826,8 @@ mod tests { #[test] fn test_time_validation() { - // Test demonstrates expected time validation behavior: - // - Times below 1.2 seconds should be filtered out - // - Valid times should be processed normally - // - This logic is tested indirectly through the parsing functions - - let valid_time = 112.45; // Valid time - let invalid_time = 1.0; // Invalid time (too low) + 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"); @@ -928,7 +835,6 @@ mod tests { #[test] fn test_track_name_completeness() { - // Test that we have a reasonable number of track mappings let test_tracks = vec![ "laguna", "mnCity", @@ -942,35 +848,24 @@ mod tests { for track in test_tracks { let name = translate_track_name(track); - assert_ne!( - name, track, - "Track {} should have a proper name mapping", - 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() { - // Test that we have mappings for all common condition codes 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_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() { - // Verify the time tolerance is 5ms as required - // We test this indirectly by verifying tie behavior let ctx = AnalysisContext::new(); let record1 = BestCarRecord { @@ -982,6 +877,7 @@ mod tests { config: "111".to_string(), time: 100.0, owned: false, + prize: false, guid: "guid_a".to_string(), }; @@ -992,8 +888,9 @@ mod tests { year: 2020, rq: 85, config: "111".to_string(), - time: 100.003, // Within 0.005 tolerance + time: 100.003, owned: false, + prize: false, guid: "guid_b".to_string(), }; @@ -1004,8 +901,9 @@ mod tests { year: 2020, rq: 85, config: "111".to_string(), - time: 100.006, // Outside 0.005 tolerance + time: 100.006, owned: false, + prize: false, guid: "guid_c".to_string(), }; @@ -1017,8 +915,6 @@ mod tests { let key = ("laguna".to_string(), 85, "Dry".to_string()); let records = best_cars.get(&key).unwrap(); - // record1 and record2 should both be present (within tolerance) - // record3 should not be added (outside tolerance, and worse time) 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"));