Modernize dependencies, add wishlist features, improve error handling
- Remove once_cell and serde dependencies; use std::sync::LazyLock and manual serde_json::Value parsing instead - Add wishlist support: load wishlist from my_cars.json, prefix missing cars with '+' in missing mode, add 'wishlist' command to show stale wishlist entries (prefixed with '-') - Replace unwrap()/expect() with graceful eprintln warnings and early returns throughout (get_car_list, parse_car_times, etc.) - Scope all Mutex locks tightly to avoid holding during I/O (print_best_times, print_best_cars, print_trash, print_wishlist_stale) - Replace custom split_prefix_postfix with str::split_once - Pass owned-car set into parse_all_times/parse_car_times via Arc<HashSet> instead of reading file per task - Consolidate my_cars.json loading into single load_my_cars_data() returning both owned and wishlist sets - Add .gitignore and Cargo.lock to version control - Report task failures in parse_all_times join loop
This commit is contained in:
parent
32c4d3c10e
commit
6a68469239
4 changed files with 2374 additions and 168 deletions
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
/target
|
||||||
|
.idea/
|
||||||
|
my_cars.json
|
||||||
|
cars_repo.json
|
||||||
|
tracks_repo.json
|
||||||
|
times/
|
||||||
2065
Cargo.lock
generated
Normal file
2065
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -17,8 +17,6 @@ reqwest = "0.12.15"
|
||||||
tokio = { version = "1.44.1", features = ["full"] }
|
tokio = { version = "1.44.1", features = ["full"] }
|
||||||
serde_json = "1.0.140"
|
serde_json = "1.0.140"
|
||||||
futures = "0.3.31"
|
futures = "0.3.31"
|
||||||
once_cell = "1.20.2"
|
|
||||||
serde = { version = "1.0.210", features = ["derive"] }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3.10.1"
|
tempfile = "3.10.1"
|
||||||
|
|
|
||||||
413
src/records.rs
413
src/records.rs
|
|
@ -27,36 +27,22 @@
|
||||||
//! - `tokio`: Asynchronous runtime for concurrent processing
|
//! - `tokio`: Asynchronous runtime for concurrent processing
|
||||||
//! - `futures`: Future utilities for handling async operations
|
//! - `futures`: Future utilities for handling async operations
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::BufReader;
|
use std::io::BufReader;
|
||||||
use std::sync::Mutex;
|
use std::sync::{Arc, LazyLock, Mutex};
|
||||||
|
|
||||||
use tokio::sync::Semaphore;
|
use tokio::sync::Semaphore;
|
||||||
use tokio::task;
|
use tokio::task;
|
||||||
|
|
||||||
use futures::future::join_all;
|
use futures::future::join_all;
|
||||||
|
|
||||||
use once_cell::sync::Lazy;
|
|
||||||
|
|
||||||
use reqwest::get;
|
use reqwest::get;
|
||||||
|
|
||||||
use serde::Deserialize;
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
/// Represents a card in the user's collection from the Top Drives game export
|
|
||||||
/// Used to identify which cars the user owns by matching card IDs
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[allow(non_snake_case)]
|
|
||||||
struct Card {
|
|
||||||
/// Unique identifier for the card in the game
|
|
||||||
cardId: String,
|
|
||||||
/// Whether this card is locked (owned) by the player
|
|
||||||
locked: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Core car information structure containing basic car metadata
|
/// Core car information structure containing basic car metadata
|
||||||
/// Used throughout the application to represent car entities
|
/// Used throughout the application to represent car entities
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|
@ -106,9 +92,9 @@ type TripletKey = (String, u8, String);
|
||||||
|
|
||||||
/// Global cache storing the best car records for each track, RQ, and surface combination
|
/// Global cache storing the best car records for each track, RQ, and surface combination
|
||||||
/// Protected by a Mutex for thread-safe access during concurrent parsing operations
|
/// Protected by a Mutex for thread-safe access during concurrent parsing operations
|
||||||
/// Uses once_cell::Lazy for one-time initialization
|
/// Uses std::sync::LazyLock for one-time initialization
|
||||||
static BEST_CARS: Lazy<Mutex<HashMap<TripletKey, Vec<BestCarRecord>>>> =
|
static BEST_CARS: LazyLock<Mutex<HashMap<TripletKey, Vec<BestCarRecord>>>> =
|
||||||
Lazy::new(|| Mutex::new(HashMap::new()));
|
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
/// Main entry point for the Top Drives Records Analyzer
|
/// Main entry point for the Top Drives Records Analyzer
|
||||||
///
|
///
|
||||||
|
|
@ -120,8 +106,9 @@ static BEST_CARS: Lazy<Mutex<HashMap<TripletKey, Vec<BestCarRecord>>>> =
|
||||||
/// The first argument specifies the operation to perform:
|
/// The first argument specifies the operation to perform:
|
||||||
/// - `fetch`: Download latest car and track data from repository
|
/// - `fetch`: Download latest car and track data from repository
|
||||||
/// - `best`: Show best performing cars in user's collection
|
/// - `best`: Show best performing cars in user's collection
|
||||||
/// - `missing`: Show best cars not owned by the user
|
/// - `missing`: Show best cars not owned by the user (prefix `+` if not on wishlist)
|
||||||
/// - `trash`: Identify cars that can be safely removed from collection
|
/// - `trash`: Identify cars that can be safely removed from collection
|
||||||
|
/// - `wishlist`: Show wishlist cars that are no longer among the best
|
||||||
/// - `details`: Show detailed timing data for all tracks
|
/// - `details`: Show detailed timing data for all tracks
|
||||||
/// - `track`: Show best car for specific track/condition/RQ combination
|
/// - `track`: Show best car for specific track/condition/RQ combination
|
||||||
///
|
///
|
||||||
|
|
@ -152,25 +139,37 @@ async fn main() {
|
||||||
"best" => {
|
"best" => {
|
||||||
// Standard best cars analysis
|
// Standard best cars analysis
|
||||||
// Shows cars you own that perform well across tracks
|
// Shows cars you own that perform well across tracks
|
||||||
parse_all_times().await;
|
let my_data = load_my_cars_data();
|
||||||
print_best_cars(false); // false = include owned cars
|
let _car_list = parse_all_times(&my_data.owned).await;
|
||||||
|
print_best_cars(false, &my_data.wishlist); // false = include owned cars
|
||||||
}
|
}
|
||||||
"missing" => {
|
"missing" => {
|
||||||
// Missing cars analysis
|
// Missing cars analysis
|
||||||
// Shows high-performing cars you don't own
|
// Shows high-performing cars you don't own
|
||||||
parse_all_times().await;
|
// Cars not yet on the wishlist are prefixed with "+"
|
||||||
print_best_cars(true); // true = filter out owned cars
|
let my_data = load_my_cars_data();
|
||||||
|
let _car_list = parse_all_times(&my_data.owned).await;
|
||||||
|
print_best_cars(true, &my_data.wishlist); // true = filter out owned cars
|
||||||
|
}
|
||||||
|
"wishlist" => {
|
||||||
|
// Wishlist review
|
||||||
|
// Shows cars on the wishlist that are no longer among the best
|
||||||
|
let my_data = load_my_cars_data();
|
||||||
|
let car_list = parse_all_times(&my_data.owned).await;
|
||||||
|
print_wishlist_stale(&my_data.wishlist, &car_list);
|
||||||
}
|
}
|
||||||
"trash" => {
|
"trash" => {
|
||||||
// Collection optimization
|
// Collection optimization
|
||||||
// Identifies cars that can be safely removed
|
// Identifies cars that can be safely removed
|
||||||
parse_all_times().await;
|
let my_data = load_my_cars_data();
|
||||||
print_trash();
|
let car_list = parse_all_times(&my_data.owned).await;
|
||||||
|
print_trash(&car_list, &my_data.owned);
|
||||||
}
|
}
|
||||||
"details" => {
|
"details" => {
|
||||||
// Comprehensive data export
|
// Comprehensive data export
|
||||||
// Outputs all timing data for external analysis
|
// Outputs all timing data for external analysis
|
||||||
parse_all_times().await;
|
let my_data = load_my_cars_data();
|
||||||
|
parse_all_times(&my_data.owned).await;
|
||||||
print_best_times();
|
print_best_times();
|
||||||
}
|
}
|
||||||
"track" => {
|
"track" => {
|
||||||
|
|
@ -180,7 +179,8 @@ async fn main() {
|
||||||
println!("Track name, condition and RQ limit required.");
|
println!("Track name, condition and RQ limit required.");
|
||||||
} else {
|
} else {
|
||||||
// Safe to unwrap here due to above check
|
// Safe to unwrap here due to above check
|
||||||
parse_all_times().await;
|
let my_data = load_my_cars_data();
|
||||||
|
parse_all_times(&my_data.owned).await;
|
||||||
let track = args.get(2).unwrap();
|
let track = args.get(2).unwrap();
|
||||||
let condition = args.get(3).unwrap();
|
let condition = args.get(3).unwrap();
|
||||||
let rq = args.get(4).unwrap();
|
let rq = args.get(4).unwrap();
|
||||||
|
|
@ -199,6 +199,10 @@ async fn main() {
|
||||||
/// Identifies and prints cars that can be safely removed from the user's collection
|
/// 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.
|
/// Compares the user's owned cars against the list of best performing cars across all tracks.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `car_list` - Pre-loaded list of all cars, to avoid re-reading the data file
|
||||||
/// Only considers cars that are not among the best performers and are below RQ 80 (to avoid
|
/// Only considers cars that are not among the best performers and are below RQ 80 (to avoid
|
||||||
/// removing potentially useful higher-end cars).
|
/// removing potentially useful higher-end cars).
|
||||||
///
|
///
|
||||||
|
|
@ -212,21 +216,28 @@ async fn main() {
|
||||||
/// # Note
|
/// # Note
|
||||||
/// Prize cars are excluded from trash recommendations as they may have collection value
|
/// Prize cars are excluded from trash recommendations as they may have collection value
|
||||||
/// beyond their performance characteristics.
|
/// beyond their performance characteristics.
|
||||||
fn print_trash() {
|
fn print_trash(car_list: &[Car], owned: &HashSet<String>) {
|
||||||
// Extract GUIDs of all best performing cars across all tracks and conditions
|
// Extract GUIDs of all best performing cars across all tracks and conditions
|
||||||
let best_cars: Vec<_> = BEST_CARS
|
// Lock is scoped to avoid holding it during file I/O below
|
||||||
.lock()
|
let best_cars: HashSet<_> = {
|
||||||
.unwrap()
|
let guard = BEST_CARS.lock().unwrap();
|
||||||
|
guard
|
||||||
.values()
|
.values()
|
||||||
.flat_map(|records| records.iter().map(|record| record.guid.clone()))
|
.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))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Check each car in the user's collection
|
// Check each car in the user's collection
|
||||||
for my_car in get_my_car_list() {
|
for my_car in owned {
|
||||||
if !best_cars.contains(&my_car) {
|
if !best_cars.contains(my_car) {
|
||||||
// Find the car details for this GUID
|
if let Some(bad_car) = car_map.get(my_car) {
|
||||||
for bad_car in get_car_list() {
|
if !bad_car.prize && bad_car.rq < 80 {
|
||||||
if bad_car.guid == my_car && !bad_car.prize && bad_car.rq < 80 {
|
|
||||||
println!(
|
println!(
|
||||||
"{},{},{},{}",
|
"{},{},{},{}",
|
||||||
bad_car.rq, bad_car.brand, bad_car.only_name, bad_car.year
|
bad_car.rq, bad_car.brand, bad_car.only_name, bad_car.year
|
||||||
|
|
@ -236,6 +247,147 @@ fn print_trash() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
struct MyCarsData {
|
||||||
|
/// GUIDs of cars the user owns (from `playerDeck[].cardId` where `locked == true`)
|
||||||
|
owned: HashSet<String>,
|
||||||
|
/// GUIDs of wishlisted cars (from `wishlists[0].cards`)
|
||||||
|
wishlist: HashSet<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads and parses `my_cars.json` once, extracting both owned and wishlist data
|
||||||
|
///
|
||||||
|
/// Opens the game export file a single time and extracts:
|
||||||
|
/// - **Owned cars** from `playerDeck[].cardId` (filtered by `locked == true`)
|
||||||
|
/// - **Wishlist** from `wishlists[0].cards`
|
||||||
|
///
|
||||||
|
/// # File Source
|
||||||
|
///
|
||||||
|
/// * **File**: `my_cars.json` (exported from Top Drives game)
|
||||||
|
///
|
||||||
|
/// # 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.
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract owned car GUIDs from playerDeck
|
||||||
|
let owned: HashSet<String> = json
|
||||||
|
.get("playerDeck")
|
||||||
|
.and_then(|d| d.as_array())
|
||||||
|
.map(|deck| {
|
||||||
|
deck.iter()
|
||||||
|
.filter_map(|card| {
|
||||||
|
if card.get("locked").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||||
|
card.get("cardId").and_then(|v| v.as_str()).map(|s| s.to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Extract wishlist GUIDs from wishlists[0].cards
|
||||||
|
let wishlist: HashSet<String> = json
|
||||||
|
.get("wishlists")
|
||||||
|
.and_then(|w| w.get(0))
|
||||||
|
.and_then(|w| w.get("cards"))
|
||||||
|
.and_then(|c| c.as_array())
|
||||||
|
.map(|cards| {
|
||||||
|
cards
|
||||||
|
.iter()
|
||||||
|
.filter_map(|c| c.as_str().map(|s| s.to_string()))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
MyCarsData { owned, wishlist }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Comparison function for sorting cars by RQ, then brand, then model name
|
||||||
|
///
|
||||||
|
/// Used as a shared comparator to ensure consistent ordering across output functions.
|
||||||
|
fn compare_cars(a: &Car, b: &Car) -> std::cmp::Ordering {
|
||||||
|
a.rq.cmp(&b.rq)
|
||||||
|
.then(a.brand.cmp(&b.brand))
|
||||||
|
.then(a.only_name.cmp(&b.only_name))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
///
|
||||||
|
/// * `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(wishlist: &HashSet<String>, 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<String> = {
|
||||||
|
let best_cars = 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
|
/// Prints detailed timing information for all tracks, conditions, and RQ levels
|
||||||
///
|
///
|
||||||
/// Outputs comprehensive performance data showing the best car for every combination
|
/// Outputs comprehensive performance data showing the best car for every combination
|
||||||
|
|
@ -254,14 +406,21 @@ fn print_trash() {
|
||||||
/// This generates a large amount of output data and is primarily used for
|
/// This generates a large amount of output data and is primarily used for
|
||||||
/// external analysis or when detailed performance data is required.
|
/// external analysis or when detailed performance data is required.
|
||||||
fn print_best_times() {
|
fn print_best_times() {
|
||||||
|
// Scope the lock to only the data extraction phase
|
||||||
|
let sorted_data: Vec<((String, u8, String), Vec<BestCarRecord>)> = {
|
||||||
let best_cars = BEST_CARS.lock().unwrap();
|
let best_cars = BEST_CARS.lock().unwrap();
|
||||||
|
|
||||||
// Collect keys and sort them by RQ and Track ID for consistent output
|
// Collect keys and sort them by RQ and Track ID for consistent output
|
||||||
let mut sorted_keys: Vec<_> = best_cars.keys().collect();
|
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))); // Sort by RQ, then Track ID
|
||||||
|
|
||||||
for key in sorted_keys {
|
sorted_keys
|
||||||
if let Some(records) = best_cars.get(key) {
|
.into_iter()
|
||||||
|
.filter_map(|key| best_cars.get(key).map(|records| (key.clone(), records.clone())))
|
||||||
|
.collect()
|
||||||
|
}; // Lock dropped here
|
||||||
|
|
||||||
|
for (key, records) in sorted_data {
|
||||||
for best_car in records {
|
for best_car in records {
|
||||||
println!(
|
println!(
|
||||||
"{},{},{},{},{},{},{},{},{}",
|
"{},{},{},{},{},{},{},{},{}",
|
||||||
|
|
@ -277,7 +436,6 @@ fn print_best_times() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
/// Prints a ranked list of the best performing cars across all tracks and conditions
|
/// Prints a ranked list of the best performing cars across all tracks and conditions
|
||||||
///
|
///
|
||||||
|
|
@ -300,19 +458,23 @@ fn print_best_times() {
|
||||||
/// # Note
|
/// # Note
|
||||||
/// The count represents how many times this car achieved the best time across different
|
/// The count represents how many times this car achieved the best time across different
|
||||||
/// track/RQ/condition combinations, indicating overall performance quality.
|
/// track/RQ/condition combinations, indicating overall performance quality.
|
||||||
fn print_best_cars(filter_out_owned: bool) {
|
fn print_best_cars(filter_out_owned: bool, wishlist: &HashSet<String>) {
|
||||||
|
// Scope the lock to only the data extraction phase
|
||||||
|
let car_count: HashMap<String, (BestCarRecord, usize)> = {
|
||||||
let best_cars = BEST_CARS.lock().unwrap();
|
let best_cars = BEST_CARS.lock().unwrap();
|
||||||
let mut car_count: HashMap<String, (BestCarRecord, usize)> = HashMap::new();
|
let mut counts: HashMap<String, (BestCarRecord, usize)> = HashMap::new();
|
||||||
|
|
||||||
// Count occurrences of each car across all tracks and conditions
|
// Count occurrences of each car across all tracks and conditions
|
||||||
for (_key, best_list) in best_cars.iter() {
|
for (_key, best_list) in best_cars.iter() {
|
||||||
for best_car in best_list {
|
for best_car in best_list {
|
||||||
let entry = car_count
|
let entry = counts
|
||||||
.entry(best_car.car_rid.clone())
|
.entry(best_car.car_rid.clone())
|
||||||
.or_insert((best_car.clone(), 0));
|
.or_insert((best_car.clone(), 0));
|
||||||
entry.1 += 1; // Increment occurrence count
|
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
|
// 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();
|
let mut car_count_vec: Vec<_> = car_count.iter().collect();
|
||||||
|
|
@ -321,8 +483,14 @@ fn print_best_cars(filter_out_owned: bool) {
|
||||||
for (_car_rid, (car_record, count)) in car_count_vec {
|
for (_car_rid, (car_record, count)) in car_count_vec {
|
||||||
// Apply ownership filter if requested
|
// Apply ownership filter if requested
|
||||||
if !(filter_out_owned && car_record.owned) {
|
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!(
|
println!(
|
||||||
"{},{},{},{},{}",
|
"{}{},{},{},{},{}",
|
||||||
|
prefix,
|
||||||
car_record.rq, // Car's RQ level
|
car_record.rq, // Car's RQ level
|
||||||
car_record.brand, // Car brand
|
car_record.brand, // Car brand
|
||||||
car_record.only_name, // Car model
|
car_record.only_name, // Car model
|
||||||
|
|
@ -409,18 +577,30 @@ fn print_best_car(track: &String, condition: &String, rq: &String) {
|
||||||
///
|
///
|
||||||
/// This function is designed to be called concurrently for multiple cars to improve
|
/// This function is designed to be called concurrently for multiple cars to improve
|
||||||
/// overall processing speed when analyzing large car databases.
|
/// overall processing speed when analyzing large car databases.
|
||||||
async fn parse_car_times(car: Car) {
|
async fn parse_car_times(car: Car, my_cars: &HashSet<String>) {
|
||||||
// Get list of user's owned cars for ownership tracking
|
|
||||||
let my_cars = get_my_car_list();
|
|
||||||
|
|
||||||
// Construct file path for this car's timing data
|
// Construct file path for this car's timing data
|
||||||
let file_name = car.rid.to_owned() + ".json";
|
let file_name = car.rid.to_owned() + ".json";
|
||||||
let local_file_path = "times/".to_owned() + file_name.as_str();
|
let local_file_path = "times/".to_owned() + file_name.as_str();
|
||||||
let file_content = fs::read_to_string(local_file_path);
|
|
||||||
|
|
||||||
// Parse JSON data, handling potential file errors gracefully
|
// Read file content, emitting a warning on failure
|
||||||
let json_data: Value =
|
let file_content = match fs::read_to_string(&local_file_path) {
|
||||||
serde_json::from_str(&file_content.unwrap_or(String::default())).unwrap_or(Value::Null);
|
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.)
|
// Process each configuration variant (111, 233, 323, etc.)
|
||||||
if let Some(configurations) = json_data["data"].as_object() {
|
if let Some(configurations) = json_data["data"].as_object() {
|
||||||
|
|
@ -430,9 +610,7 @@ async fn parse_car_times(car: Car) {
|
||||||
for (track_id, details) in times {
|
for (track_id, details) in times {
|
||||||
// Parse track ID to separate base track name from surface condition
|
// Parse track ID to separate base track name from surface condition
|
||||||
// Example: "laguna_a00" -> ("laguna", "00")
|
// Example: "laguna_a00" -> ("laguna", "00")
|
||||||
if let Some((cleaned_track_id, track_type)) =
|
if let Some((cleaned_track_id, track_type)) = track_id.split_once("_a") {
|
||||||
split_prefix_postfix(track_id, "_a")
|
|
||||||
{
|
|
||||||
// Extract the raw time value from JSON
|
// Extract the raw time value from JSON
|
||||||
let mut time = details["t"].as_f64().unwrap_or(0.0);
|
let mut time = details["t"].as_f64().unwrap_or(0.0);
|
||||||
|
|
||||||
|
|
@ -440,7 +618,7 @@ async fn parse_car_times(car: Car) {
|
||||||
let down_votes = details["down"].as_array();
|
let down_votes = details["down"].as_array();
|
||||||
let no_down_votes = match down_votes {
|
let no_down_votes = match down_votes {
|
||||||
None => true, // No downvotes = valid
|
None => true, // No downvotes = valid
|
||||||
Some(votes) => votes.len() <= 0, // Empty array = no downvotes
|
Some(votes) => votes.is_empty(), // Empty array = no downvotes
|
||||||
};
|
};
|
||||||
|
|
||||||
// Special handling for Test Bowl track
|
// Special handling for Test Bowl track
|
||||||
|
|
@ -556,46 +734,35 @@ fn update_best_car(track_id: &str, car_rq: u8, surface: &str, new_record: BestCa
|
||||||
///
|
///
|
||||||
/// This function is called by most analysis commands as it's the first step
|
/// This function is called by most analysis commands as it's the first step
|
||||||
/// in processing the timing data before generating reports.
|
/// in processing the timing data before generating reports.
|
||||||
async fn parse_all_times() {
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// Returns the full car list so callers can reuse it without re-reading the file.
|
||||||
|
async fn parse_all_times(my_cars: &HashSet<String>) -> Vec<Car> {
|
||||||
|
// Use Arc<HashSet> 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![];
|
let mut handles = vec![];
|
||||||
|
|
||||||
// Create one async task per car for concurrent processing
|
// Create one async task per car for concurrent processing
|
||||||
for car in get_car_list() {
|
for car in car_list.iter() {
|
||||||
|
let my_cars = Arc::clone(&my_cars);
|
||||||
|
let car = car.clone();
|
||||||
let handle = task::spawn(async move {
|
let handle = task::spawn(async move {
|
||||||
parse_car_times(car).await;
|
parse_car_times(car, &my_cars).await;
|
||||||
});
|
});
|
||||||
handles.push(handle);
|
handles.push(handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for all parsing tasks to complete
|
// Wait for all parsing tasks to complete and report any failures
|
||||||
join_all(handles).await;
|
let results = join_all(handles).await;
|
||||||
}
|
for result in results {
|
||||||
/// Splits a string at the first occurrence of a delimiter
|
if let Err(e) = result {
|
||||||
///
|
eprintln!("Warning: parsing task failed: {e}");
|
||||||
/// Utility function for parsing track identifiers that combine track name
|
}
|
||||||
/// with surface condition (e.g., "laguna_a00" -> ("laguna", "00")).
|
}
|
||||||
///
|
|
||||||
/// # Arguments
|
car_list
|
||||||
///
|
|
||||||
/// * `input` - The string to split
|
|
||||||
/// * `delimiter` - The delimiter to split on
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
///
|
|
||||||
/// Returns `Some((prefix, postfix))` if delimiter is found, `None` otherwise
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
///
|
|
||||||
/// ```rust,ignore
|
|
||||||
/// let result = split_prefix_postfix("laguna_a00", "_a");
|
|
||||||
/// // Returns: Some(("laguna", "00"))
|
|
||||||
/// ```
|
|
||||||
fn split_prefix_postfix<'a>(input: &'a str, delimiter: &'a str) -> Option<(&'a str, &'a str)> {
|
|
||||||
input.find(delimiter).map(|index| {
|
|
||||||
let prefix = &input[..index];
|
|
||||||
let postfix = &input[index + delimiter.len()..];
|
|
||||||
(prefix, postfix)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
/// Fetches timing data files for all cars using controlled concurrency
|
/// Fetches timing data files for all cars using controlled concurrency
|
||||||
///
|
///
|
||||||
|
|
@ -614,7 +781,7 @@ fn split_prefix_postfix<'a>(input: &'a str, delimiter: &'a str) -> Option<(&'a s
|
||||||
/// but with controlled concurrency limits.
|
/// but with controlled concurrency limits.
|
||||||
async fn fetch_times() {
|
async fn fetch_times() {
|
||||||
// Limit concurrent downloads to 20 to avoid overwhelming the server
|
// Limit concurrent downloads to 20 to avoid overwhelming the server
|
||||||
let semaphore = std::sync::Arc::new(Semaphore::new(20));
|
let semaphore = Arc::new(Semaphore::new(20));
|
||||||
let mut handles = vec![];
|
let mut handles = vec![];
|
||||||
|
|
||||||
for car in get_car_list() {
|
for car in get_car_list() {
|
||||||
|
|
@ -678,8 +845,20 @@ fn get_car_list() -> Vec<Car> {
|
||||||
|
|
||||||
// Read the cars database file
|
// Read the cars database file
|
||||||
let cars_file = "cars_repo.json";
|
let cars_file = "cars_repo.json";
|
||||||
let cars_data: Vec<Value> =
|
let content = match fs::read_to_string(cars_file) {
|
||||||
serde_json::from_str(&fs::read_to_string(cars_file).unwrap()).unwrap();
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Warning: could not read {cars_file}: {e}");
|
||||||
|
return car_list;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
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 car_list;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Parse each car entry and build Car structures
|
// Parse each car entry and build Car structures
|
||||||
for car in cars_data {
|
for car in cars_data {
|
||||||
|
|
@ -698,48 +877,6 @@ fn get_car_list() -> Vec<Car> {
|
||||||
|
|
||||||
car_list
|
car_list
|
||||||
}
|
}
|
||||||
/// Loads the user's car collection from the game export file
|
|
||||||
///
|
|
||||||
/// Parses the `my_cars.json` file exported from the Top Drives game to determine
|
|
||||||
/// which cars the user owns. Only includes cards that are marked as "locked"
|
|
||||||
/// (owned by the player).
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
///
|
|
||||||
/// Returns a `Vec<String>` containing the GUIDs of all cars owned by the user.
|
|
||||||
/// These GUIDs are used to mark cars as "owned" in the analysis results.
|
|
||||||
///
|
|
||||||
/// # File Source
|
|
||||||
///
|
|
||||||
/// * **File**: `my_cars.json` (exported from Top Drives game)
|
|
||||||
/// * **Format**: JSON with playerDeck containing card information
|
|
||||||
/// * **Key Field**: `playerDeck[].cardId` - unique car identifiers
|
|
||||||
/// * **Filter**: `playerDeck[].locked` - only include owned cars
|
|
||||||
///
|
|
||||||
/// # Error Handling
|
|
||||||
///
|
|
||||||
/// Uses `expect()` for simplicity - in production, proper error handling should
|
|
||||||
/// be implemented for missing files or malformed JSON data.
|
|
||||||
fn get_my_car_list() -> Vec<String> {
|
|
||||||
// Open and read the user's car collection file
|
|
||||||
let file = File::open("my_cars.json").expect("Could not read my cars file.");
|
|
||||||
let reader = BufReader::new(file);
|
|
||||||
let json: Value =
|
|
||||||
serde_json::from_reader(reader).expect("Could not extract JSON from my cars file.");
|
|
||||||
|
|
||||||
// Extract the playerDeck section containing card information
|
|
||||||
let deck_json = json.get("playerDeck").ok_or("Missing 'playerDeck'");
|
|
||||||
let cards: Vec<Card> =
|
|
||||||
serde_json::from_value(deck_json.clone().expect("Could not extract cards.").clone())
|
|
||||||
.expect("Could not extract cards.");
|
|
||||||
|
|
||||||
// Filter to only include locked (owned) cards and extract their GUIDs
|
|
||||||
cards
|
|
||||||
.into_iter()
|
|
||||||
.filter(|card| card.locked)
|
|
||||||
.map(|card| card.cardId)
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
/// Downloads the tracks database file from the remote repository
|
/// Downloads the tracks database file from the remote repository
|
||||||
///
|
///
|
||||||
/// Fetches the latest track definitions including track names, conditions,
|
/// Fetches the latest track definitions including track names, conditions,
|
||||||
|
|
@ -903,21 +1040,21 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_split_prefix_postfix() {
|
fn test_split_once() {
|
||||||
// Test normal case
|
// Test normal case
|
||||||
let result = split_prefix_postfix("laguna_a00", "_a");
|
let result = "laguna_a00".split_once("_a");
|
||||||
assert_eq!(result, Some(("laguna", "00")));
|
assert_eq!(result, Some(("laguna", "00")));
|
||||||
|
|
||||||
// Test no delimiter
|
// Test no delimiter
|
||||||
let result = split_prefix_postfix("laguna", "_a");
|
let result = "laguna".split_once("_a");
|
||||||
assert_eq!(result, None);
|
assert_eq!(result, None);
|
||||||
|
|
||||||
// Test multiple delimiters (should split at first occurrence)
|
// Test multiple delimiters (should split at first occurrence)
|
||||||
let result = split_prefix_postfix("test_a_data_a_more", "_a");
|
let result = "test_a_data_a_more".split_once("_a");
|
||||||
assert_eq!(result, Some(("test", "_data_a_more")));
|
assert_eq!(result, Some(("test", "_data_a_more")));
|
||||||
|
|
||||||
// Test empty strings
|
// Test empty strings
|
||||||
let result = split_prefix_postfix("", "_a");
|
let result = "".split_once("_a");
|
||||||
assert_eq!(result, None);
|
assert_eq!(result, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue