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"] }
|
||||
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"
|
||||
|
|
|
|||
411
src/records.rs
411
src/records.rs
|
|
@ -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(¤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
|
||||
///
|
||||
/// 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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue