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:
Jan-Peter von Hunnius 2026-05-06 00:30:56 +02:00
parent 32c4d3c10e
commit 6a68469239
4 changed files with 2374 additions and 168 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
/target
.idea/
my_cars.json
cars_repo.json
tracks_repo.json
times/

2065
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -17,8 +17,6 @@ reqwest = "0.12.15"
tokio = { version = "1.44.1", features = ["full"] }
serde_json = "1.0.140"
futures = "0.3.31"
once_cell = "1.20.2"
serde = { version = "1.0.210", features = ["derive"] }
[dev-dependencies]
tempfile = "3.10.1"

View file

@ -27,36 +27,22 @@
//! - `tokio`: Asynchronous runtime for concurrent processing
//! - `futures`: Future utilities for handling async operations
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::env;
use std::fs;
use std::fs::File;
use std::io::BufReader;
use std::sync::Mutex;
use std::sync::{Arc, LazyLock, Mutex};
use tokio::sync::Semaphore;
use tokio::task;
use futures::future::join_all;
use once_cell::sync::Lazy;
use reqwest::get;
use serde::Deserialize;
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
/// Used throughout the application to represent car entities
#[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
/// Protected by a Mutex for thread-safe access during concurrent parsing operations
/// Uses once_cell::Lazy for one-time initialization
static BEST_CARS: Lazy<Mutex<HashMap<TripletKey, Vec<BestCarRecord>>>> =
Lazy::new(|| Mutex::new(HashMap::new()));
/// Uses std::sync::LazyLock for one-time initialization
static BEST_CARS: LazyLock<Mutex<HashMap<TripletKey, Vec<BestCarRecord>>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
/// 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:
/// - `fetch`: Download latest car and track data from repository
/// - `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
/// - `wishlist`: Show wishlist cars that are no longer among the best
/// - `details`: Show detailed timing data for all tracks
/// - `track`: Show best car for specific track/condition/RQ combination
///
@ -152,25 +139,37 @@ async fn main() {
"best" => {
// Standard best cars analysis
// Shows cars you own that perform well across tracks
parse_all_times().await;
print_best_cars(false); // false = include owned cars
let my_data = load_my_cars_data();
let _car_list = parse_all_times(&my_data.owned).await;
print_best_cars(false, &my_data.wishlist); // false = include owned cars
}
"missing" => {
// Missing cars analysis
// Shows high-performing cars you don't own
parse_all_times().await;
print_best_cars(true); // true = filter out owned cars
// Cars not yet on the wishlist are prefixed with "+"
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" => {
// Collection optimization
// Identifies cars that can be safely removed
parse_all_times().await;
print_trash();
let my_data = load_my_cars_data();
let car_list = parse_all_times(&my_data.owned).await;
print_trash(&car_list, &my_data.owned);
}
"details" => {
// Comprehensive data export
// 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();
}
"track" => {
@ -180,7 +179,8 @@ async fn main() {
println!("Track name, condition and RQ limit required.");
} else {
// 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 condition = args.get(3).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
///
/// 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
/// removing potentially useful higher-end cars).
///
@ -212,21 +216,28 @@ async fn main() {
/// # Note
/// Prize cars are excluded from trash recommendations as they may have collection value
/// 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
let best_cars: Vec<_> = BEST_CARS
.lock()
.unwrap()
// Lock is scoped to avoid holding it during file I/O below
let best_cars: HashSet<_> = {
let guard = 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))
.collect();
// Check each car in the user's collection
for my_car in get_my_car_list() {
if !best_cars.contains(&my_car) {
// Find the car details for this GUID
for bad_car in get_car_list() {
if bad_car.guid == my_car && !bad_car.prize && bad_car.rq < 80 {
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
@ -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(&current_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
///
/// 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
/// external analysis or when detailed performance data is required.
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();
// 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
for key in sorted_keys {
if let Some(records) = best_cars.get(key) {
sorted_keys
.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 {
println!(
"{},{},{},{},{},{},{},{},{}",
@ -278,7 +437,6 @@ fn print_best_times() {
}
}
}
}
/// 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.
@ -300,19 +458,23 @@ fn print_best_times() {
/// # 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(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 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
for (_key, best_list) in best_cars.iter() {
for best_car in best_list {
let entry = car_count
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();
@ -321,8 +483,14 @@ fn print_best_cars(filter_out_owned: bool) {
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
@ -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
/// overall processing speed when analyzing large car databases.
async fn parse_car_times(car: Car) {
// Get list of user's owned cars for ownership tracking
let my_cars = get_my_car_list();
async fn parse_car_times(car: Car, my_cars: &HashSet<String>) {
// Construct file path for this car's timing data
let file_name = car.rid.to_owned() + ".json";
let local_file_path = "times/".to_owned() + file_name.as_str();
let file_content = fs::read_to_string(local_file_path);
// Parse JSON data, handling potential file errors gracefully
let json_data: Value =
serde_json::from_str(&file_content.unwrap_or(String::default())).unwrap_or(Value::Null);
// Read file content, emitting a warning on failure
let file_content = match fs::read_to_string(&local_file_path) {
Ok(content) => content,
Err(e) => {
eprintln!("Warning: could not read {local_file_path}: {e}");
String::new()
}
};
// Parse JSON data, emitting a warning on failure
let json_data: Value = match serde_json::from_str(&file_content) {
Ok(v) => v,
Err(e) => {
if !file_content.is_empty() {
eprintln!("Warning: could not parse {local_file_path}: {e}");
}
Value::Null
}
};
// Process each configuration variant (111, 233, 323, etc.)
if let Some(configurations) = json_data["data"].as_object() {
@ -430,9 +610,7 @@ async fn parse_car_times(car: Car) {
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)) =
split_prefix_postfix(track_id, "_a")
{
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);
@ -440,7 +618,7 @@ async fn parse_car_times(car: Car) {
let down_votes = details["down"].as_array();
let no_down_votes = match down_votes {
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
@ -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
/// 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![];
// 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 {
parse_car_times(car).await;
parse_car_times(car, &my_cars).await;
});
handles.push(handle);
}
// Wait for all parsing tasks to complete
join_all(handles).await;
// 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 {
eprintln!("Warning: parsing task failed: {e}");
}
/// Splits a string at the first occurrence of a delimiter
///
/// Utility function for parsing track identifiers that combine track name
/// with surface condition (e.g., "laguna_a00" -> ("laguna", "00")).
///
/// # Arguments
///
/// * `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)
})
}
car_list
}
/// 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.
async fn fetch_times() {
// 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![];
for car in get_car_list() {
@ -678,8 +845,20 @@ fn get_car_list() -> Vec<Car> {
// Read the cars database file
let cars_file = "cars_repo.json";
let cars_data: Vec<Value> =
serde_json::from_str(&fs::read_to_string(cars_file).unwrap()).unwrap();
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;
}
};
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
for car in cars_data {
@ -698,48 +877,6 @@ fn get_car_list() -> Vec<Car> {
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
///
/// Fetches the latest track definitions including track names, conditions,
@ -903,21 +1040,21 @@ mod tests {
}
#[test]
fn test_split_prefix_postfix() {
fn test_split_once() {
// Test normal case
let result = split_prefix_postfix("laguna_a00", "_a");
let result = "laguna_a00".split_once("_a");
assert_eq!(result, Some(("laguna", "00")));
// Test no delimiter
let result = split_prefix_postfix("laguna", "_a");
let result = "laguna".split_once("_a");
assert_eq!(result, None);
// 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")));
// Test empty strings
let result = split_prefix_postfix("", "_a");
let result = "".split_once("_a");
assert_eq!(result, None);
}