refactor: modular architecture with unified error handling and DashMap concurrency
- Break monolithic records.rs into commands/, output/, parsing/ modules - Add lib.rs with shared types (AppError, Car, BestCarRecord, etc.) - Replace Arc<Mutex<HashMap>> with DashMap for concurrent access - Add unified AppError enum with thiserror - Add configurable CLI paths (--my-cars, --cars-db, --times-dir, --tracks-db) - Add serde deserialization for Car struct - Add CSV escaping for fields with commas/quotes/newlines - Fix track name splitting: split_once -> rsplit_once for _a handling - Add integration tests, proptest property-based tests, and unit tests - Add rustfmt.toml configuration - Update documentation (API, ARCHITECTURE, DATA_STRUCTURES, README)
This commit is contained in:
parent
9d57b18a4e
commit
a9a46ee551
24 changed files with 3541 additions and 1470 deletions
32
src/commands/all.rs
Normal file
32
src/commands/all.rs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
//! All command implementation.
|
||||
//!
|
||||
//! Generates all output files in a single pass: best.csv, very_best.csv,
|
||||
//! trash.csv, details.csv, wishlist.csv, add.csv, and remove.csv.
|
||||
|
||||
use top_drives::AppError;
|
||||
|
||||
use crate::commands::{load_my_cars_or_warn, run_analysis};
|
||||
use crate::output;
|
||||
|
||||
/// Executes the all command: generates all output files in a single analysis pass.
|
||||
pub async fn run_all(
|
||||
my_cars_path: &str,
|
||||
cars_db_path: &str,
|
||||
times_dir: &str,
|
||||
min_count: usize,
|
||||
max_wishlist: usize,
|
||||
) -> Result<(), AppError> {
|
||||
let my_data = load_my_cars_or_warn(my_cars_path)?;
|
||||
let (ctx, counts) = run_analysis(&my_data.owned, cars_db_path, times_dir).await?;
|
||||
output::write_best_csv(&counts)?;
|
||||
output::write_very_best_csv(&counts, min_count)?;
|
||||
output::write_trash_csv(&counts, &my_data.owned)?;
|
||||
output::write_details_csv(&ctx)?;
|
||||
output::write_wishlist_files(
|
||||
&counts, &my_data.owned, &my_data.wishlist, min_count, max_wishlist,
|
||||
)?;
|
||||
println!(
|
||||
"Written: best.csv, very_best.csv, trash.csv, details.csv, wishlist.csv, add.csv, remove.csv"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
19
src/commands/best.rs
Normal file
19
src/commands/best.rs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
//! Best and very_best command implementations.
|
||||
//!
|
||||
//! Generates best.csv (all cars best on at least one track) and
|
||||
//! very_best.csv (cars best on >= min_count tracks).
|
||||
|
||||
use top_drives::AppError;
|
||||
|
||||
use crate::commands::{load_my_cars_or_warn, run_analysis};
|
||||
use crate::output::{write_best_csv, write_very_best_csv};
|
||||
|
||||
/// Executes the best command: generates best.csv and very_best.csv.
|
||||
pub async fn run_best(my_cars_path: &str, cars_db_path: &str, times_dir: &str, min_count: usize) -> Result<(), AppError> {
|
||||
let my_data = load_my_cars_or_warn(my_cars_path)?;
|
||||
let (_ctx, counts) = run_analysis(&my_data.owned, cars_db_path, times_dir).await?;
|
||||
write_best_csv(&counts)?;
|
||||
write_very_best_csv(&counts, min_count)?;
|
||||
println!("Written: best.csv, very_best.csv");
|
||||
Ok(())
|
||||
}
|
||||
18
src/commands/details.rs
Normal file
18
src/commands/details.rs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
//! Details command implementation.
|
||||
//!
|
||||
//! Generates details.csv with comprehensive timing data for all
|
||||
//! track/condition/RQ combinations.
|
||||
|
||||
use top_drives::AppError;
|
||||
|
||||
use crate::commands::{load_my_cars_or_warn, run_analysis};
|
||||
use crate::output::write_details_csv;
|
||||
|
||||
/// Executes the details command: generates details.csv.
|
||||
pub async fn run_details(my_cars_path: &str, cars_db_path: &str, times_dir: &str) -> Result<(), AppError> {
|
||||
let my_data = load_my_cars_or_warn(my_cars_path)?;
|
||||
let (ctx, _counts) = run_analysis(&my_data.owned, cars_db_path, times_dir).await?;
|
||||
write_details_csv(&ctx)?;
|
||||
println!("Written: details.csv");
|
||||
Ok(())
|
||||
}
|
||||
48
src/commands/fetch.rs
Normal file
48
src/commands/fetch.rs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
//! Fetch command implementation.
|
||||
//!
|
||||
//! Downloads car database, tracks database, and per-car timing data
|
||||
//! from remote repositories.
|
||||
|
||||
use top_drives::{AppError, get_file};
|
||||
|
||||
use crate::parsing::fetch_times;
|
||||
|
||||
/// Base URL for the remote data repository on GitHub.
|
||||
/// Single place to update if the repository path or branch changes.
|
||||
const GITHUB_RAW_BASE: &str =
|
||||
"https://github.com/TiagoXavi/topdrives-records/raw/refs/heads/main/src/database";
|
||||
|
||||
/// Returns the full URL for the car database file.
|
||||
fn cars_db_url() -> String {
|
||||
format!("{GITHUB_RAW_BASE}/cars_final.json")
|
||||
}
|
||||
|
||||
/// Returns the full URL for the tracks database file.
|
||||
fn tracks_db_url() -> String {
|
||||
format!("{GITHUB_RAW_BASE}/tracks_repo.json")
|
||||
}
|
||||
|
||||
/// Downloads the tracks database file from the remote repository.
|
||||
async fn get_tracks_file(tracks_db_path: &str) -> Result<(), AppError> {
|
||||
println!("Fetching tracks file...");
|
||||
get_file(&tracks_db_url(), tracks_db_path).await
|
||||
}
|
||||
|
||||
/// Downloads the cars database file from the remote repository.
|
||||
pub async fn get_cars_file(cars_db_path: &str) -> Result<(), AppError> {
|
||||
println!("Fetching cars file...");
|
||||
get_file(&cars_db_url(), cars_db_path).await
|
||||
}
|
||||
|
||||
/// Executes the fetch command: downloads all data files.
|
||||
///
|
||||
/// Downloads the car database, tracks database, and per-car timing files.
|
||||
/// Fails fast on critical dependency failures — the cars DB must be available
|
||||
/// before per-car timing files can be fetched.
|
||||
pub async fn run_fetch(cars_db_path: &str, tracks_db_path: &str, times_dir: &str) -> Result<(), AppError> {
|
||||
get_cars_file(cars_db_path).await?;
|
||||
get_tracks_file(tracks_db_path).await?;
|
||||
fetch_times(cars_db_path, times_dir).await?;
|
||||
println!("Fetch complete — all files downloaded successfully.");
|
||||
Ok(())
|
||||
}
|
||||
63
src/commands/mod.rs
Normal file
63
src/commands/mod.rs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
//! Command dispatch and shared helpers.
|
||||
//!
|
||||
//! Provides the `run_analysis` orchestration function and `load_my_cars_or_warn`
|
||||
//! helper used by all command handlers.
|
||||
|
||||
pub mod all;
|
||||
pub mod best;
|
||||
pub mod details;
|
||||
pub mod fetch;
|
||||
pub mod track;
|
||||
pub mod trash;
|
||||
pub mod wishlist;
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use top_drives::{
|
||||
AppError, AnalysisContext, CarCounts, MyCarsData,
|
||||
compute_car_counts, load_my_cars_data,
|
||||
};
|
||||
|
||||
use crate::parsing::parse_all_times;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: load my_cars.json with graceful degradation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Loads user car data, printing a warning and returning empty data if the file is missing.
|
||||
///
|
||||
/// Only `MissingFile` errors are gracefully degraded — parse errors and other
|
||||
/// failures are propagated to the caller, because running analysis with
|
||||
/// silently-corrupted ownership data would produce misleading results.
|
||||
pub fn load_my_cars_or_warn(path: &str) -> Result<MyCarsData, AppError> {
|
||||
match load_my_cars_data(path) {
|
||||
Ok(data) => Ok(data),
|
||||
Err(AppError::MissingFile(msg)) => {
|
||||
eprintln!("Warning: {msg}");
|
||||
eprintln!(" Analysis will run without ownership data.");
|
||||
Ok(MyCarsData::default())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Analysis orchestration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Common analysis setup: loads user data, parses all timing files, and computes car counts.
|
||||
///
|
||||
/// Returns the analysis context (for stdout commands) and the aggregated car counts
|
||||
/// (for CSV output commands). The car list is stored inside `CarCounts`.
|
||||
pub async fn run_analysis(
|
||||
owned: &HashSet<String>,
|
||||
cars_db_path: &str,
|
||||
times_dir: &str,
|
||||
) -> Result<(AnalysisContext, CarCounts), AppError> {
|
||||
let ctx = AnalysisContext::new();
|
||||
let owned = Arc::new(owned.clone());
|
||||
let car_list = parse_all_times(owned, &ctx, cars_db_path, times_dir).await?;
|
||||
let counts = compute_car_counts(&ctx, car_list)?;
|
||||
Ok((ctx, counts))
|
||||
}
|
||||
22
src/commands/track.rs
Normal file
22
src/commands/track.rs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
//! Track command implementation.
|
||||
//!
|
||||
//! Shows the best car for a specific track/condition/RQ combination.
|
||||
|
||||
use top_drives::AppError;
|
||||
|
||||
use crate::commands::{load_my_cars_or_warn, run_analysis};
|
||||
use crate::output::print_best_car;
|
||||
|
||||
/// Executes the track command: shows best car for a specific track/condition/RQ.
|
||||
pub async fn run_track(
|
||||
my_cars_path: &str,
|
||||
cars_db_path: &str,
|
||||
times_dir: &str,
|
||||
track: &str,
|
||||
condition: &str,
|
||||
rq: u8,
|
||||
) -> Result<(), AppError> {
|
||||
let my_data = load_my_cars_or_warn(my_cars_path)?;
|
||||
let (ctx, _counts) = run_analysis(&my_data.owned, cars_db_path, times_dir).await?;
|
||||
print_best_car(&ctx, track, condition, rq)
|
||||
}
|
||||
17
src/commands/trash.rs
Normal file
17
src/commands/trash.rs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
//! Trash command implementation.
|
||||
//!
|
||||
//! Generates trash.csv — owned cars not best on any track (RQ < 80, not prize).
|
||||
|
||||
use top_drives::AppError;
|
||||
|
||||
use crate::commands::{load_my_cars_or_warn, run_analysis};
|
||||
use crate::output::write_trash_csv;
|
||||
|
||||
/// Executes the trash command: generates trash.csv.
|
||||
pub async fn run_trash(my_cars_path: &str, cars_db_path: &str, times_dir: &str) -> Result<(), AppError> {
|
||||
let my_data = load_my_cars_or_warn(my_cars_path)?;
|
||||
let (_ctx, counts) = run_analysis(&my_data.owned, cars_db_path, times_dir).await?;
|
||||
write_trash_csv(&counts, &my_data.owned)?;
|
||||
println!("Written: trash.csv");
|
||||
Ok(())
|
||||
}
|
||||
25
src/commands/wishlist.rs
Normal file
25
src/commands/wishlist.rs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
//! Wishlist command implementation.
|
||||
//!
|
||||
//! Generates wishlist.csv, add.csv, and remove.csv from ideal wishlist computation.
|
||||
|
||||
use top_drives::AppError;
|
||||
|
||||
use crate::commands::{load_my_cars_or_warn, run_analysis};
|
||||
use crate::output::write_wishlist_files;
|
||||
|
||||
/// Executes the wishlist command: generates wishlist.csv, add.csv, and remove.csv.
|
||||
pub async fn run_wishlist(
|
||||
my_cars_path: &str,
|
||||
cars_db_path: &str,
|
||||
times_dir: &str,
|
||||
min_count: usize,
|
||||
max_wishlist: usize,
|
||||
) -> Result<(), AppError> {
|
||||
let my_data = load_my_cars_or_warn(my_cars_path)?;
|
||||
let (_ctx, counts) = run_analysis(&my_data.owned, cars_db_path, times_dir).await?;
|
||||
write_wishlist_files(
|
||||
&counts, &my_data.owned, &my_data.wishlist, min_count, max_wishlist,
|
||||
)?;
|
||||
println!("Written: wishlist.csv, add.csv, remove.csv");
|
||||
Ok(())
|
||||
}
|
||||
410
src/lib.rs
410
src/lib.rs
|
|
@ -10,6 +10,7 @@
|
|||
//! - **`AnalysisContext` struct** — holds mutable analysis state (replaces global static)
|
||||
//! - **`CarCounts` struct** — aggregated per-car win counts
|
||||
//! - **`MyCarsData` struct** — parsed user data from `my_cars.json`
|
||||
//! - **`AppError` enum** — unified error type for the application
|
||||
//! - **`translate_track_name()`** — internal track code → human-readable name
|
||||
//! - **`translate_track_type()`** — condition code → surface name
|
||||
//! - **`get_file()`** — async file download utility
|
||||
|
|
@ -21,26 +22,63 @@ use std::collections::{HashMap, HashSet};
|
|||
use std::fs;
|
||||
use std::fs::File;
|
||||
use std::io::BufReader;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use dashmap::DashMap;
|
||||
use reqwest::get;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
/// Custom error type for the Top Drives application.
|
||||
///
|
||||
/// Provides a unified error handling strategy across all modules,
|
||||
/// replacing the mix of `Result<(), String>`, `std::io::Result`, and
|
||||
/// silent `eprintln!` + early return patterns.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppError {
|
||||
/// An I/O error occurred (file not found, permission denied, etc.)
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
/// A network error occurred during data fetching
|
||||
#[error("Network error: {0}")]
|
||||
Network(String),
|
||||
|
||||
/// A JSON parsing error in a specific file
|
||||
#[error("Parse error in {file}: {reason}")]
|
||||
Parse {
|
||||
/// The file that failed to parse
|
||||
file: String,
|
||||
/// The reason for the parse failure
|
||||
reason: String,
|
||||
},
|
||||
|
||||
/// A required data file is missing
|
||||
#[error("Missing data file: {0}")]
|
||||
MissingFile(String),
|
||||
|
||||
/// An internal runtime error (e.g., semaphore closed, task join failure)
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
/// Core car information structure containing basic car metadata.
|
||||
/// Used throughout the application to represent car entities.
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Car {
|
||||
/// Unique car identifier used in the records database
|
||||
pub rid: String,
|
||||
/// Car manufacturer/brand name
|
||||
pub brand: String,
|
||||
/// Car model name (without brand)
|
||||
#[serde(rename = "onlyName")]
|
||||
pub only_name: String,
|
||||
/// Car model year
|
||||
pub year: u16,
|
||||
/// Rarity level (10-113, affects performance brackets)
|
||||
pub rq: u8,
|
||||
/// Whether this car is only available as a prize car
|
||||
#[serde(default)]
|
||||
pub prize: bool,
|
||||
/// Game-specific unique identifier for the car
|
||||
pub guid: String,
|
||||
|
|
@ -80,10 +118,13 @@ pub type TripletKey = (String, u8, String);
|
|||
///
|
||||
/// Replaces the former global `static BEST_CARS`. Created in `main()` and
|
||||
/// passed through the call chain so that no global mutable state is needed.
|
||||
///
|
||||
/// Uses `Arc<DashMap>` for concurrent access from spawned tasks.
|
||||
/// `DashMap` provides sharded internal locking for high-concurrency access.
|
||||
pub struct AnalysisContext {
|
||||
/// Best car records for each track/RQ/surface combination, protected by
|
||||
/// `Arc<Mutex<...>>` for concurrent access from spawned tasks.
|
||||
pub best_cars: Arc<Mutex<HashMap<TripletKey, Vec<BestCarRecord>>>>,
|
||||
/// Best car records for each track/RQ/surface combination.
|
||||
/// Wrapped in `Arc` so it can be shared across async tasks.
|
||||
pub best_cars: Arc<DashMap<TripletKey, Vec<BestCarRecord>>>,
|
||||
}
|
||||
|
||||
impl AnalysisContext {
|
||||
|
|
@ -91,7 +132,7 @@ impl AnalysisContext {
|
|||
#[allow(clippy::new_without_default)]
|
||||
pub fn new() -> Self {
|
||||
AnalysisContext {
|
||||
best_cars: Arc::new(Mutex::new(HashMap::new())),
|
||||
best_cars: Arc::new(DashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -112,21 +153,19 @@ pub struct CarCounts {
|
|||
|
||||
/// Computes per-car win counts from the analysis context.
|
||||
/// Returns a `CarCounts` struct that can be shared across all output functions.
|
||||
pub fn compute_car_counts(ctx: &AnalysisContext, car_list: Vec<Car>) -> CarCounts {
|
||||
let best_cars = ctx.best_cars.lock()
|
||||
.expect("best_cars mutex poisoned — another thread panicked");
|
||||
pub fn compute_car_counts(ctx: &AnalysisContext, car_list: Vec<Car>) -> Result<CarCounts, AppError> {
|
||||
let mut by_rid: HashMap<String, (BestCarRecord, usize)> = HashMap::new();
|
||||
let mut guid_to_rid: HashMap<String, String> = HashMap::new();
|
||||
|
||||
for (_key, best_list) in best_cars.iter() {
|
||||
for best_car in best_list {
|
||||
let entry = by_rid
|
||||
for entry in ctx.best_cars.iter() {
|
||||
for best_car in entry.value() {
|
||||
let rid_entry = by_rid
|
||||
.entry(best_car.car_rid.clone())
|
||||
.or_insert_with(|| (best_car.clone(), 0usize));
|
||||
entry.1 += 1;
|
||||
rid_entry.1 += 1;
|
||||
// Keep the record with the best (lowest) time for this car.
|
||||
if best_car.time < entry.0.time {
|
||||
entry.0 = best_car.clone();
|
||||
if best_car.time < rid_entry.0.time {
|
||||
rid_entry.0 = best_car.clone();
|
||||
}
|
||||
// HashMap iteration order is non-deterministic, but car_rid and guid
|
||||
// have a 1:1 relationship in the data model, so any mapping is correct.
|
||||
|
|
@ -140,13 +179,14 @@ pub fn compute_car_counts(ctx: &AnalysisContext, car_list: Vec<Car>) -> CarCount
|
|||
.map(|c| (c.guid.clone(), c.clone()))
|
||||
.collect();
|
||||
|
||||
CarCounts { by_rid, guid_to_rid, car_map, car_list }
|
||||
Ok(CarCounts { by_rid, guid_to_rid, car_map, car_list })
|
||||
}
|
||||
|
||||
/// Parsed user data from `my_cars.json`
|
||||
///
|
||||
/// Holds both the owned car GUIDs and wishlist GUIDs extracted from a single
|
||||
/// file read, avoiding duplicate I/O.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct MyCarsData {
|
||||
/// GUIDs of cars the user owns (from `playerDeck[].cardId` where `locked == true`)
|
||||
pub owned: HashSet<String>,
|
||||
|
|
@ -166,31 +206,19 @@ pub struct MyCarsData {
|
|||
///
|
||||
/// # Error Handling
|
||||
///
|
||||
/// Returns an empty `MyCarsData` (both sets empty) with a warning if the file
|
||||
/// is missing or cannot be parsed, matching the graceful degradation pattern
|
||||
/// used throughout the codebase.
|
||||
pub fn load_my_cars_data() -> MyCarsData {
|
||||
let file = match File::open("my_cars.json") {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
eprintln!("Warning: could not open my_cars.json: {e}");
|
||||
return MyCarsData {
|
||||
owned: HashSet::new(),
|
||||
wishlist: HashSet::new(),
|
||||
};
|
||||
/// Returns an `AppError` if the file cannot be opened or parsed.
|
||||
/// Use `MyCarsData::default()` for graceful degradation when the file is optional.
|
||||
pub fn load_my_cars_data(path: &str) -> Result<MyCarsData, AppError> {
|
||||
let file = File::open(path).map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
AppError::MissingFile(format!("{path}: {e}"))
|
||||
} else {
|
||||
AppError::Io(e)
|
||||
}
|
||||
};
|
||||
})?;
|
||||
let reader = BufReader::new(file);
|
||||
let json: Value = match serde_json::from_reader(reader) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!("Warning: could not parse my_cars.json: {e}");
|
||||
return MyCarsData {
|
||||
owned: HashSet::new(),
|
||||
wishlist: HashSet::new(),
|
||||
};
|
||||
}
|
||||
};
|
||||
let json: Value = serde_json::from_reader(reader)
|
||||
.map_err(|e| AppError::Parse { file: path.to_string(), reason: e.to_string() })?;
|
||||
|
||||
// Extract owned car GUIDs from playerDeck
|
||||
let owned: HashSet<String> = json
|
||||
|
|
@ -223,7 +251,7 @@ pub fn load_my_cars_data() -> MyCarsData {
|
|||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
MyCarsData { owned, wishlist }
|
||||
Ok(MyCarsData { owned, wishlist })
|
||||
}
|
||||
|
||||
/// Comparison function for sorting cars by RQ, then brand, then model name.
|
||||
|
|
@ -238,21 +266,87 @@ pub fn compare_cars(a: &Car, b: &Car) -> std::cmp::Ordering {
|
|||
/// Downloads a file from a URL and saves it locally.
|
||||
///
|
||||
/// Returns `Ok(())` on success, or an error describing what went wrong.
|
||||
/// Removes any existing file at the local path before downloading.
|
||||
pub async fn get_file(file_url: &str, local_file_path: &str) -> Result<(), String> {
|
||||
let _ = fs::remove_file(local_file_path);
|
||||
///
|
||||
/// Writes to a temporary file first, then renames on success, so a failed
|
||||
/// download never deletes the existing file.
|
||||
///
|
||||
/// Retries up to `MAX_FETCH_RETRIES` times on transient failures (server errors,
|
||||
/// connection errors, or empty responses). Permanent HTTP errors (4xx) fail immediately.
|
||||
pub async fn get_file(file_url: &str, local_file_path: &str) -> Result<(), AppError> {
|
||||
/// Maximum number of retries when the server returns an empty response.
|
||||
const MAX_FETCH_RETRIES: usize = 3;
|
||||
|
||||
match get(file_url).await {
|
||||
Ok(response) => {
|
||||
match response.text().await {
|
||||
Ok(content) => {
|
||||
fs::write(local_file_path, content)
|
||||
.map_err(|e| format!("Error writing {local_file_path}: {e}"))
|
||||
let tmp_path = format!("{local_file_path}.tmp");
|
||||
let _ = fs::remove_file(&tmp_path); // clean stale temp file, OK to ignore
|
||||
|
||||
let mut attempt = 0usize;
|
||||
loop {
|
||||
match get(file_url).await {
|
||||
Ok(response) => {
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
// Only retry on server errors (5xx); client errors (4xx) are permanent.
|
||||
if status.is_server_error() && attempt < MAX_FETCH_RETRIES {
|
||||
eprintln!(
|
||||
"Warning: HTTP {} from {file_url} (attempt {}/{MAX_FETCH_RETRIES}), retrying...",
|
||||
status,
|
||||
attempt + 1,
|
||||
);
|
||||
tokio::time::sleep(
|
||||
std::time::Duration::from_millis(500 * (attempt + 1) as u64)
|
||||
).await;
|
||||
attempt += 1;
|
||||
continue;
|
||||
}
|
||||
return Err(AppError::Network(
|
||||
format!("HTTP {status} from {file_url}")
|
||||
));
|
||||
}
|
||||
Err(e) => Err(format!("Error reading response from {file_url}: {e}")),
|
||||
match response.text().await {
|
||||
Ok(content) => {
|
||||
if content.is_empty() {
|
||||
if attempt < MAX_FETCH_RETRIES {
|
||||
eprintln!(
|
||||
"Warning: empty response from {file_url} (attempt {}/{MAX_FETCH_RETRIES}), retrying...",
|
||||
attempt + 1,
|
||||
);
|
||||
tokio::time::sleep(
|
||||
std::time::Duration::from_millis(500 * (attempt + 1) as u64)
|
||||
).await;
|
||||
attempt += 1;
|
||||
continue;
|
||||
}
|
||||
return Err(AppError::Network(
|
||||
format!("Empty response from {file_url} after {MAX_FETCH_RETRIES} retries")
|
||||
));
|
||||
}
|
||||
fs::write(&tmp_path, &content).map_err(AppError::Io)?;
|
||||
return fs::rename(&tmp_path, local_file_path).map_err(AppError::Io);
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(AppError::Network(
|
||||
format!("Error reading response from {file_url}: {e}")
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
if attempt < MAX_FETCH_RETRIES {
|
||||
eprintln!(
|
||||
"Warning: failed to fetch {file_url} (attempt {}/{MAX_FETCH_RETRIES}): {error}",
|
||||
attempt + 1,
|
||||
);
|
||||
tokio::time::sleep(
|
||||
std::time::Duration::from_millis(500 * (attempt + 1) as u64)
|
||||
).await;
|
||||
attempt += 1;
|
||||
continue;
|
||||
}
|
||||
return Err(AppError::Network(
|
||||
format!("Failed to fetch {file_url} after {MAX_FETCH_RETRIES} retries: {error}")
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(error) => Err(format!("Failed to fetch {file_url}: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -343,3 +437,217 @@ pub fn translate_track_name(track_code: &str) -> String {
|
|||
.cloned()
|
||||
.unwrap_or_else(|| track_code.to_string())
|
||||
}
|
||||
|
||||
/// Escapes a field for safe inclusion in a CSV file.
|
||||
///
|
||||
/// If the field contains a comma, quote, newline, or carriage return,
|
||||
/// it is wrapped in double-quotes with internal quotes doubled.
|
||||
pub fn escape_csv_field(field: &str) -> String {
|
||||
if field.contains(',') || field.contains('"') || field.contains('\n') || field.contains('\r') {
|
||||
format!("\"{}\"", field.replace('"', "\"\""))
|
||||
} else {
|
||||
field.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_translate_track_name() {
|
||||
assert_eq!(translate_track_name("laguna"), "Laguna Seca");
|
||||
assert_eq!(translate_track_name("mnCity"), "Monaco Streets");
|
||||
assert_eq!(translate_track_name("testBowl"), "Test Bowl");
|
||||
assert_eq!(translate_track_name("unknown_track"), "unknown_track");
|
||||
assert_eq!(translate_track_name("drag100"), "0-100mph");
|
||||
assert_eq!(translate_track_name("mile4"), "1/4 Mile Drag");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_translate_track_type() {
|
||||
assert_eq!(translate_track_type("00"), "Dry");
|
||||
assert_eq!(translate_track_type("01"), "Wet");
|
||||
assert_eq!(translate_track_type("10"), "Dirt");
|
||||
assert_eq!(translate_track_type("11"), "Dirt Wet");
|
||||
assert_eq!(translate_track_type("30"), "Ice");
|
||||
assert_eq!(translate_track_type("60"), "Snow");
|
||||
assert_eq!(translate_track_type("99"), "99");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_track_name_completeness() {
|
||||
let test_tracks = vec![
|
||||
"laguna",
|
||||
"mnCity",
|
||||
"testBowl",
|
||||
"drag100",
|
||||
"mile4",
|
||||
"airplaneHangars",
|
||||
"dealsGap",
|
||||
"northloop",
|
||||
];
|
||||
|
||||
for track in test_tracks {
|
||||
let name = translate_track_name(track);
|
||||
assert_ne!(name, track, "Track {} should have a proper name mapping", track);
|
||||
assert!(!name.is_empty(), "Track name should not be empty");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_condition_completeness() {
|
||||
let test_conditions = vec!["00", "01", "10", "11", "20", "30", "40", "41", "50", "60"];
|
||||
|
||||
for condition in test_conditions {
|
||||
let name = translate_track_type(condition);
|
||||
assert_ne!(name, condition, "Condition {} should have a proper name mapping", condition);
|
||||
assert!(!name.is_empty(), "Condition name should not be empty");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compare_cars_ordering() {
|
||||
let car_a = Car {
|
||||
rid: "A".to_string(), brand: "BMW".to_string(), only_name: "M3".to_string(),
|
||||
year: 2020, rq: 80, prize: false, guid: "guid_a".to_string(),
|
||||
};
|
||||
let car_b = Car {
|
||||
rid: "B".to_string(), brand: "Audi".to_string(), only_name: "RS6".to_string(),
|
||||
year: 2021, rq: 90, prize: false, guid: "guid_b".to_string(),
|
||||
};
|
||||
let car_c = Car {
|
||||
rid: "C".to_string(), brand: "BMW".to_string(), only_name: "M5".to_string(),
|
||||
year: 2022, rq: 80, prize: false, guid: "guid_c".to_string(),
|
||||
};
|
||||
|
||||
// Lower RQ sorts first
|
||||
assert_eq!(compare_cars(&car_a, &car_b), std::cmp::Ordering::Less);
|
||||
// Same RQ, same brand, then model alphabetically (M3 < M5)
|
||||
assert_eq!(compare_cars(&car_a, &car_c), std::cmp::Ordering::Less);
|
||||
// Higher RQ sorts last
|
||||
assert_eq!(compare_cars(&car_b, &car_a), std::cmp::Ordering::Greater);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_car_counts_basic() {
|
||||
let ctx = AnalysisContext::new();
|
||||
let record = BestCarRecord {
|
||||
car_rid: "Test_Car".to_string(),
|
||||
brand: "TestBrand".to_string(),
|
||||
only_name: "TestModel".to_string(),
|
||||
year: 2020,
|
||||
rq: 85,
|
||||
config: "111".to_string(),
|
||||
time: 100.0,
|
||||
owned: true,
|
||||
prize: false,
|
||||
guid: "guid_test".to_string(),
|
||||
};
|
||||
|
||||
// Manually insert into best_cars
|
||||
{
|
||||
ctx.best_cars.insert(
|
||||
("Laguna Seca".to_string(), 85, "Dry".to_string()),
|
||||
vec![record.clone()],
|
||||
);
|
||||
ctx.best_cars.insert(
|
||||
("Monaco Streets".to_string(), 85, "Dry".to_string()),
|
||||
vec![record.clone()],
|
||||
);
|
||||
}
|
||||
|
||||
let car_list = vec![Car {
|
||||
rid: "Test_Car".to_string(),
|
||||
brand: "TestBrand".to_string(),
|
||||
only_name: "TestModel".to_string(),
|
||||
year: 2020,
|
||||
rq: 85,
|
||||
prize: false,
|
||||
guid: "guid_test".to_string(),
|
||||
}];
|
||||
|
||||
let counts = compute_car_counts(&ctx, car_list).unwrap();
|
||||
|
||||
// Should have exactly 1 entry in by_rid
|
||||
assert_eq!(counts.by_rid.len(), 1);
|
||||
let (rec, count) = counts.by_rid.get("Test_Car").unwrap();
|
||||
assert_eq!(count, &2); // best on 2 tracks
|
||||
assert_eq!(rec.car_rid, "Test_Car");
|
||||
|
||||
// guid_to_rid should map guid → rid
|
||||
assert_eq!(counts.guid_to_rid.get("guid_test"), Some(&"Test_Car".to_string()));
|
||||
|
||||
// car_map should have the car
|
||||
assert!(counts.car_map.contains_key("guid_test"));
|
||||
assert_eq!(counts.car_list.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_car_counts_keeps_best_time() {
|
||||
let ctx = AnalysisContext::new();
|
||||
|
||||
let record_fast = BestCarRecord {
|
||||
car_rid: "Car_A".to_string(),
|
||||
brand: "Brand".to_string(),
|
||||
only_name: "Model".to_string(),
|
||||
year: 2020,
|
||||
rq: 85,
|
||||
config: "111".to_string(),
|
||||
time: 90.0,
|
||||
owned: false,
|
||||
prize: false,
|
||||
guid: "guid_a".to_string(),
|
||||
};
|
||||
let record_slow = BestCarRecord {
|
||||
car_rid: "Car_A".to_string(),
|
||||
brand: "Brand".to_string(),
|
||||
only_name: "Model".to_string(),
|
||||
year: 2020,
|
||||
rq: 85,
|
||||
config: "233".to_string(),
|
||||
time: 110.0,
|
||||
owned: false,
|
||||
prize: false,
|
||||
guid: "guid_a".to_string(),
|
||||
};
|
||||
|
||||
{
|
||||
ctx.best_cars.insert(
|
||||
("Track1".to_string(), 85, "Dry".to_string()),
|
||||
vec![record_fast],
|
||||
);
|
||||
ctx.best_cars.insert(
|
||||
("Track2".to_string(), 85, "Dry".to_string()),
|
||||
vec![record_slow],
|
||||
);
|
||||
}
|
||||
|
||||
let counts = compute_car_counts(&ctx, vec![]).unwrap();
|
||||
let (rec, count) = counts.by_rid.get("Car_A").unwrap();
|
||||
assert_eq!(*count, 2);
|
||||
assert_eq!(rec.time, 90.0); // Should keep the faster time
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_escape_csv_field() {
|
||||
// Simple field — no escaping needed
|
||||
assert_eq!(escape_csv_field("hello"), "hello");
|
||||
// Field with comma
|
||||
assert_eq!(escape_csv_field("hello,world"), "\"hello,world\"");
|
||||
// Field with quote
|
||||
assert_eq!(escape_csv_field("say \"hi\""), "\"say \"\"hi\"\"\"");
|
||||
// Field with newline
|
||||
assert_eq!(escape_csv_field("line1\nline2"), "\"line1\nline2\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_my_cars_data_missing_file() {
|
||||
let result = load_my_cars_data("nonexistent_file.json");
|
||||
assert!(result.is_err());
|
||||
match result.unwrap_err() {
|
||||
AppError::MissingFile(msg) => assert!(msg.contains("nonexistent_file.json")),
|
||||
other => panic!("Expected MissingFile error, got: {other}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
649
src/output/mod.rs
Normal file
649
src/output/mod.rs
Normal file
|
|
@ -0,0 +1,649 @@
|
|||
//! CSV output utilities and sorting helpers.
|
||||
//!
|
||||
//! Provides generic CSV writing infrastructure and comparison functions
|
||||
//! used across multiple output commands.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
|
||||
use top_drives::{BestCarRecord, CarCounts, TripletKey, AnalysisContext, AppError, compare_cars, escape_csv_field};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sorting helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Comparison function for sorting `BestCarRecord`s by RQ, then brand, then model name.
|
||||
///
|
||||
/// Avoids cloning into a `Car` just for sorting — uses the same ordering as
|
||||
/// `compare_cars` but operates directly on `BestCarRecord`.
|
||||
pub fn compare_records(a: &BestCarRecord, b: &BestCarRecord) -> std::cmp::Ordering {
|
||||
a.rq.cmp(&b.rq)
|
||||
.then(a.brand.cmp(&b.brand))
|
||||
.then(a.only_name.cmp(&b.only_name))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CSV output helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Writes a sorted CSV file with a header row.
|
||||
///
|
||||
/// Generic helper that sorts entries by index (avoiding cloning), writes the header,
|
||||
/// and formats each row. Returns `std::io::Result<()>` so callers can propagate errors
|
||||
/// instead of panicking.
|
||||
pub fn write_sorted_csv<T, G>(
|
||||
path: &str,
|
||||
header: &str,
|
||||
entries: &[T],
|
||||
cmp: impl Fn(&T, &T) -> std::cmp::Ordering,
|
||||
format_row: G,
|
||||
) -> Result<(), AppError>
|
||||
where
|
||||
G: Fn(&T, &mut fs::File) -> std::io::Result<()>,
|
||||
{
|
||||
let mut indices: Vec<usize> = (0..entries.len()).collect();
|
||||
indices.sort_by(|&a, &b| cmp(&entries[a], &entries[b]));
|
||||
|
||||
let mut file = fs::File::create(path).map_err(AppError::Io)?;
|
||||
writeln!(file, "{header}")?;
|
||||
for i in indices {
|
||||
format_row(&entries[i], &mut file)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Formats a `(BestCarRecord, usize)` row as `RQ,Brand,Model,Year,Count`.
|
||||
/// Shared by `write_best_csv`, `write_very_best_csv`, and wishlist output functions.
|
||||
/// Fields are properly CSV-escaped to handle commas or quotes in names.
|
||||
pub fn format_counted_row(entry: &(BestCarRecord, usize), file: &mut fs::File) -> std::io::Result<()> {
|
||||
let (rec, count) = entry;
|
||||
writeln!(
|
||||
file,
|
||||
"{},{},{},{},{}",
|
||||
rec.rq,
|
||||
escape_csv_field(&rec.brand),
|
||||
escape_csv_field(&rec.only_name),
|
||||
rec.year,
|
||||
count
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CSV output: best / very_best
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Writes best.csv — all cars that are best on at least one track, sorted by RQ.
|
||||
/// Format: RQ,Brand,Model,Year,Count
|
||||
pub fn write_best_csv(counts: &CarCounts) -> Result<(), AppError> {
|
||||
let entries: Vec<_> = counts.by_rid.values().collect();
|
||||
write_sorted_csv(
|
||||
"best.csv",
|
||||
"RQ,Brand,Model,Year,Count",
|
||||
&entries,
|
||||
|a, b| compare_records(&a.0, &b.0),
|
||||
|entry, file| format_counted_row(entry, file),
|
||||
)
|
||||
}
|
||||
|
||||
/// Writes very_best.csv — cars best on >= min_count tracks, sorted by RQ.
|
||||
/// Format: RQ,Brand,Model,Year,Count
|
||||
pub fn write_very_best_csv(counts: &CarCounts, min_count: usize) -> Result<(), AppError> {
|
||||
let entries: Vec<_> = counts.by_rid.values()
|
||||
.filter(|(_, count)| *count >= min_count)
|
||||
.collect();
|
||||
write_sorted_csv(
|
||||
"very_best.csv",
|
||||
"RQ,Brand,Model,Year,Count",
|
||||
&entries,
|
||||
|a, b| compare_records(&a.0, &b.0),
|
||||
|entry, file| format_counted_row(entry, file),
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CSV output: trash
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Writes trash.csv — owned cars not best on any track, RQ < 80, not prize.
|
||||
/// Format: RQ,Brand,Model,Year
|
||||
pub fn write_trash_csv(counts: &CarCounts, owned: &HashSet<String>) -> Result<(), AppError> {
|
||||
let trash: Vec<_> = owned.iter()
|
||||
.filter(|guid| !counts.guid_to_rid.contains_key(*guid))
|
||||
.filter_map(|guid| counts.car_map.get(guid))
|
||||
.filter(|car| !car.prize && car.rq < 80)
|
||||
.collect();
|
||||
|
||||
write_sorted_csv(
|
||||
"trash.csv",
|
||||
"RQ,Brand,Model,Year",
|
||||
&trash,
|
||||
|a, b| compare_cars(a, b),
|
||||
|car, file| writeln!(
|
||||
file,
|
||||
"{},{},{},{}",
|
||||
car.rq,
|
||||
escape_csv_field(&car.brand),
|
||||
escape_csv_field(&car.only_name),
|
||||
car.year
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CSV output: details
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Writes detailed timing information for all tracks, conditions, and RQ levels to `details.csv`.
|
||||
///
|
||||
/// Outputs comprehensive performance data showing the best car for every combination
|
||||
/// of track, surface condition, and RQ level. Results are sorted by RQ then track ID
|
||||
/// for consistent output ordering.
|
||||
///
|
||||
/// # Output Format
|
||||
/// ```csv
|
||||
/// RQ,TrackID,Condition,CarRQ,Brand,Model,Year,Config,Time
|
||||
/// 85,laguna,Dry,85,McLaren,F1,1993,233,112.45
|
||||
/// ```
|
||||
pub fn write_details_csv(ctx: &AnalysisContext) -> Result<(), AppError> {
|
||||
// Collect data from DashMap — no global lock needed
|
||||
let mut sorted_data: Vec<(TripletKey, Vec<BestCarRecord>)> = ctx
|
||||
.best_cars
|
||||
.iter()
|
||||
.map(|entry| (entry.key().clone(), entry.value().clone()))
|
||||
.collect();
|
||||
|
||||
// Sort by RQ then Track ID for consistent output
|
||||
sorted_data.sort_by(|a, b| a.0 .1.cmp(&b.0 .1).then(a.0 .0.cmp(&b.0 .0)));
|
||||
|
||||
let mut file = fs::File::create("details.csv")?;
|
||||
writeln!(file, "RQ,TrackID,Condition,CarRQ,Brand,Model,Year,Config,Time")?;
|
||||
for (key, records) in sorted_data {
|
||||
for best_car in records {
|
||||
writeln!(
|
||||
file,
|
||||
"{},{},{},{},{},{},{},{},{}",
|
||||
key.1, // RQ level
|
||||
escape_csv_field(&key.0), // Track ID
|
||||
escape_csv_field(&key.2), // Surface condition
|
||||
best_car.rq, // Car's RQ
|
||||
escape_csv_field(&best_car.brand), // Car brand
|
||||
escape_csv_field(&best_car.only_name), // Car model
|
||||
best_car.year, // Car year
|
||||
best_car.config, // Tune configuration
|
||||
best_car.time // Track time
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wishlist logic
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Computes the ideal wishlist: very_best unowned cars, capped at max_wishlist, sorted by RQ.
|
||||
pub fn compute_ideal_wishlist(
|
||||
counts: &CarCounts,
|
||||
owned: &HashSet<String>,
|
||||
min_count: usize,
|
||||
max_wishlist: usize,
|
||||
) -> Vec<(BestCarRecord, usize)> {
|
||||
let mut candidates: Vec<_> = counts.by_rid.values()
|
||||
.filter(|(record, count)| *count >= min_count && !owned.contains(&record.guid))
|
||||
.cloned()
|
||||
.collect();
|
||||
candidates.sort_by(|a, b| compare_records(&a.0, &b.0));
|
||||
candidates.truncate(max_wishlist);
|
||||
candidates
|
||||
}
|
||||
|
||||
/// Writes wishlist.csv, add.csv, and remove.csv from a single computed ideal wishlist.
|
||||
///
|
||||
/// Computes the ideal wishlist once and derives all three output files from it,
|
||||
/// avoiding redundant computation.
|
||||
pub fn write_wishlist_files(
|
||||
counts: &CarCounts,
|
||||
owned: &HashSet<String>,
|
||||
wishlist: &HashSet<String>,
|
||||
min_count: usize,
|
||||
max_wishlist: usize,
|
||||
) -> Result<(), AppError> {
|
||||
let ideal = compute_ideal_wishlist(counts, owned, min_count, max_wishlist);
|
||||
let ideal_guids: HashSet<_> = ideal.iter().map(|(r, _)| r.guid.clone()).collect();
|
||||
|
||||
// --- wishlist.csv ---
|
||||
write_sorted_csv(
|
||||
"wishlist.csv",
|
||||
"RQ,Brand,Model,Year,Count",
|
||||
&ideal,
|
||||
|a, b| compare_records(&a.0, &b.0),
|
||||
format_counted_row,
|
||||
)?;
|
||||
|
||||
// --- add.csv (ideal cars not yet on the wishlist) ---
|
||||
let to_add: Vec<_> = ideal.iter()
|
||||
.filter(|(record, _)| !wishlist.contains(&record.guid))
|
||||
.collect();
|
||||
|
||||
write_sorted_csv(
|
||||
"add.csv",
|
||||
"RQ,Brand,Model,Year,Count",
|
||||
&to_add,
|
||||
|a, b| compare_records(&a.0, &b.0),
|
||||
|entry, file| format_counted_row(entry, file),
|
||||
)?;
|
||||
|
||||
// --- remove.csv (wishlist cars not in the ideal set) ---
|
||||
// Single pass: collect found entries and count dropped ones together.
|
||||
let mut dropped_count = 0usize;
|
||||
let to_remove: Vec<_> = wishlist.iter()
|
||||
.filter(|guid| !ideal_guids.contains(*guid))
|
||||
.filter_map(|guid| {
|
||||
if let Some(rid) = counts.guid_to_rid.get(guid) {
|
||||
counts.by_rid.get(rid).cloned()
|
||||
} else {
|
||||
dropped_count += 1;
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if dropped_count > 0 {
|
||||
eprintln!(
|
||||
"Warning: {dropped_count} wishlist car(s) not found in best cars data and omitted from remove.csv"
|
||||
);
|
||||
}
|
||||
|
||||
write_sorted_csv(
|
||||
"remove.csv",
|
||||
"RQ,Brand,Model,Year,Count",
|
||||
&to_remove,
|
||||
|a, b| compare_records(&a.0, &b.0),
|
||||
format_counted_row,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stdout output: track-specific query
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Finds and displays the best performing car for a specific track, condition, and RQ level
|
||||
///
|
||||
/// Provides targeted recommendations for specific racing scenarios. Useful for determining
|
||||
/// the optimal car choice when you need to win a particular race or meet specific requirements.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `ctx` - Analysis context containing the best cars data
|
||||
/// * `track` - The track identifier (e.g., "laguna", "monaco")
|
||||
/// * `condition` - Surface condition (e.g., "Dry", "Wet", "Dirt")
|
||||
/// * `rq` - RQ level limit (cars with this RQ or lower will be considered)
|
||||
pub fn print_best_car(ctx: &AnalysisContext, track: &str, condition: &str, rq: u8) -> Result<(), AppError> {
|
||||
let key = (track.to_string(), rq, condition.to_string());
|
||||
|
||||
if let Some(best_list) = ctx.best_cars.get(&key) {
|
||||
for best_car in best_list.value() {
|
||||
println!(
|
||||
"Best car for Track: {}, Condition: {}, RQ: {} -> {} {} ({}) with time {:.2}",
|
||||
track,
|
||||
condition,
|
||||
rq,
|
||||
best_car.brand,
|
||||
best_car.only_name,
|
||||
best_car.year,
|
||||
best_car.time
|
||||
);
|
||||
}
|
||||
} else {
|
||||
println!(
|
||||
"No best car found for Track: {}, Condition: {}, RQ: {}",
|
||||
track, condition, rq
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
use top_drives::{Car, compute_car_counts};
|
||||
|
||||
// -- Helper: create a test BestCarRecord --
|
||||
fn make_record(car_rid: &str, rq: u8, time: f64, guid: &str) -> BestCarRecord {
|
||||
BestCarRecord {
|
||||
car_rid: car_rid.to_string(),
|
||||
brand: "TestBrand".to_string(),
|
||||
only_name: "TestModel".to_string(),
|
||||
year: 2020,
|
||||
rq,
|
||||
config: "111".to_string(),
|
||||
time,
|
||||
owned: false,
|
||||
prize: false,
|
||||
guid: guid.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// -- Helper: create a test Car --
|
||||
fn make_car(rid: &str, rq: u8, guid: &str) -> Car {
|
||||
Car {
|
||||
rid: rid.to_string(),
|
||||
brand: "TestBrand".to_string(),
|
||||
only_name: "TestModel".to_string(),
|
||||
year: 2020,
|
||||
rq,
|
||||
prize: false,
|
||||
guid: guid.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// compute_ideal_wishlist tests
|
||||
// =======================================================================
|
||||
|
||||
#[test]
|
||||
fn test_compute_ideal_wishlist_basic() {
|
||||
let ctx = AnalysisContext::new();
|
||||
let rec = make_record("Car_A", 85, 100.0, "guid_a");
|
||||
|
||||
{
|
||||
for i in 0..15 {
|
||||
ctx.best_cars.insert(
|
||||
(format!("Track{i}"), 85, "Dry".to_string()),
|
||||
vec![rec.clone()],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let car_list = vec![make_car("Car_A", 85, "guid_a")];
|
||||
let counts = compute_car_counts(&ctx, car_list).unwrap();
|
||||
|
||||
let owned: HashSet<String> = HashSet::new();
|
||||
let wishlist = compute_ideal_wishlist(&counts, &owned, 10, 300);
|
||||
|
||||
assert_eq!(wishlist.len(), 1);
|
||||
assert_eq!(wishlist[0].0.car_rid, "Car_A");
|
||||
assert!(wishlist[0].1 >= 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_ideal_wishlist_excludes_owned() {
|
||||
let ctx = AnalysisContext::new();
|
||||
let rec = make_record("Car_A", 85, 100.0, "guid_a");
|
||||
|
||||
{
|
||||
for i in 0..15 {
|
||||
ctx.best_cars.insert(
|
||||
(format!("Track{i}"), 85, "Dry".to_string()),
|
||||
vec![rec.clone()],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let car_list = vec![make_car("Car_A", 85, "guid_a")];
|
||||
let counts = compute_car_counts(&ctx, car_list).unwrap();
|
||||
|
||||
let owned: HashSet<String> = vec!["guid_a".to_string()].into_iter().collect();
|
||||
let wishlist = compute_ideal_wishlist(&counts, &owned, 10, 300);
|
||||
|
||||
assert_eq!(wishlist.len(), 0, "Owned cars should be excluded from wishlist");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_ideal_wishlist_respects_max() {
|
||||
let ctx = AnalysisContext::new();
|
||||
|
||||
{
|
||||
for i in 0..15 {
|
||||
for j in 0..5u8 {
|
||||
let rec = make_record(
|
||||
&format!("Car_{j}"),
|
||||
80 + j,
|
||||
100.0 + j as f64,
|
||||
&format!("guid_{j}"),
|
||||
);
|
||||
ctx.best_cars.insert(
|
||||
(format!("Track{i}"), 80 + j, "Dry".to_string()),
|
||||
vec![rec],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let car_list: Vec<Car> = (0..5u8)
|
||||
.map(|j| make_car(&format!("Car_{j}"), 80 + j, &format!("guid_{j}")))
|
||||
.collect();
|
||||
let counts = compute_car_counts(&ctx, car_list).unwrap();
|
||||
|
||||
let owned: HashSet<String> = HashSet::new();
|
||||
let wishlist = compute_ideal_wishlist(&counts, &owned, 10, 3);
|
||||
|
||||
assert!(wishlist.len() <= 3, "Wishlist should be capped at max_wishlist");
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// CSV output tests
|
||||
// =======================================================================
|
||||
|
||||
#[test]
|
||||
fn test_write_sorted_csv_basic() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("test.csv");
|
||||
let path_str = path.to_str().unwrap();
|
||||
|
||||
let entries = vec![("beta", 2), ("alpha", 1), ("gamma", 3)];
|
||||
|
||||
write_sorted_csv(
|
||||
path_str,
|
||||
"Name,Value",
|
||||
&entries,
|
||||
|a, b| a.0.cmp(b.0),
|
||||
|entry, file| writeln!(file, "{},{}", entry.0, entry.1),
|
||||
).unwrap();
|
||||
|
||||
let content = fs::read_to_string(path).unwrap();
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
assert_eq!(lines[0], "Name,Value");
|
||||
assert_eq!(lines[1], "alpha,1");
|
||||
assert_eq!(lines[2], "beta,2");
|
||||
assert_eq!(lines[3], "gamma,3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_counted_row_escaping() {
|
||||
let rec = BestCarRecord {
|
||||
car_rid: "Test".to_string(),
|
||||
brand: "Brand, Inc.".to_string(),
|
||||
only_name: "Model \"Sport\"".to_string(),
|
||||
year: 2020,
|
||||
rq: 85,
|
||||
config: "111".to_string(),
|
||||
time: 100.0,
|
||||
owned: false,
|
||||
prize: false,
|
||||
guid: "guid".to_string(),
|
||||
};
|
||||
let entry = (rec, 5);
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("test.csv");
|
||||
let path_str = path.to_str().unwrap();
|
||||
|
||||
write_sorted_csv(
|
||||
path_str,
|
||||
"RQ,Brand,Model,Year,Count",
|
||||
&[entry],
|
||||
|_, _| std::cmp::Ordering::Equal,
|
||||
|e, file| format_counted_row(e, file),
|
||||
).unwrap();
|
||||
|
||||
let content = fs::read_to_string(path).unwrap();
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
assert_eq!(lines[1], "85,\"Brand, Inc.\",\"Model \"\"Sport\"\"\",2020,5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_trash_csv_filters_correctly() {
|
||||
let ctx = AnalysisContext::new();
|
||||
|
||||
// Car_A is best on a track, Car_B is not
|
||||
let rec_a = make_record("Car_A", 70, 100.0, "guid_a");
|
||||
{
|
||||
ctx.best_cars.insert(
|
||||
("Track1".to_string(), 70, "Dry".to_string()),
|
||||
vec![rec_a],
|
||||
);
|
||||
}
|
||||
|
||||
// Make Car_D a prize car
|
||||
let mut car_d = make_car("Car_D", 50, "guid_d");
|
||||
car_d.prize = true;
|
||||
|
||||
let car_list = vec![
|
||||
make_car("Car_A", 70, "guid_a"), // best on a track — not trash
|
||||
make_car("Car_B", 60, "guid_b"), // not best, RQ < 80 — trash
|
||||
make_car("Car_C", 90, "guid_c"), // not best, but RQ >= 80 — not trash
|
||||
car_d, // not best, prize car — not trash
|
||||
];
|
||||
|
||||
let counts = compute_car_counts(&ctx, car_list).unwrap();
|
||||
|
||||
let owned: HashSet<String> = vec![
|
||||
"guid_a".to_string(),
|
||||
"guid_b".to_string(),
|
||||
"guid_c".to_string(),
|
||||
"guid_d".to_string(),
|
||||
].into_iter().collect();
|
||||
|
||||
// Test the filtering logic directly (same logic as write_trash_csv)
|
||||
let trash: Vec<_> = owned.iter()
|
||||
.filter(|guid| !counts.guid_to_rid.contains_key(*guid))
|
||||
.filter_map(|guid| counts.car_map.get(guid))
|
||||
.filter(|car| !car.prize && car.rq < 80)
|
||||
.collect();
|
||||
|
||||
assert_eq!(trash.len(), 1, "Only Car_B should be trash");
|
||||
assert_eq!(trash[0].rid, "Car_B");
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// compare_records tests
|
||||
// =======================================================================
|
||||
|
||||
#[test]
|
||||
fn test_compare_records_by_rq() {
|
||||
let a = make_record("A", 80, 100.0, "guid_a");
|
||||
let b = make_record("B", 90, 100.0, "guid_b");
|
||||
assert_eq!(compare_records(&a, &b), std::cmp::Ordering::Less);
|
||||
assert_eq!(compare_records(&b, &a), std::cmp::Ordering::Greater);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compare_records_by_brand_then_model() {
|
||||
let a = BestCarRecord {
|
||||
car_rid: "A".to_string(),
|
||||
brand: "Audi".to_string(),
|
||||
only_name: "RS6".to_string(),
|
||||
year: 2020,
|
||||
rq: 85,
|
||||
config: "111".to_string(),
|
||||
time: 100.0,
|
||||
owned: false,
|
||||
prize: false,
|
||||
guid: "guid_a".to_string(),
|
||||
};
|
||||
let b = BestCarRecord {
|
||||
car_rid: "B".to_string(),
|
||||
brand: "BMW".to_string(),
|
||||
only_name: "M3".to_string(),
|
||||
year: 2020,
|
||||
rq: 85,
|
||||
config: "111".to_string(),
|
||||
time: 100.0,
|
||||
owned: false,
|
||||
prize: false,
|
||||
guid: "guid_b".to_string(),
|
||||
};
|
||||
assert_eq!(compare_records(&a, &b), std::cmp::Ordering::Less);
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Edge case tests
|
||||
// =======================================================================
|
||||
|
||||
#[test]
|
||||
fn test_empty_analysis_context() {
|
||||
let ctx = AnalysisContext::new();
|
||||
let counts = compute_car_counts(&ctx, vec![]).unwrap();
|
||||
assert!(counts.by_rid.is_empty());
|
||||
assert!(counts.guid_to_rid.is_empty());
|
||||
assert!(counts.car_map.is_empty());
|
||||
assert!(counts.car_list.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_car_analysis() {
|
||||
let ctx = AnalysisContext::new();
|
||||
let rec = make_record("Only_Car", 90, 88.5, "guid_only");
|
||||
|
||||
ctx.best_cars.insert(
|
||||
("Track1".to_string(), 90, "Dry".to_string()),
|
||||
vec![rec],
|
||||
);
|
||||
|
||||
let car = make_car("Only_Car", 90, "guid_only");
|
||||
let counts = compute_car_counts(&ctx, vec![car]).unwrap();
|
||||
|
||||
assert_eq!(counts.by_rid.len(), 1);
|
||||
let (rec, count) = counts.by_rid.get("Only_Car").unwrap();
|
||||
assert_eq!(*count, 1);
|
||||
assert_eq!(rec.car_rid, "Only_Car");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_cars_owned() {
|
||||
let ctx = AnalysisContext::new();
|
||||
let rec = make_record("Car_A", 85, 100.0, "guid_a");
|
||||
|
||||
for i in 0..15 {
|
||||
ctx.best_cars.insert(
|
||||
(format!("Track{i}"), 85, "Dry".to_string()),
|
||||
vec![rec.clone()],
|
||||
);
|
||||
}
|
||||
|
||||
let car = make_car("Car_A", 85, "guid_a");
|
||||
let counts = compute_car_counts(&ctx, vec![car]).unwrap();
|
||||
|
||||
let all_owned: HashSet<String> = vec!["guid_a".to_string()].into_iter().collect();
|
||||
let wishlist = compute_ideal_wishlist(&counts, &all_owned, 10, 300);
|
||||
assert_eq!(wishlist.len(), 0, "All owned → empty wishlist");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_cars_owned() {
|
||||
let ctx = AnalysisContext::new();
|
||||
let rec = make_record("Car_A", 85, 100.0, "guid_a");
|
||||
|
||||
for i in 0..15 {
|
||||
ctx.best_cars.insert(
|
||||
(format!("Track{i}"), 85, "Dry".to_string()),
|
||||
vec![rec.clone()],
|
||||
);
|
||||
}
|
||||
|
||||
let car = make_car("Car_A", 85, "guid_a");
|
||||
let counts = compute_car_counts(&ctx, vec![car]).unwrap();
|
||||
|
||||
let no_owned: HashSet<String> = HashSet::new();
|
||||
let wishlist = compute_ideal_wishlist(&counts, &no_owned, 10, 300);
|
||||
assert_eq!(wishlist.len(), 1, "No owned → wishlist includes the car");
|
||||
}
|
||||
}
|
||||
47
src/parsing/car_list.rs
Normal file
47
src/parsing/car_list.rs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
//! Car database loading and parsing.
|
||||
//!
|
||||
//! Handles reading the car database JSON file with serde deserialization.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use top_drives::{AppError, Car};
|
||||
|
||||
/// Loads the complete car database from the local repository file.
|
||||
///
|
||||
/// Returns a `Vec<Car>` containing all cars from the database.
|
||||
/// Returns an `AppError` if the file is missing or cannot be parsed.
|
||||
pub fn get_car_list(cars_db_path: &str) -> Result<Vec<Car>, AppError> {
|
||||
let content = fs::read_to_string(cars_db_path).map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
AppError::MissingFile(format!("{cars_db_path}: {e}"))
|
||||
} else {
|
||||
AppError::Io(e)
|
||||
}
|
||||
})?;
|
||||
|
||||
serde_json::from_str(&content).map_err(|e| AppError::Parse {
|
||||
file: cars_db_path.to_string(),
|
||||
reason: e.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_get_car_list_missing_file() {
|
||||
let result = get_car_list("nonexistent_cars_db.json");
|
||||
assert!(result.is_err(), "Should return error for missing file");
|
||||
match result.unwrap_err() {
|
||||
AppError::MissingFile(msg) => {
|
||||
assert!(msg.contains("nonexistent_cars_db.json"), "Error should mention the file path");
|
||||
}
|
||||
other => panic!("Expected MissingFile error, got: {other}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
652
src/parsing/mod.rs
Normal file
652
src/parsing/mod.rs
Normal file
|
|
@ -0,0 +1,652 @@
|
|||
//! Core parsing logic for car timing data.
|
||||
//!
|
||||
//! Handles per-car JSON timing file parsing, best-car record updates,
|
||||
//! and orchestrated concurrent parsing of all timing files.
|
||||
|
||||
pub mod car_list;
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::sync::Arc;
|
||||
|
||||
use dashmap::DashMap;
|
||||
use futures::future::join_all;
|
||||
use serde_json::Value;
|
||||
use tokio::sync::Semaphore;
|
||||
use tokio::task;
|
||||
|
||||
use top_drives::{
|
||||
AppError, AnalysisContext, BestCarRecord, Car, TripletKey,
|
||||
translate_track_name, translate_track_type,
|
||||
};
|
||||
|
||||
use self::car_list::get_car_list;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants (shared across parsing modules)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Maximum RQ level in the Top Drives database.
|
||||
/// Used as the upper bound when iterating RQ ranges and as a fallback for parse errors.
|
||||
pub const MAX_RQ: u8 = 113;
|
||||
|
||||
/// Minimum valid track time in seconds. Times below this threshold are discarded.
|
||||
pub const MIN_VALID_TIME: f64 = 1.2;
|
||||
|
||||
/// Tolerance for considering two times as equal (5ms).
|
||||
pub const TIME_TOLERANCE: f64 = 0.005;
|
||||
|
||||
/// Multiplier constant for Test Bowl time inversion.
|
||||
/// Test Bowl tracks use speed rather than time, so we invert: `FACTOR / time`.
|
||||
pub const TEST_BOWL_FACTOR: f64 = 2000.0;
|
||||
|
||||
/// Maximum number of concurrent download/parse tasks.
|
||||
///
|
||||
/// IMPORTANT: Keep ≤ 50 to avoid exhausting tokio's default blocking thread pool (512 threads),
|
||||
/// since each concurrent permit holder also occupies a `spawn_blocking` thread.
|
||||
pub const CONCURRENCY_LIMIT: usize = 20;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core parsing: per-car timing data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parses timing data for a single car from its JSON file.
|
||||
///
|
||||
/// **Blocking** — performs synchronous file I/O and CPU-intensive JSON parsing.
|
||||
/// Must be called from [`tokio::task::spawn_blocking`], never directly from an async context,
|
||||
/// as it would block the tokio runtime thread.
|
||||
///
|
||||
/// This is the core function responsible for processing individual car performance data.
|
||||
/// It reads the car's JSON file, extracts timing information for each track and condition,
|
||||
/// filters out invalid or downvoted times, and updates the analysis context.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `car` - The car data structure containing metadata about the car to process
|
||||
/// * `my_cars` - Set of GUIDs the user owns (used to mark ownership)
|
||||
/// * `best_cars` - Shared concurrent map of best car records (`DashMap`)
|
||||
/// * `times_dir` - Directory containing per-car timing JSON files
|
||||
pub fn parse_car_times(
|
||||
car: Car,
|
||||
my_cars: &HashSet<String>,
|
||||
best_cars: &DashMap<TripletKey, Vec<BestCarRecord>>,
|
||||
times_dir: &str,
|
||||
) -> Result<(), AppError> {
|
||||
// Construct file path for this car's timing data
|
||||
let file_name = car.rid.to_owned() + ".json";
|
||||
let local_file_path = format!("{times_dir}/{file_name}");
|
||||
|
||||
// Read file content
|
||||
let file_content = match fs::read_to_string(&local_file_path) {
|
||||
Ok(content) => content,
|
||||
Err(e) => {
|
||||
// File not found is expected for some cars — treat as non-fatal
|
||||
eprintln!("Warning: could not read {local_file_path}: {e}");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
// Parse JSON data
|
||||
let json_data: Value = match serde_json::from_str(&file_content) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
return Err(AppError::Parse {
|
||||
file: local_file_path,
|
||||
reason: e.to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Process each configuration variant (111, 233, 323, etc.)
|
||||
if let Some(configurations) = json_data["data"].as_object() {
|
||||
for (config, entry) in configurations {
|
||||
// Process timing data for each track
|
||||
if let Some(times) = entry["times"].as_object() {
|
||||
for (track_id, details) in times {
|
||||
// Parse track ID to separate base track name from surface condition.
|
||||
// Use rsplit_once to split on the LAST "_a" so track names that
|
||||
// contain "_a" (e.g. "airplaneHangars") are handled correctly.
|
||||
if let Some((cleaned_track_id, track_type)) = track_id.rsplit_once("_a") {
|
||||
let mut time = details["t"].as_f64().unwrap_or(0.0);
|
||||
|
||||
let down_votes = details["down"].as_array();
|
||||
let no_down_votes = match down_votes {
|
||||
None => true,
|
||||
Some(votes) => votes.is_empty(),
|
||||
};
|
||||
|
||||
if track_id.starts_with("testBowl") {
|
||||
if time > 0.0 {
|
||||
time = TEST_BOWL_FACTOR / time;
|
||||
} else {
|
||||
continue; // skip invalid zero time
|
||||
}
|
||||
}
|
||||
|
||||
let owned = my_cars.contains(&car.guid);
|
||||
|
||||
if time > MIN_VALID_TIME && no_down_votes {
|
||||
let track_name = translate_track_name(cleaned_track_id);
|
||||
let surface = translate_track_type(track_type);
|
||||
|
||||
update_best_car(
|
||||
best_cars,
|
||||
&track_name,
|
||||
car.rq,
|
||||
surface,
|
||||
BestCarRecord {
|
||||
car_rid: car.rid.clone(),
|
||||
brand: car.brand.clone(),
|
||||
only_name: car.only_name.clone(),
|
||||
year: car.year,
|
||||
rq: car.rq,
|
||||
config: config.clone(),
|
||||
time,
|
||||
owned,
|
||||
prize: car.prize,
|
||||
guid: car.guid.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core parsing: update best car records
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Updates the best cars database with a new performance record.
|
||||
///
|
||||
/// Implements the core logic for determining the best performing cars across different
|
||||
/// RQ ranges. For each RQ level from the car's base RQ up to MAX_RQ, this
|
||||
/// function determines whether the new record represents an improvement or tie.
|
||||
///
|
||||
/// # Allocation Optimization
|
||||
///
|
||||
/// The record is cloned at most once (for the first insertion), and subsequent
|
||||
/// RQ levels reuse the entry via `DashMap`'s `entry()` API, avoiding per-RQ
|
||||
/// clone overhead for the common "insert new" case.
|
||||
///
|
||||
/// String keys (`track_id`, `surface`) are pre-allocated once and cloned per
|
||||
/// iteration, avoiding repeated `to_string()` formatting overhead.
|
||||
pub fn update_best_car(
|
||||
best_cars: &DashMap<TripletKey, Vec<BestCarRecord>>,
|
||||
track_id: &str,
|
||||
car_rq: u8,
|
||||
surface: &str,
|
||||
new_record: BestCarRecord,
|
||||
) {
|
||||
let track_id_owned = track_id.to_string();
|
||||
let surface_owned = surface.to_string();
|
||||
|
||||
for rq in car_rq..=MAX_RQ {
|
||||
let is_last = rq == MAX_RQ;
|
||||
let key = (track_id_owned.clone(), rq, surface_owned.clone());
|
||||
|
||||
match best_cars.entry(key) {
|
||||
dashmap::mapref::entry::Entry::Occupied(mut entry) => {
|
||||
let existing_list = entry.get_mut();
|
||||
let best_time = existing_list.first().map(|r| r.time).unwrap_or(f64::MAX);
|
||||
|
||||
if new_record.time < best_time {
|
||||
if is_last {
|
||||
*existing_list = vec![new_record];
|
||||
return;
|
||||
} else {
|
||||
*existing_list = vec![new_record.clone()];
|
||||
}
|
||||
} else if (new_record.time - best_time).abs() < TIME_TOLERANCE
|
||||
&& !existing_list
|
||||
.iter()
|
||||
.any(|r| r.car_rid == new_record.car_rid)
|
||||
{
|
||||
if is_last {
|
||||
existing_list.push(new_record);
|
||||
return;
|
||||
} else {
|
||||
existing_list.push(new_record.clone());
|
||||
}
|
||||
}
|
||||
// else: record is discarded for this RQ — no clone needed
|
||||
}
|
||||
dashmap::mapref::entry::Entry::Vacant(entry) => {
|
||||
if is_last {
|
||||
entry.insert(vec![new_record]);
|
||||
return;
|
||||
} else {
|
||||
entry.insert(vec![new_record.clone()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Orchestrated parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Orchestrates concurrent parsing of all car timing files.
|
||||
///
|
||||
/// Spawns a separate async task for each car in the database. The semaphore is
|
||||
/// acquired in the async context (avoiding `block_on` inside `spawn_blocking`,
|
||||
/// which risks deadlocking the blocking thread pool), then the synchronous
|
||||
/// parsing work runs inside `spawn_blocking` while the permit is held.
|
||||
///
|
||||
/// Takes `Arc<HashSet<String>>` to avoid cloning the entire owned-car set on
|
||||
/// every call.
|
||||
pub async fn parse_all_times(
|
||||
my_cars: Arc<HashSet<String>>,
|
||||
ctx: &AnalysisContext,
|
||||
cars_db_path: &str,
|
||||
times_dir: &str,
|
||||
) -> Result<Vec<Car>, AppError> {
|
||||
let car_list = get_car_list(cars_db_path)?;
|
||||
let mut handles = vec![];
|
||||
|
||||
let semaphore = Arc::new(Semaphore::new(CONCURRENCY_LIMIT));
|
||||
let best_cars = Arc::clone(&ctx.best_cars);
|
||||
|
||||
for car in car_list.iter() {
|
||||
let my_cars = Arc::clone(&my_cars);
|
||||
let best_cars = Arc::clone(&best_cars);
|
||||
let car = car.clone();
|
||||
let sem = Arc::clone(&semaphore);
|
||||
let times_dir = times_dir.to_string();
|
||||
let handle = task::spawn(async move {
|
||||
// Acquire the semaphore asynchronously — propagate error if closed.
|
||||
let _permit = match sem.acquire().await {
|
||||
Ok(p) => p,
|
||||
Err(_) => return Err(AppError::Internal("Semaphore closed".into())),
|
||||
};
|
||||
// Run the synchronous parsing on a blocking thread while holding the permit.
|
||||
task::spawn_blocking(move || {
|
||||
parse_car_times(car, &my_cars, &best_cars, ×_dir)
|
||||
}).await.unwrap_or_else(|e| Err(AppError::Internal(format!("Blocking task join error: {e}"))))
|
||||
});
|
||||
handles.push(handle);
|
||||
}
|
||||
|
||||
let results = join_all(handles).await;
|
||||
let mut parse_errors = 0usize;
|
||||
for result in results {
|
||||
match result {
|
||||
// Outer JoinError from tokio::spawn
|
||||
Err(e) => {
|
||||
eprintln!("Warning: parsing task failed: {e}");
|
||||
parse_errors += 1;
|
||||
}
|
||||
// parse_car_times or semaphore returned an error
|
||||
Ok(Err(e)) => {
|
||||
eprintln!("Warning: {e}");
|
||||
parse_errors += 1;
|
||||
}
|
||||
// parse_car_times succeeded
|
||||
Ok(Ok(())) => {}
|
||||
}
|
||||
}
|
||||
if parse_errors > 0 {
|
||||
eprintln!("Warning: {parse_errors} car(s) had parse errors during analysis");
|
||||
}
|
||||
|
||||
// If every car failed to parse, the data is unusable — propagate the error.
|
||||
if parse_errors == car_list.len() && !car_list.is_empty() {
|
||||
return Err(AppError::Parse {
|
||||
file: times_dir.to_string(),
|
||||
reason: format!("All {parse_errors} car(s) failed to parse — data directory may be corrupted"),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(car_list)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data fetching
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Remote URL for the per-car timing API.
|
||||
const CAR_TIMING_API_BASE: &str = "https://api.topdrivesrecords.com/car/";
|
||||
|
||||
/// Fetches timing data files for all cars using controlled concurrency.
|
||||
///
|
||||
/// Returns the first error encountered, or `Ok(())` if all downloads succeeded.
|
||||
pub async fn fetch_times(cars_db_path: &str, times_dir: &str) -> Result<(), AppError> {
|
||||
let semaphore = Arc::new(Semaphore::new(CONCURRENCY_LIMIT));
|
||||
let mut handles = vec![];
|
||||
|
||||
for car in get_car_list(cars_db_path)? {
|
||||
let permit = semaphore.clone().acquire_owned().await
|
||||
.map_err(|e| AppError::Network(format!("Semaphore closed: {e}")))?;
|
||||
let times_dir = times_dir.to_string();
|
||||
handles.push(tokio::spawn(async move {
|
||||
let _permit = permit; // held until end of block
|
||||
fetch_car(&car.rid, ×_dir).await
|
||||
}));
|
||||
}
|
||||
|
||||
let results = join_all(handles).await;
|
||||
let mut first_error: Option<AppError> = None;
|
||||
for result in results {
|
||||
match result {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => {
|
||||
eprintln!("{e}");
|
||||
if first_error.is_none() { first_error = Some(e); }
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = AppError::Network(format!("Task join error: {e}"));
|
||||
eprintln!("{msg}");
|
||||
if first_error.is_none() { first_error = Some(msg); }
|
||||
}
|
||||
}
|
||||
}
|
||||
first_error.map_or(Ok(()), Err)
|
||||
}
|
||||
|
||||
/// Downloads timing data for a single car from the remote API.
|
||||
async fn fetch_car(rid: &str, times_dir: &str) -> Result<(), AppError> {
|
||||
let file_name = rid.to_owned() + ".json";
|
||||
let url = format!("{CAR_TIMING_API_BASE}{rid}");
|
||||
let local_file_path = format!("{times_dir}/{file_name}");
|
||||
println!("Fetching {url}.");
|
||||
top_drives::get_file(&url, &local_file_path).await
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
|
||||
// -- Helper: create a test BestCarRecord --
|
||||
fn make_record(car_rid: &str, rq: u8, time: f64, guid: &str) -> BestCarRecord {
|
||||
BestCarRecord {
|
||||
car_rid: car_rid.to_string(),
|
||||
brand: "TestBrand".to_string(),
|
||||
only_name: "TestModel".to_string(),
|
||||
year: 2020,
|
||||
rq,
|
||||
config: "111".to_string(),
|
||||
time,
|
||||
owned: false,
|
||||
prize: false,
|
||||
guid: guid.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// update_best_car tests
|
||||
// =======================================================================
|
||||
|
||||
#[test]
|
||||
fn test_update_best_car_inserts_new_record() {
|
||||
let ctx = AnalysisContext::new();
|
||||
let record = make_record("Car_A", 85, 100.0, "guid_a");
|
||||
|
||||
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record);
|
||||
|
||||
let key = ("laguna".to_string(), 85, "Dry".to_string());
|
||||
assert!(ctx.best_cars.contains_key(&key));
|
||||
let records = ctx.best_cars.get(&key).unwrap();
|
||||
assert_eq!(records.len(), 1);
|
||||
assert_eq!(records[0].car_rid, "Car_A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_best_car_replaces_slower_record() {
|
||||
let ctx = AnalysisContext::new();
|
||||
|
||||
let slow = make_record("Car_A", 85, 110.0, "guid_a");
|
||||
let fast = make_record("Car_B", 85, 95.0, "guid_b");
|
||||
|
||||
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", slow);
|
||||
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", fast);
|
||||
|
||||
let records = ctx.best_cars.get(&("laguna".to_string(), 85, "Dry".to_string())).unwrap();
|
||||
assert_eq!(records.len(), 1);
|
||||
assert_eq!(records[0].car_rid, "Car_B"); // faster car wins
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_best_car_tie_within_tolerance() {
|
||||
let ctx = AnalysisContext::new();
|
||||
|
||||
let record1 = make_record("Car_A", 85, 100.0, "guid_a");
|
||||
let record2 = make_record("Car_B", 85, 100.003, "guid_b"); // within 0.005
|
||||
|
||||
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record1);
|
||||
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record2);
|
||||
|
||||
let records = ctx.best_cars.get(&("laguna".to_string(), 85, "Dry".to_string())).unwrap();
|
||||
assert_eq!(records.len(), 2, "Tied records should both be kept");
|
||||
assert!(records.iter().any(|r| r.car_rid == "Car_A"));
|
||||
assert!(records.iter().any(|r| r.car_rid == "Car_B"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_best_car_outside_tolerance_replaces() {
|
||||
let ctx = AnalysisContext::new();
|
||||
|
||||
let record1 = make_record("Car_A", 85, 100.0, "guid_a");
|
||||
let record3 = make_record("Car_C", 85, 100.006, "guid_c"); // outside 0.005
|
||||
|
||||
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record1);
|
||||
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record3);
|
||||
|
||||
let records = ctx.best_cars.get(&("laguna".to_string(), 85, "Dry".to_string())).unwrap();
|
||||
assert_eq!(records.len(), 1, "Record outside tolerance should not tie");
|
||||
assert_eq!(records[0].car_rid, "Car_A"); // original kept (same best time)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_best_car_rq_range_propagation() {
|
||||
let ctx = AnalysisContext::new();
|
||||
let record = make_record("Car_A", 85, 100.0, "guid_a");
|
||||
|
||||
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record);
|
||||
|
||||
for rq in 85..=MAX_RQ {
|
||||
let key = ("laguna".to_string(), rq, "Dry".to_string());
|
||||
assert!(
|
||||
ctx.best_cars.contains_key(&key),
|
||||
"Car should appear at RQ level {rq}"
|
||||
);
|
||||
}
|
||||
// Should NOT appear below its RQ
|
||||
let key_below = ("laguna".to_string(), 84, "Dry".to_string());
|
||||
assert!(!ctx.best_cars.contains_key(&key_below));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_best_car_duplicate_rid_not_added_in_tie() {
|
||||
let ctx = AnalysisContext::new();
|
||||
|
||||
let record1 = make_record("Car_A", 85, 100.0, "guid_a");
|
||||
let record2 = make_record("Car_A", 85, 100.001, "guid_a"); // same car, within tolerance
|
||||
|
||||
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record1);
|
||||
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record2);
|
||||
|
||||
let records = ctx.best_cars.get(&("laguna".to_string(), 85, "Dry".to_string())).unwrap();
|
||||
assert_eq!(records.len(), 1, "Same car_rid should not appear twice in tie list");
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// parse_car_times tests
|
||||
// =======================================================================
|
||||
|
||||
#[test]
|
||||
fn test_parse_car_times_valid_json() {
|
||||
let ctx = AnalysisContext::new();
|
||||
let car = Car {
|
||||
rid: "TestCar_2020".to_string(),
|
||||
brand: "TestBrand".to_string(),
|
||||
only_name: "TestModel".to_string(),
|
||||
year: 2020,
|
||||
rq: 85,
|
||||
prize: false,
|
||||
guid: "guid_test".to_string(),
|
||||
};
|
||||
|
||||
// Create a temporary directory and timing file
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let times_dir = dir.path().to_str().unwrap();
|
||||
let file_path = dir.path().join("TestCar_2020.json");
|
||||
|
||||
let json_content = r#"{
|
||||
"data": {
|
||||
"111": {
|
||||
"times": {
|
||||
"laguna_a00": { "t": 112.45, "u": "Player1" },
|
||||
"mnCity_a01": { "t": 45.23, "u": "Player2" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
fs::write(&file_path, json_content).unwrap();
|
||||
|
||||
let my_cars: HashSet<String> = HashSet::new();
|
||||
parse_car_times(car, &my_cars, &ctx.best_cars, times_dir).unwrap();
|
||||
|
||||
// Should have entries for RQ 85..=113 for both tracks
|
||||
assert!(ctx.best_cars.contains_key(&("Laguna Seca".to_string(), 85, "Dry".to_string())));
|
||||
assert!(ctx.best_cars.contains_key(&("Monaco Streets".to_string(), 85, "Wet".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_car_times_filters_downvoted() {
|
||||
let ctx = AnalysisContext::new();
|
||||
let car = Car {
|
||||
rid: "TestCar_2020".to_string(),
|
||||
brand: "TestBrand".to_string(),
|
||||
only_name: "TestModel".to_string(),
|
||||
year: 2020,
|
||||
rq: 85,
|
||||
prize: false,
|
||||
guid: "guid_test".to_string(),
|
||||
};
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let times_dir = dir.path().to_str().unwrap();
|
||||
let file_path = dir.path().join("TestCar_2020.json");
|
||||
|
||||
let json_content = r#"{
|
||||
"data": {
|
||||
"111": {
|
||||
"times": {
|
||||
"laguna_a00": { "t": 112.45, "u": "Player1", "down": ["Player3"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
fs::write(&file_path, json_content).unwrap();
|
||||
|
||||
let my_cars: HashSet<String> = HashSet::new();
|
||||
parse_car_times(car, &my_cars, &ctx.best_cars, times_dir).unwrap();
|
||||
|
||||
// Downvoted time should be filtered out
|
||||
assert!(
|
||||
!ctx.best_cars.contains_key(&("Laguna Seca".to_string(), 85, "Dry".to_string())),
|
||||
"Downvoted times should be excluded"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_car_times_test_bowl_inversion() {
|
||||
let ctx = AnalysisContext::new();
|
||||
let car = Car {
|
||||
rid: "TestCar_2020".to_string(),
|
||||
brand: "TestBrand".to_string(),
|
||||
only_name: "TestModel".to_string(),
|
||||
year: 2020,
|
||||
rq: 85,
|
||||
prize: false,
|
||||
guid: "guid_test".to_string(),
|
||||
};
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let times_dir = dir.path().to_str().unwrap();
|
||||
let file_path = dir.path().join("TestCar_2020.json");
|
||||
|
||||
let json_content = r#"{
|
||||
"data": {
|
||||
"111": {
|
||||
"times": {
|
||||
"testBowl_a00": { "t": 50.0, "u": "Player1" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
fs::write(&file_path, json_content).unwrap();
|
||||
|
||||
let my_cars: HashSet<String> = HashSet::new();
|
||||
parse_car_times(car, &my_cars, &ctx.best_cars, times_dir).unwrap();
|
||||
|
||||
// Test Bowl time should be inverted: 2000.0 / 50.0 = 40.0
|
||||
let records = ctx
|
||||
.best_cars
|
||||
.get(&("Test Bowl".to_string(), 85, "Dry".to_string()))
|
||||
.unwrap();
|
||||
assert_eq!(records[0].time, 40.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_car_times_missing_file() {
|
||||
let ctx = AnalysisContext::new();
|
||||
let car = Car {
|
||||
rid: "NonExistent".to_string(),
|
||||
brand: "TestBrand".to_string(),
|
||||
only_name: "TestModel".to_string(),
|
||||
year: 2020,
|
||||
rq: 85,
|
||||
prize: false,
|
||||
guid: "guid_test".to_string(),
|
||||
};
|
||||
|
||||
let my_cars: HashSet<String> = HashSet::new();
|
||||
// Should not panic — just print a warning and return Ok(())
|
||||
parse_car_times(car, &my_cars, &ctx.best_cars, "/nonexistent/times").unwrap();
|
||||
|
||||
assert_eq!(ctx.best_cars.len(), 0, "No entries for missing file");
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Track name / type tests (from lib)
|
||||
// =======================================================================
|
||||
|
||||
#[test]
|
||||
fn test_translate_track_name_known_tracks() {
|
||||
assert_eq!(translate_track_name("laguna"), "Laguna Seca");
|
||||
assert_eq!(translate_track_name("mnCity"), "Monaco Streets");
|
||||
assert_eq!(translate_track_name("testBowl"), "Test Bowl");
|
||||
assert_eq!(translate_track_name("unknown_track"), "unknown_track");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_translate_track_type_known_conditions() {
|
||||
assert_eq!(translate_track_type("00"), "Dry");
|
||||
assert_eq!(translate_track_type("01"), "Wet");
|
||||
assert_eq!(translate_track_type("10"), "Dirt");
|
||||
assert_eq!(translate_track_type("99"), "99");
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Constants tests
|
||||
// =======================================================================
|
||||
|
||||
#[test]
|
||||
fn test_constants_are_reasonable() {
|
||||
assert_eq!(MAX_RQ, 113);
|
||||
assert!(MIN_VALID_TIME > 0.0);
|
||||
assert!(TIME_TOLERANCE > 0.0);
|
||||
assert!(TEST_BOWL_FACTOR > 0.0);
|
||||
assert!(CONCURRENCY_LIMIT > 0);
|
||||
}
|
||||
}
|
||||
878
src/records.rs
878
src/records.rs
|
|
@ -1,8 +1,14 @@
|
|||
//! # Top Drives Records Analyzer
|
||||
//!
|
||||
//! A comprehensive Rust-based tool for analyzing car performance data in the Top Drives racing game.
|
||||
//! This module provides functionality to fetch, parse, and analyze track timing data from a
|
||||
//! community-maintained repository.
|
||||
//! This module provides the CLI entry point and delegates to submodules for parsing, output, and
|
||||
//! command handling.
|
||||
//!
|
||||
//! ## Module Structure
|
||||
//!
|
||||
//! - [`commands`] — Command handlers (fetch, best, trash, wishlist, details, track)
|
||||
//! - [`parsing`] — Core timing data parsing and car database loading
|
||||
//! - [`output`] — CSV output utilities and sorting helpers
|
||||
//!
|
||||
//! ## Key Features
|
||||
//!
|
||||
|
|
@ -26,34 +32,56 @@
|
|||
//! - `serde_json`: JSON parsing for car and track data
|
||||
//! - `tokio`: Asynchronous runtime for concurrent processing
|
||||
//! - `futures`: Future utilities for handling async operations
|
||||
//! - `clap`: Command-line argument parsing
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::sync::Arc;
|
||||
mod commands;
|
||||
mod output;
|
||||
mod parsing;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use tokio::sync::Semaphore;
|
||||
use tokio::task;
|
||||
|
||||
use futures::future::join_all;
|
||||
use top_drives::AppError;
|
||||
|
||||
use serde_json::Value;
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants (CLI defaults)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use top_drives::{
|
||||
AnalysisContext, BestCarRecord, Car, CarCounts, TripletKey,
|
||||
compare_cars, compute_car_counts, get_file, load_my_cars_data,
|
||||
translate_track_name, translate_track_type,
|
||||
};
|
||||
/// Default path for the user's car collection export.
|
||||
const DEFAULT_MY_CARS_PATH: &str = "my_cars.json";
|
||||
|
||||
/// Maximum RQ level in the Top Drives database.
|
||||
/// Used as the upper bound when iterating RQ ranges and as a fallback for parse errors.
|
||||
const MAX_RQ: u8 = 113;
|
||||
/// Default path for the car database file.
|
||||
const DEFAULT_CARS_DB_PATH: &str = "cars_repo.json";
|
||||
|
||||
/// Default directory for per-car timing JSON files.
|
||||
const DEFAULT_TIMES_DIR: &str = "times";
|
||||
|
||||
/// Default path for the tracks database file.
|
||||
const DEFAULT_TRACKS_DB_PATH: &str = "tracks_repo.json";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI definition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Command-line interface for the Top Drives Records Analyzer.
|
||||
#[derive(Parser)]
|
||||
#[command(name = "records", about = "Top Drives Records Analyzer", version)]
|
||||
struct Cli {
|
||||
/// Path to the user's car collection export file
|
||||
#[arg(long, default_value = DEFAULT_MY_CARS_PATH)]
|
||||
my_cars: String,
|
||||
|
||||
/// Path to the car database file
|
||||
#[arg(long, default_value = DEFAULT_CARS_DB_PATH)]
|
||||
cars_db: String,
|
||||
|
||||
/// Directory containing per-car timing JSON files
|
||||
#[arg(long, default_value = DEFAULT_TIMES_DIR)]
|
||||
times_dir: String,
|
||||
|
||||
/// Path to the tracks database file
|
||||
#[arg(long, default_value = DEFAULT_TRACKS_DB_PATH)]
|
||||
tracks_db: String,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
|
|
@ -99,16 +127,9 @@ enum Commands {
|
|||
},
|
||||
}
|
||||
|
||||
/// Common analysis setup: loads user data, parses all timing files, and computes car counts.
|
||||
///
|
||||
/// Returns the analysis context (for stdout commands) and the aggregated car counts
|
||||
/// (for CSV output commands). The car list is stored inside `CarCounts`.
|
||||
async fn run_analysis(owned: &HashSet<String>) -> (AnalysisContext, CarCounts) {
|
||||
let ctx = AnalysisContext::new();
|
||||
let car_list = parse_all_times(owned, &ctx).await;
|
||||
let counts = compute_car_counts(&ctx, car_list);
|
||||
(ctx, counts)
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Main entry point for the Top Drives Records Analyzer
|
||||
///
|
||||
|
|
@ -120,804 +141,47 @@ async fn run_analysis(owned: &HashSet<String>) -> (AnalysisContext, CarCounts) {
|
|||
/// ```bash
|
||||
/// cargo run --bin records -- best
|
||||
/// cargo run --bin records -- track "Laguna Seca" "Dry" "85"
|
||||
/// cargo run --bin records -- --cars-db /data/cars.json --times-dir /data/times best
|
||||
/// ```
|
||||
#[tokio::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
async fn main() {
|
||||
if let Err(e) = run().await {
|
||||
eprintln!("Error: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async fn run() -> Result<(), AppError> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.command {
|
||||
Commands::Fetch => {
|
||||
let mut errors = 0usize;
|
||||
if let Err(e) = get_cars_file().await { eprintln!("Error: {e}"); errors += 1; }
|
||||
if let Err(e) = get_tracks_file().await { eprintln!("Error: {e}"); errors += 1; }
|
||||
if let Err(e) = fetch_times().await { eprintln!("Error: {e}"); errors += 1; }
|
||||
if errors == 0 {
|
||||
println!("Fetch complete — all files downloaded successfully.");
|
||||
} else {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
format!("Fetch completed with {errors} error(s). Check above for details."),
|
||||
));
|
||||
}
|
||||
commands::fetch::run_fetch(&cli.cars_db, &cli.tracks_db, &cli.times_dir).await?;
|
||||
}
|
||||
Commands::Best { min_count } => {
|
||||
let my_data = load_my_cars_data();
|
||||
let (_ctx, counts) = run_analysis(&my_data.owned).await;
|
||||
write_best_csv(&counts)?;
|
||||
write_very_best_csv(&counts, min_count)?;
|
||||
println!("Written: best.csv, very_best.csv");
|
||||
commands::best::run_best(&cli.my_cars, &cli.cars_db, &cli.times_dir, min_count).await?;
|
||||
}
|
||||
Commands::Trash => {
|
||||
let my_data = load_my_cars_data();
|
||||
let (_ctx, counts) = run_analysis(&my_data.owned).await;
|
||||
write_trash_csv(&counts, &my_data.owned)?;
|
||||
println!("Written: trash.csv");
|
||||
commands::trash::run_trash(&cli.my_cars, &cli.cars_db, &cli.times_dir).await?;
|
||||
}
|
||||
Commands::Wishlist { min_count, max_wishlist } => {
|
||||
let my_data = load_my_cars_data();
|
||||
let (_ctx, counts) = run_analysis(&my_data.owned).await;
|
||||
write_wishlist_files(&counts, &my_data.owned, &my_data.wishlist, min_count, max_wishlist)?;
|
||||
println!("Written: wishlist.csv, add.csv, remove.csv");
|
||||
commands::wishlist::run_wishlist(
|
||||
&cli.my_cars, &cli.cars_db, &cli.times_dir, min_count, max_wishlist,
|
||||
).await?;
|
||||
}
|
||||
Commands::All { min_count, max_wishlist } => {
|
||||
let my_data = load_my_cars_data();
|
||||
let (ctx, counts) = run_analysis(&my_data.owned).await;
|
||||
write_best_csv(&counts)?;
|
||||
write_very_best_csv(&counts, min_count)?;
|
||||
write_trash_csv(&counts, &my_data.owned)?;
|
||||
write_details_csv(&ctx)?;
|
||||
write_wishlist_files(&counts, &my_data.owned, &my_data.wishlist, min_count, max_wishlist)?;
|
||||
println!("Written: best.csv, very_best.csv, trash.csv, details.csv, wishlist.csv, add.csv, remove.csv");
|
||||
commands::all::run_all(
|
||||
&cli.my_cars, &cli.cars_db, &cli.times_dir, min_count, max_wishlist,
|
||||
).await?;
|
||||
}
|
||||
Commands::Details => {
|
||||
let my_data = load_my_cars_data();
|
||||
let (ctx, _counts) = run_analysis(&my_data.owned).await;
|
||||
write_details_csv(&ctx)?;
|
||||
println!("Written: details.csv");
|
||||
commands::details::run_details(&cli.my_cars, &cli.cars_db, &cli.times_dir).await?;
|
||||
}
|
||||
Commands::Track { track, condition, rq } => {
|
||||
let my_data = load_my_cars_data();
|
||||
let (ctx, _counts) = run_analysis(&my_data.owned).await;
|
||||
print_best_car(&ctx, &track, &condition, rq);
|
||||
commands::track::run_track(
|
||||
&cli.my_cars, &cli.cars_db, &cli.times_dir, &track, &condition, rq,
|
||||
).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Comparison function for sorting `BestCarRecord`s by RQ, then brand, then model name.
|
||||
///
|
||||
/// Avoids cloning into a `Car` just for sorting — uses the same ordering as
|
||||
/// `compare_cars` but operates directly on `BestCarRecord`.
|
||||
fn compare_records(a: &BestCarRecord, b: &BestCarRecord) -> std::cmp::Ordering {
|
||||
a.rq.cmp(&b.rq)
|
||||
.then(a.brand.cmp(&b.brand))
|
||||
.then(a.only_name.cmp(&b.only_name))
|
||||
}
|
||||
|
||||
/// Writes a sorted CSV file with a header row.
|
||||
///
|
||||
/// Generic helper that sorts entries by index (avoiding cloning), writes the header,
|
||||
/// and formats each row. Returns `std::io::Result<()>` so callers can propagate errors
|
||||
/// instead of panicking.
|
||||
fn write_sorted_csv<T, G>(
|
||||
path: &str,
|
||||
header: &str,
|
||||
entries: &[T],
|
||||
cmp: impl Fn(&T, &T) -> std::cmp::Ordering,
|
||||
format_row: G,
|
||||
) -> std::io::Result<()>
|
||||
where
|
||||
G: Fn(&T, &mut fs::File) -> std::io::Result<()>,
|
||||
{
|
||||
let mut indices: Vec<usize> = (0..entries.len()).collect();
|
||||
indices.sort_by(|&a, &b| cmp(&entries[a], &entries[b]));
|
||||
|
||||
let mut file = fs::File::create(path)?;
|
||||
writeln!(file, "{header}")?;
|
||||
for i in indices {
|
||||
format_row(&entries[i], &mut file)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Formats a `(BestCarRecord, usize)` row as `RQ,Brand,Model,Year,Count`.
|
||||
/// Shared by `write_best_csv`, `write_very_best_csv`, and wishlist output functions.
|
||||
fn format_counted_row(entry: &(BestCarRecord, usize), file: &mut fs::File) -> std::io::Result<()> {
|
||||
let (rec, count) = entry;
|
||||
writeln!(file, "{},{},{},{},{}", rec.rq, rec.brand, rec.only_name, rec.year, count)
|
||||
}
|
||||
|
||||
/// Writes best.csv — all cars that are best on at least one track, sorted by RQ.
|
||||
/// Format: RQ,Brand,Model,Year,Count
|
||||
fn write_best_csv(counts: &CarCounts) -> std::io::Result<()> {
|
||||
let entries: Vec<_> = counts.by_rid.values().collect();
|
||||
write_sorted_csv(
|
||||
"best.csv",
|
||||
"RQ,Brand,Model,Year,Count",
|
||||
&entries,
|
||||
|a, b| compare_records(&a.0, &b.0),
|
||||
|entry, file| format_counted_row(entry, file),
|
||||
)
|
||||
}
|
||||
|
||||
/// Writes very_best.csv — cars best on >= min_count tracks, sorted by RQ.
|
||||
/// Format: RQ,Brand,Model,Year,Count
|
||||
fn write_very_best_csv(counts: &CarCounts, min_count: usize) -> std::io::Result<()> {
|
||||
let entries: Vec<_> = counts.by_rid.values()
|
||||
.filter(|(_, count)| *count >= min_count)
|
||||
.collect();
|
||||
write_sorted_csv(
|
||||
"very_best.csv",
|
||||
"RQ,Brand,Model,Year,Count",
|
||||
&entries,
|
||||
|a, b| compare_records(&a.0, &b.0),
|
||||
|entry, file| format_counted_row(entry, file),
|
||||
)
|
||||
}
|
||||
|
||||
/// Writes trash.csv — owned cars not best on any track, RQ < 80, not prize.
|
||||
/// Format: RQ,Brand,Model,Year
|
||||
fn write_trash_csv(counts: &CarCounts, owned: &HashSet<String>) -> std::io::Result<()> {
|
||||
let trash: Vec<_> = owned.iter()
|
||||
.filter(|guid| !counts.guid_to_rid.contains_key(*guid))
|
||||
.filter_map(|guid| counts.car_map.get(guid))
|
||||
.filter(|car| !car.prize && car.rq < 80)
|
||||
.collect();
|
||||
|
||||
write_sorted_csv(
|
||||
"trash.csv",
|
||||
"RQ,Brand,Model,Year",
|
||||
&trash,
|
||||
|a, b| compare_cars(a, b),
|
||||
|car, file| writeln!(file, "{},{},{},{}", car.rq, car.brand, car.only_name, car.year),
|
||||
)
|
||||
}
|
||||
|
||||
/// Computes the ideal wishlist: very_best unowned cars, capped at max_wishlist, sorted by RQ.
|
||||
fn compute_ideal_wishlist(
|
||||
counts: &CarCounts,
|
||||
owned: &HashSet<String>,
|
||||
min_count: usize,
|
||||
max_wishlist: usize,
|
||||
) -> Vec<(BestCarRecord, usize)> {
|
||||
let mut candidates: Vec<_> = counts.by_rid.values()
|
||||
.filter(|(record, count)| *count >= min_count && !owned.contains(&record.guid))
|
||||
.cloned()
|
||||
.collect();
|
||||
candidates.sort_by(|a, b| compare_records(&a.0, &b.0));
|
||||
candidates.truncate(max_wishlist);
|
||||
candidates
|
||||
}
|
||||
|
||||
/// Writes wishlist.csv, add.csv, and remove.csv from a single computed ideal wishlist.
|
||||
///
|
||||
/// Computes the ideal wishlist once and derives all three output files from it,
|
||||
/// avoiding redundant computation.
|
||||
fn write_wishlist_files(
|
||||
counts: &CarCounts,
|
||||
owned: &HashSet<String>,
|
||||
wishlist: &HashSet<String>,
|
||||
min_count: usize,
|
||||
max_wishlist: usize,
|
||||
) -> std::io::Result<()> {
|
||||
let ideal = compute_ideal_wishlist(counts, owned, min_count, max_wishlist);
|
||||
let ideal_guids: HashSet<_> = ideal.iter().map(|(r, _)| r.guid.clone()).collect();
|
||||
|
||||
// --- wishlist.csv ---
|
||||
write_sorted_csv(
|
||||
"wishlist.csv",
|
||||
"RQ,Brand,Model,Year,Count",
|
||||
&ideal,
|
||||
|a, b| compare_records(&a.0, &b.0),
|
||||
|entry, file| format_counted_row(entry, file),
|
||||
)?;
|
||||
|
||||
// --- add.csv (ideal cars not yet on the wishlist) ---
|
||||
let to_add: Vec<_> = ideal.iter()
|
||||
.filter(|(record, _)| !wishlist.contains(&record.guid))
|
||||
.collect();
|
||||
|
||||
write_sorted_csv(
|
||||
"add.csv",
|
||||
"RQ,Brand,Model,Year,Count",
|
||||
&to_add,
|
||||
|a, b| compare_records(&a.0, &b.0),
|
||||
|entry, file| format_counted_row(entry, file),
|
||||
)?;
|
||||
|
||||
// --- remove.csv (wishlist cars not in the ideal set) ---
|
||||
// Single pass: collect found entries and count dropped ones together.
|
||||
let mut dropped_count = 0usize;
|
||||
let to_remove: Vec<_> = wishlist.iter()
|
||||
.filter(|guid| !ideal_guids.contains(*guid))
|
||||
.filter_map(|guid| {
|
||||
if let Some(rid) = counts.guid_to_rid.get(guid) {
|
||||
counts.by_rid.get(rid).cloned()
|
||||
} else {
|
||||
dropped_count += 1;
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if dropped_count > 0 {
|
||||
eprintln!(
|
||||
"Warning: {dropped_count} wishlist car(s) not found in best cars data and omitted from remove.csv"
|
||||
);
|
||||
}
|
||||
|
||||
write_sorted_csv(
|
||||
"remove.csv",
|
||||
"RQ,Brand,Model,Year,Count",
|
||||
&to_remove,
|
||||
|a, b| compare_records(&a.0, &b.0),
|
||||
|entry, file| format_counted_row(entry, file),
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Writes detailed timing information for all tracks, conditions, and RQ levels to `details.csv`.
|
||||
///
|
||||
/// Outputs comprehensive performance data showing the best car for every combination
|
||||
/// of track, surface condition, and RQ level. Results are sorted by RQ then track ID
|
||||
/// for consistent output ordering.
|
||||
///
|
||||
/// # Output Format
|
||||
/// ```csv
|
||||
/// RQ,TrackID,Condition,CarRQ,Brand,Model,Year,Config,Time
|
||||
/// 85,laguna,Dry,85,McLaren,F1,1993,233,112.45
|
||||
/// 85,monaco,Dry,85,Pagani,Zonda,2002,111,45.23
|
||||
/// 82,laguna,Dry,82,Ferrari,250 GTO,1962,111,115.67
|
||||
/// ```
|
||||
fn write_details_csv(ctx: &AnalysisContext) -> std::io::Result<()> {
|
||||
// Scope the lock to only the data extraction phase
|
||||
let sorted_data: Vec<(TripletKey, Vec<BestCarRecord>)> = {
|
||||
let best_cars = ctx.best_cars.lock()
|
||||
.expect("best_cars mutex poisoned — another thread panicked");
|
||||
|
||||
// Collect keys and sort them by RQ and Track ID for consistent output
|
||||
let mut sorted_keys: Vec<_> = best_cars.keys().collect();
|
||||
sorted_keys.sort_by(|a, b| a.1.cmp(&b.1).then(a.0.cmp(&b.0)));
|
||||
|
||||
sorted_keys
|
||||
.into_iter()
|
||||
.filter_map(|key| best_cars.get(key).map(|records| (key.clone(), records.clone())))
|
||||
.collect()
|
||||
}; // Lock dropped here
|
||||
|
||||
let mut file = fs::File::create("details.csv")?;
|
||||
writeln!(file, "RQ,TrackID,Condition,CarRQ,Brand,Model,Year,Config,Time")?;
|
||||
for (key, records) in sorted_data {
|
||||
for best_car in records {
|
||||
writeln!(
|
||||
file,
|
||||
"{},{},{},{},{},{},{},{},{}",
|
||||
key.1, // RQ level
|
||||
key.0, // Track ID
|
||||
key.2, // Surface condition
|
||||
best_car.rq, // Car's RQ
|
||||
best_car.brand, // Car brand
|
||||
best_car.only_name, // Car model
|
||||
best_car.year, // Car year
|
||||
best_car.config, // Tune configuration
|
||||
best_car.time // Track time
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Finds and displays the best performing car for a specific track, condition, and RQ level
|
||||
///
|
||||
/// Provides targeted recommendations for specific racing scenarios. Useful for determining
|
||||
/// the optimal car choice when you need to win a particular race or meet specific requirements.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `ctx` - Analysis context containing the best cars data
|
||||
/// * `track` - The track identifier (e.g., "laguna", "monaco")
|
||||
/// * `condition` - Surface condition (e.g., "Dry", "Wet", "Dirt")
|
||||
/// * `rq` - RQ level limit (cars with this RQ or lower will be considered)
|
||||
///
|
||||
/// # Output Example
|
||||
/// ```
|
||||
/// Best car for Track: laguna, Condition: Dry, RQ: 85 -> McLaren F1 (1993) with time 112.45
|
||||
/// ```
|
||||
///
|
||||
/// # Note
|
||||
/// If no car is found for the specified combination, it may indicate that no data
|
||||
/// is available for that specific track/condition/RQ combination.
|
||||
fn print_best_car(ctx: &AnalysisContext, track: &str, condition: &str, rq: u8) {
|
||||
let best_cars = ctx.best_cars.lock()
|
||||
.expect("best_cars mutex poisoned — another thread panicked");
|
||||
let key = (
|
||||
track.to_string(),
|
||||
rq,
|
||||
condition.to_string(),
|
||||
);
|
||||
|
||||
if let Some(best_list) = best_cars.get(&key) {
|
||||
for best_car in best_list {
|
||||
println!(
|
||||
"Best car for Track: {}, Condition: {}, RQ: {} -> {} {} ({}) with time {:.2}",
|
||||
track,
|
||||
condition,
|
||||
rq,
|
||||
best_car.brand,
|
||||
best_car.only_name,
|
||||
best_car.year,
|
||||
best_car.time
|
||||
);
|
||||
}
|
||||
} else {
|
||||
println!(
|
||||
"No best car found for Track: {}, Condition: {}, RQ: {}",
|
||||
track, condition, rq
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses timing data for a single car from its JSON file
|
||||
///
|
||||
/// This is the core function responsible for processing individual car performance data.
|
||||
/// It reads the car's JSON file, extracts timing information for each track and condition,
|
||||
/// filters out invalid or downvoted times, and updates the analysis context.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `car` - The car data structure containing metadata about the car to process
|
||||
/// * `my_cars` - Set of GUIDs the user owns (used to mark ownership)
|
||||
/// * `ctx` - Analysis context containing the shared best cars map
|
||||
///
|
||||
/// # Data Processing
|
||||
///
|
||||
/// 1. **File Reading**: Loads the car's JSON timing file from the times/ directory
|
||||
/// 2. **Configuration Parsing**: Processes each tune configuration (111, 233, 323, etc.)
|
||||
/// 3. **Track Processing**: Extracts timing data for each track and surface condition
|
||||
/// 4. **Validation**: Filters out invalid times and downvoted entries
|
||||
/// 5. **Time Conversion**: Handles special track calculations (e.g., Test Bowl)
|
||||
/// 6. **Database Update**: Updates the analysis context with new records
|
||||
///
|
||||
/// # Special Cases
|
||||
///
|
||||
/// - **Test Bowl Track**: Time calculation is inverted (2000.0 / time) due to unique track mechanics
|
||||
/// - **Downvoted Times**: Entries with downvotes are excluded to maintain data quality
|
||||
/// - **Minimum Time Threshold**: Times below 1.2 seconds are considered invalid
|
||||
///
|
||||
/// # Performance
|
||||
///
|
||||
/// This function is designed to be called concurrently for multiple cars to improve
|
||||
/// overall processing speed when analyzing large car databases.
|
||||
async fn parse_car_times(car: Car, my_cars: &HashSet<String>, ctx: &AnalysisContext) {
|
||||
// Construct file path for this car's timing data
|
||||
let file_name = car.rid.to_owned() + ".json";
|
||||
let local_file_path = "times/".to_owned() + file_name.as_str();
|
||||
|
||||
// Read file content, emitting a warning on failure
|
||||
let file_content = match fs::read_to_string(&local_file_path) {
|
||||
Ok(content) => content,
|
||||
Err(e) => {
|
||||
eprintln!("Warning: could not read {local_file_path}: {e}");
|
||||
String::new()
|
||||
}
|
||||
};
|
||||
|
||||
// Parse JSON data, emitting a warning on failure
|
||||
let json_data: Value = match serde_json::from_str(&file_content) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
if !file_content.is_empty() {
|
||||
eprintln!("Warning: could not parse {local_file_path}: {e}");
|
||||
}
|
||||
Value::Null
|
||||
}
|
||||
};
|
||||
|
||||
// Process each configuration variant (111, 233, 323, etc.)
|
||||
if let Some(configurations) = json_data["data"].as_object() {
|
||||
for (config, entry) in configurations {
|
||||
// Process timing data for each track
|
||||
if let Some(times) = entry["times"].as_object() {
|
||||
for (track_id, details) in times {
|
||||
// Parse track ID to separate base track name from surface condition
|
||||
if let Some((cleaned_track_id, track_type)) = track_id.split_once("_a") {
|
||||
let mut time = details["t"].as_f64().unwrap_or(0.0);
|
||||
|
||||
let down_votes = details["down"].as_array();
|
||||
let no_down_votes = match down_votes {
|
||||
None => true,
|
||||
Some(votes) => votes.is_empty(),
|
||||
};
|
||||
|
||||
if track_id.starts_with("testBowl") {
|
||||
time = 2000.0 / time;
|
||||
}
|
||||
|
||||
let owned = my_cars.contains(&car.guid);
|
||||
let guid = car.guid.clone();
|
||||
|
||||
if time > 1.2 && no_down_votes {
|
||||
update_best_car(
|
||||
&ctx.best_cars,
|
||||
&translate_track_name(cleaned_track_id),
|
||||
car.rq,
|
||||
translate_track_type(track_type),
|
||||
BestCarRecord {
|
||||
car_rid: car.rid.clone(),
|
||||
brand: car.brand.clone(),
|
||||
only_name: car.only_name.clone(),
|
||||
year: car.year,
|
||||
rq: car.rq,
|
||||
config: config.clone(),
|
||||
time,
|
||||
owned,
|
||||
prize: car.prize,
|
||||
guid,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the best cars database with a new performance record
|
||||
///
|
||||
/// Implements the core logic for determining the best performing cars across different
|
||||
/// RQ ranges. For each RQ level from the car's base RQ up to MAX_RQ, this
|
||||
/// function determines whether the new record represents an improvement or tie.
|
||||
fn update_best_car(
|
||||
best_cars: &Arc<std::sync::Mutex<HashMap<TripletKey, Vec<BestCarRecord>>>>,
|
||||
track_id: &str,
|
||||
car_rq: u8,
|
||||
surface: &str,
|
||||
new_record: BestCarRecord,
|
||||
) {
|
||||
/// Tolerance for considering two times as equal (5ms)
|
||||
const TIME_TOLERANCE: f64 = 0.005;
|
||||
|
||||
let mut best_cars = best_cars.lock().unwrap();
|
||||
|
||||
for rq in car_rq..=MAX_RQ {
|
||||
let key = (track_id.to_string(), rq, surface.to_string());
|
||||
|
||||
match best_cars.get_mut(&key) {
|
||||
Some(existing_list) => {
|
||||
let best_time = existing_list.first().map(|r| r.time).unwrap_or(f64::MAX);
|
||||
|
||||
if new_record.time < best_time {
|
||||
*existing_list = vec![new_record.clone()];
|
||||
} else if (new_record.time - best_time).abs() < TIME_TOLERANCE
|
||||
&& !existing_list
|
||||
.iter()
|
||||
.any(|r| r.car_rid == new_record.car_rid)
|
||||
{
|
||||
existing_list.push(new_record.clone());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
best_cars.insert(key, vec![new_record.clone()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Orchestrates concurrent parsing of all car timing files
|
||||
///
|
||||
/// Spawns a separate async task for each car in the database, allowing for
|
||||
/// parallel processing of timing data.
|
||||
async fn parse_all_times(my_cars: &HashSet<String>, ctx: &AnalysisContext) -> Vec<Car> {
|
||||
let my_cars = Arc::new(my_cars.clone());
|
||||
let car_list = get_car_list();
|
||||
let mut handles = vec![];
|
||||
|
||||
let semaphore = Arc::new(Semaphore::new(20));
|
||||
|
||||
for car in car_list.iter() {
|
||||
let my_cars = Arc::clone(&my_cars);
|
||||
let best_cars = Arc::clone(&ctx.best_cars);
|
||||
let car = car.clone();
|
||||
let sem = Arc::clone(&semaphore);
|
||||
let handle = task::spawn(async move {
|
||||
let _permit = sem.acquire().await.unwrap();
|
||||
parse_car_times(car, &my_cars, &AnalysisContext { best_cars }).await;
|
||||
});
|
||||
handles.push(handle);
|
||||
}
|
||||
|
||||
let results = join_all(handles).await;
|
||||
for result in results {
|
||||
if let Err(e) = result {
|
||||
eprintln!("Warning: parsing task failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
car_list
|
||||
}
|
||||
|
||||
/// Fetches timing data files for all cars using controlled concurrency.
|
||||
///
|
||||
/// Returns the first error encountered, or `Ok(())` if all downloads succeeded.
|
||||
async fn fetch_times() -> Result<(), String> {
|
||||
let semaphore = Arc::new(Semaphore::new(20));
|
||||
let mut handles = vec![];
|
||||
|
||||
for car in get_car_list() {
|
||||
let permit = semaphore.clone().acquire_owned().await
|
||||
.map_err(|e| format!("Semaphore closed: {e}"))?;
|
||||
handles.push(tokio::spawn(async move {
|
||||
let _permit = permit; // held until end of block
|
||||
fetch_car(car.rid).await
|
||||
}));
|
||||
}
|
||||
|
||||
let results = join_all(handles).await;
|
||||
let mut first_error: Option<String> = None;
|
||||
for result in results {
|
||||
match result {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => {
|
||||
eprintln!("{e}");
|
||||
if first_error.is_none() { first_error = Some(e); }
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = format!("Task join error: {e}");
|
||||
eprintln!("{msg}");
|
||||
if first_error.is_none() { first_error = Some(msg); }
|
||||
}
|
||||
}
|
||||
}
|
||||
first_error.map_or(Ok(()), Err)
|
||||
}
|
||||
|
||||
/// Downloads timing data for a single car from the remote API
|
||||
async fn fetch_car(rid: String) -> Result<(), String> {
|
||||
let file_name = rid.to_owned() + ".json";
|
||||
let url = "https://api.topdrivesrecords.com/car/".to_owned() + rid.as_str();
|
||||
let local_file_path = "times/".to_owned() + file_name.as_str();
|
||||
println!("Fetching {}.", url);
|
||||
get_file(url.as_str(), local_file_path.as_str()).await
|
||||
}
|
||||
|
||||
/// Loads the complete car database from the local repository file
|
||||
///
|
||||
/// Returns a `Vec<Car>` containing all cars from the database.
|
||||
/// Returns an empty list with a warning if the file is missing or malformed.
|
||||
fn get_car_list() -> Vec<Car> {
|
||||
let cars_file = "cars_repo.json";
|
||||
let content = match fs::read_to_string(cars_file) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("Warning: could not read {cars_file}: {e}");
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
let cars_data: Vec<Value> = match serde_json::from_str(&content) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!("Warning: could not parse {cars_file}: {e}");
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
|
||||
let total = cars_data.len();
|
||||
let cars: Vec<Car> = cars_data.into_iter().filter_map(|car| {
|
||||
let rid = car["rid"].as_str()?;
|
||||
let brand = car["brand"].as_str()?;
|
||||
let only_name = car["onlyName"].as_str()?;
|
||||
let year = car["year"].as_u64()?;
|
||||
let rq = car["rq"].as_u64()?;
|
||||
let prize = car["prize"].as_bool().unwrap_or(false);
|
||||
let guid = car["guid"].as_str()?;
|
||||
|
||||
Some(Car { rid: rid.to_string(), brand: brand.to_string(), only_name: only_name.to_string(), year: year as u16, rq: rq as u8, prize, guid: guid.to_string() })
|
||||
}).collect();
|
||||
let skipped = total - cars.len();
|
||||
if skipped > 0 {
|
||||
eprintln!("Warning: {skipped} car(s) skipped due to missing required fields in {cars_file}");
|
||||
}
|
||||
cars
|
||||
}
|
||||
|
||||
/// Downloads the tracks database file from the remote repository
|
||||
async fn get_tracks_file() -> Result<(), String> {
|
||||
println!("Fetching tracks file...");
|
||||
get_file("https://github.com/TiagoXavi/topdrives-records/raw/refs/heads/main/src/database/tracks_repo.json","tracks_repo.json").await
|
||||
}
|
||||
|
||||
/// Downloads the cars database file from the remote repository
|
||||
async fn get_cars_file() -> Result<(), String> {
|
||||
println!("Fetching cars file...");
|
||||
get_file("https://github.com/TiagoXavi/topdrives-records/raw/refs/heads/main/src/database/cars_final.json", "cars_repo.json").await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_split_once() {
|
||||
let result = "laguna_a00".split_once("_a");
|
||||
assert_eq!(result, Some(("laguna", "00")));
|
||||
|
||||
let result = "laguna".split_once("_a");
|
||||
assert_eq!(result, None);
|
||||
|
||||
let result = "test_a_data_a_more".split_once("_a");
|
||||
assert_eq!(result, Some(("test", "_data_a_more")));
|
||||
|
||||
let result = "".split_once("_a");
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_translate_track_name() {
|
||||
assert_eq!(translate_track_name("laguna"), "Laguna Seca");
|
||||
assert_eq!(translate_track_name("mnCity"), "Monaco Streets");
|
||||
assert_eq!(translate_track_name("testBowl"), "Test Bowl");
|
||||
assert_eq!(translate_track_name("unknown_track"), "unknown_track");
|
||||
assert_eq!(translate_track_name("drag100"), "0-100mph");
|
||||
assert_eq!(translate_track_name("mile4"), "1/4 Mile Drag");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_translate_track_type() {
|
||||
assert_eq!(translate_track_type("00"), "Dry");
|
||||
assert_eq!(translate_track_type("01"), "Wet");
|
||||
assert_eq!(translate_track_type("10"), "Dirt");
|
||||
assert_eq!(translate_track_type("11"), "Dirt Wet");
|
||||
assert_eq!(translate_track_type("30"), "Ice");
|
||||
assert_eq!(translate_track_type("60"), "Snow");
|
||||
assert_eq!(translate_track_type("99"), "99");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_best_car_logic() {
|
||||
let test_record = BestCarRecord {
|
||||
car_rid: "Test_Car".to_string(),
|
||||
brand: "TestBrand".to_string(),
|
||||
only_name: "TestCar".to_string(),
|
||||
year: 2020,
|
||||
rq: 85,
|
||||
config: "111".to_string(),
|
||||
time: 100.0,
|
||||
owned: false,
|
||||
prize: false,
|
||||
guid: "test_guid".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(test_record.time, 100.0);
|
||||
assert_eq!(test_record.rq, 85);
|
||||
assert_eq!(test_record.config, "111");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rq_range_analysis() {
|
||||
let ctx = AnalysisContext::new();
|
||||
|
||||
let test_record = BestCarRecord {
|
||||
car_rid: "Test_Car".to_string(),
|
||||
brand: "TestBrand".to_string(),
|
||||
only_name: "TestCar".to_string(),
|
||||
year: 2020,
|
||||
rq: 85,
|
||||
config: "111".to_string(),
|
||||
time: 100.0,
|
||||
owned: false,
|
||||
prize: false,
|
||||
guid: "test_guid".to_string(),
|
||||
};
|
||||
|
||||
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", test_record);
|
||||
|
||||
let best_cars = ctx.best_cars.lock().unwrap();
|
||||
|
||||
for rq in 85..=MAX_RQ {
|
||||
let key = ("laguna".to_string(), rq, "Dry".to_string());
|
||||
assert!(
|
||||
best_cars.contains_key(&key),
|
||||
"Car should appear at RQ level {}",
|
||||
rq
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_time_validation() {
|
||||
let valid_time = 112.45;
|
||||
let invalid_time = 1.0;
|
||||
|
||||
assert!(valid_time > 1.2, "Valid time should be above threshold");
|
||||
assert!(invalid_time < 1.2, "Invalid time should be below threshold");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_track_name_completeness() {
|
||||
let test_tracks = vec![
|
||||
"laguna",
|
||||
"mnCity",
|
||||
"testBowl",
|
||||
"drag100",
|
||||
"mile4",
|
||||
"airplaneHangars",
|
||||
"dealsGap",
|
||||
"northloop",
|
||||
];
|
||||
|
||||
for track in test_tracks {
|
||||
let name = translate_track_name(track);
|
||||
assert_ne!(name, track, "Track {} should have a proper name mapping", track);
|
||||
assert!(!name.is_empty(), "Track name should not be empty");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_condition_completeness() {
|
||||
let test_conditions = vec!["00", "01", "10", "11", "20", "30", "40", "41", "50", "60"];
|
||||
|
||||
for condition in test_conditions {
|
||||
let name = translate_track_type(condition);
|
||||
assert_ne!(name, condition, "Condition {} should have a proper name mapping", condition);
|
||||
assert!(!name.is_empty(), "Condition name should not be empty");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_time_tolerance_constant() {
|
||||
let ctx = AnalysisContext::new();
|
||||
|
||||
let record1 = BestCarRecord {
|
||||
car_rid: "Car_A".to_string(),
|
||||
brand: "Brand".to_string(),
|
||||
only_name: "Model".to_string(),
|
||||
year: 2020,
|
||||
rq: 85,
|
||||
config: "111".to_string(),
|
||||
time: 100.0,
|
||||
owned: false,
|
||||
prize: false,
|
||||
guid: "guid_a".to_string(),
|
||||
};
|
||||
|
||||
let record2 = BestCarRecord {
|
||||
car_rid: "Car_B".to_string(),
|
||||
brand: "Brand".to_string(),
|
||||
only_name: "Model".to_string(),
|
||||
year: 2020,
|
||||
rq: 85,
|
||||
config: "111".to_string(),
|
||||
time: 100.003,
|
||||
owned: false,
|
||||
prize: false,
|
||||
guid: "guid_b".to_string(),
|
||||
};
|
||||
|
||||
let record3 = BestCarRecord {
|
||||
car_rid: "Car_C".to_string(),
|
||||
brand: "Brand".to_string(),
|
||||
only_name: "Model".to_string(),
|
||||
year: 2020,
|
||||
rq: 85,
|
||||
config: "111".to_string(),
|
||||
time: 100.006,
|
||||
owned: false,
|
||||
prize: false,
|
||||
guid: "guid_c".to_string(),
|
||||
};
|
||||
|
||||
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record1);
|
||||
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record2);
|
||||
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record3);
|
||||
|
||||
let best_cars = ctx.best_cars.lock().unwrap();
|
||||
let key = ("laguna".to_string(), 85, "Dry".to_string());
|
||||
let records = best_cars.get(&key).unwrap();
|
||||
|
||||
assert_eq!(records.len(), 2, "Should have 2 tied records within 0.005 tolerance");
|
||||
assert!(records.iter().any(|r| r.car_rid == "Car_A"));
|
||||
assert!(records.iter().any(|r| r.car_rid == "Car_B"));
|
||||
assert!(!records.iter().any(|r| r.car_rid == "Car_C"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue