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:
Jan-Peter von Hunnius 2026-05-08 16:19:01 +02:00
parent 9d57b18a4e
commit a9a46ee551
24 changed files with 3541 additions and 1470 deletions

View file

@ -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"));
}
}