top_drives/src/records.rs
Jan-Peter von Hunnius 08c96d9981 Replace manual CLI parsing with clap derive
- Add clap dependency with derive feature for structured CLI argument parsing
- Replace env::args() manual matching with clap subcommands (fetch, best, missing, wishlist, trash, details, track)
- Change print_best_car() signature from (&String, &String, &String) to (&str, &str, u8) for idiomatic Rust
- Add TODO.md tracking known tech debt and planned features
- Update CHANGELOG.md with clap migration details and set v0.2.0 release date
2026-05-07 12:27:11 +02:00

1027 lines
39 KiB
Rust

//! # 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.
//!
//! ## Key Features
//!
//! - Fetches car and track data from GitHub repository
//! - Parses JSON timing files for performance analysis
//! - Identifies best performing cars across all tracks and conditions
//! - Compares user collection against optimal car lists
//! - Provides track-specific performance recommendations
//! - Supports multiple track conditions (Dry, Wet, Dirt, Snow, etc.)
//!
//! ## Data Flow
//!
//! 1. **Fetch Phase**: Downloads car database and track definitions
//! 2. **Parse Phase**: Processes JSON timing files for each car
//! 3. **Analysis Phase**: Compares times across RQ ranges and conditions
//! 4. **Output Phase**: Presents results in CSV format or console output
//!
//! ## Dependencies
//!
//! - `reqwest`: HTTP client for fetching data from GitHub
//! - `serde_json`: JSON parsing for car and track data
//! - `tokio`: Asynchronous runtime for concurrent processing
//! - `futures`: Future utilities for handling async operations
use std::collections::{HashMap, HashSet};
use std::fs;
use std::sync::Arc;
use clap::{Parser, Subcommand};
use tokio::sync::Semaphore;
use tokio::task;
use futures::future::join_all;
use serde_json::Value;
use top_drives::{
AnalysisContext, BestCarRecord, Car, TripletKey,
compare_cars, get_file, load_my_cars_data, translate_track_name, translate_track_type,
};
/// 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;
/// Command-line interface for the Top Drives Records Analyzer.
#[derive(Parser)]
#[command(name = "records", about = "Top Drives Records Analyzer", version)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
/// Available subcommands for car performance analysis.
#[derive(Subcommand)]
enum Commands {
/// Download latest car and track data from repository
Fetch,
/// Show best performing cars in your collection
Best,
/// Show best cars not owned by you (prefix + if not on wishlist)
Missing,
/// Show wishlist cars that are no longer among the best
Wishlist,
/// Identify cars that can be safely removed from collection
Trash,
/// Show detailed timing data for all tracks
Details,
/// Show best car for a specific track/condition/RQ combination
Track {
/// Track name (e.g. "Laguna Seca")
track: String,
/// Track condition (e.g. "Dry", "Wet")
condition: String,
/// RQ limit (e.g. 85)
rq: u8,
},
}
/// Main entry point for the Top Drives Records Analyzer
///
/// Parses command line arguments using `clap` and executes the requested analysis operation.
/// Supports multiple subcommands for different types of car performance analysis.
///
/// # Example Usage
///
/// ```bash
/// cargo run --bin records -- best
/// cargo run --bin records -- track "Laguna Seca" "Dry" "85"
/// ```
#[tokio::main]
async fn main() {
let cli = Cli::parse();
match cli.command {
Commands::Fetch => {
// Download phase: Get fresh data from all sources
// This is the foundation for all other operations
get_cars_file().await;
get_tracks_file().await;
fetch_times().await;
}
Commands::Best => {
// Standard best cars analysis
// Shows cars you own that perform well across tracks
let my_data = load_my_cars_data();
let ctx = AnalysisContext::new();
let _car_list = parse_all_times(&my_data.owned, &ctx).await;
print_best_cars(&ctx, false, &my_data.wishlist); // false = include owned cars
}
Commands::Missing => {
// Missing cars analysis
// Shows high-performing cars you don't own
// Cars not yet on the wishlist are prefixed with "+"
let my_data = load_my_cars_data();
let ctx = AnalysisContext::new();
let _car_list = parse_all_times(&my_data.owned, &ctx).await;
print_best_cars(&ctx, true, &my_data.wishlist); // true = filter out owned cars
}
Commands::Wishlist => {
// Wishlist review
// Shows cars on the wishlist that are no longer among the best
let my_data = load_my_cars_data();
let ctx = AnalysisContext::new();
let car_list = parse_all_times(&my_data.owned, &ctx).await;
print_wishlist_stale(&ctx, &my_data.wishlist, &car_list);
}
Commands::Trash => {
// Collection optimization
// Identifies cars that can be safely removed
let my_data = load_my_cars_data();
let ctx = AnalysisContext::new();
let car_list = parse_all_times(&my_data.owned, &ctx).await;
print_trash(&ctx, &car_list, &my_data.owned);
}
Commands::Details => {
// Comprehensive data export
// Outputs all timing data for external analysis
let my_data = load_my_cars_data();
let ctx = AnalysisContext::new();
parse_all_times(&my_data.owned, &ctx).await;
print_best_times(&ctx);
}
Commands::Track { track, condition, rq } => {
// Track-specific analysis
let my_data = load_my_cars_data();
let ctx = AnalysisContext::new();
parse_all_times(&my_data.owned, &ctx).await;
print_best_car(&ctx, &track, &condition, rq);
}
}
}
/// Identifies and prints cars that can be safely removed from the user's collection
///
/// Compares the user's owned cars against the list of best performing cars across all tracks.
///
/// # Arguments
///
/// * `ctx` - Analysis context containing the best cars data
/// * `car_list` - Pre-loaded list of all cars, to avoid re-reading the data file
/// * `owned` - Set of GUIDs the user owns
///
/// Only considers cars that are not among the best performers and are below RQ 80 (to avoid
/// removing potentially useful higher-end cars).
///
/// # Output Format
/// ```csv
/// RQ,Brand,Model,Year
/// 45,Toyota,Corolla,1995
/// 52,Honda,Civic,1992
/// ```
///
/// # Note
/// Prize cars are excluded from trash recommendations as they may have collection value
/// beyond their performance characteristics.
fn print_trash(ctx: &AnalysisContext, car_list: &[Car], owned: &HashSet<String>) {
// Extract GUIDs of all best performing cars across all tracks and conditions
// Lock is scoped to avoid holding it during file I/O below
let best_cars: HashSet<_> = {
let guard = ctx.best_cars.lock().unwrap();
guard
.values()
.flat_map(|records| records.iter().map(|record| record.guid.clone()))
.collect()
}; // Lock dropped here
// Build a lookup map for car details by GUID (reusing pre-loaded car_list)
let car_map: HashMap<_, _> = car_list
.iter()
.map(|c| (c.guid.clone(), c))
.collect();
// Check each car in the user's collection
for my_car in owned {
if !best_cars.contains(my_car) {
if let Some(bad_car) = car_map.get(my_car) {
if !bad_car.prize && bad_car.rq < 80 {
println!(
"{},{},{},{}",
bad_car.rq, bad_car.brand, bad_car.only_name, bad_car.year
);
}
}
}
}
}
/// Prints cars on the wishlist that are no longer among the best performers
///
/// Compares the user's wishlist (from `my_cars.json`) against the current best-car
/// analysis results. Cars that appear on the wishlist but are no longer best for any
/// track/condition/RQ combination are reported with a `-` prefix, suggesting the
/// wishlist may need updating.
///
/// Cars that are best but not yet on the wishlist are shown by the `missing` command
/// instead, so they are intentionally excluded here to avoid duplication.
///
/// # Output Format
///
/// ```csv
/// -RQ,Brand,Model,Year (on wishlist but no longer best)
/// ```
///
/// # Arguments
///
/// * `ctx` - Analysis context containing the best cars data
/// * `wishlist` - Set of car GUIDs loaded from the wishlists section of `my_cars.json`
/// * `car_list` - Pre-loaded list of all cars, to avoid re-reading the data file
fn print_wishlist_stale(ctx: &AnalysisContext, wishlist: &HashSet<String>, car_list: &[Car]) {
// Collect GUIDs of all cars that are currently best for at least one combination
// Lock is scoped to avoid holding it during file I/O below
let current_best_guids: HashSet<String> = {
let best_cars = ctx.best_cars.lock().unwrap();
best_cars
.values()
.flat_map(|records| records.iter().map(|r| r.guid.clone()))
.collect()
}; // Lock dropped here
// Build a lookup map for car details by GUID (reusing pre-loaded car_list)
let car_map: HashMap<_, _> = car_list
.iter()
.map(|c| (c.guid.clone(), c))
.collect();
// Cars on wishlist that are no longer best — prefix with "-"
let mut stale: Vec<_> = wishlist
.difference(&current_best_guids)
.filter_map(|guid| car_map.get(guid))
.collect();
stale.sort_by(|a, b| compare_cars(*a, *b));
for car in &stale {
println!("-{},{},{},{}", car.rq, car.brand, car.only_name, car.year);
}
}
/// Prints detailed timing information for all tracks, conditions, and RQ levels
///
/// Outputs comprehensive performance data showing the best car for every combination
/// 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
/// ```
///
/// # Note
/// This generates a large amount of output data and is primarily used for
/// external analysis or when detailed performance data is required.
fn print_best_times(ctx: &AnalysisContext) {
// Scope the lock to only the data extraction phase
let sorted_data: Vec<(TripletKey, Vec<BestCarRecord>)> = {
let best_cars = ctx.best_cars.lock().unwrap();
// Collect keys and sort them by RQ and Track ID for consistent output
let mut sorted_keys: Vec<_> = best_cars.keys().collect();
sorted_keys.sort_by(|a, b| a.1.cmp(&b.1).then(a.0.cmp(&b.0))); // Sort by RQ, then Track ID
sorted_keys
.into_iter()
.filter_map(|key| best_cars.get(key).map(|records| (key.clone(), records.clone())))
.collect()
}; // Lock dropped here
for (key, records) in sorted_data {
for best_car in records {
println!(
"{},{},{},{},{},{},{},{},{}",
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
);
}
}
}
/// Prints a ranked list of the best performing cars across all tracks and conditions
///
/// Aggregates performance data to show which cars appear most frequently as top performers.
/// Cars are ranked by how often they achieve the best time on different tracks, providing
/// a measure of overall car quality and versatility.
///
/// # Arguments
///
/// * `ctx` - Analysis context containing the best cars data
/// * `filter_out_owned` - If true, excludes cars already owned by the user from the output
/// * `wishlist` - Set of car GUIDs on the user's wishlist
///
/// # Output Format
/// ```csv
/// RQ,Brand,Model,Year,Count
/// 85,McLaren,F1,1993,15
/// 82,Ferrari,250 GTO,1962,12
/// 80,Lamborghini,Countach,1974,8
/// ```
///
/// # Note
/// The count represents how many times this car achieved the best time across different
/// track/RQ/condition combinations, indicating overall performance quality.
fn print_best_cars(ctx: &AnalysisContext, filter_out_owned: bool, wishlist: &HashSet<String>) {
// Scope the lock to only the data extraction phase
let car_count: HashMap<String, (BestCarRecord, usize)> = {
let best_cars = ctx.best_cars.lock().unwrap();
let mut counts: HashMap<String, (BestCarRecord, usize)> = HashMap::new();
// Count occurrences of each car across all tracks and conditions
for (_key, best_list) in best_cars.iter() {
for best_car in best_list {
let entry = counts
.entry(best_car.car_rid.clone())
.or_insert((best_car.clone(), 0));
entry.1 += 1; // Increment occurrence count
}
}
counts
}; // Lock dropped here
// Convert to vector and sort by RQ first, then by car RID for consistent ordering
let mut car_count_vec: Vec<_> = car_count.iter().collect();
car_count_vec.sort_by(|a, b| a.1 .0.rq.cmp(&b.1 .0.rq).then(a.0.cmp(&b.0)));
for (_car_rid, (car_record, count)) in car_count_vec {
// Apply ownership filter if requested
if !(filter_out_owned && car_record.owned) {
// Check if this car is already on the wishlist (by GUID)
// In "missing" mode: prefix "+" if not on wishlist (suggested addition)
// In "best" mode: prefix "+" if not on wishlist (missing from wishlist)
let on_wishlist = wishlist.contains(&car_record.guid);
let prefix = if !on_wishlist { "+" } else { "" };
println!(
"{}{},{},{},{},{}",
prefix,
car_record.rq, // Car's RQ level
car_record.brand, // Car brand
car_record.only_name, // Car model
car_record.year, // Car year
count // Number of times this car was best
);
}
}
}
/// 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().unwrap();
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
// Example: "laguna_a00" -> ("laguna", "00")
if let Some((cleaned_track_id, track_type)) = track_id.split_once("_a") {
// Extract the raw time value from JSON
let mut time = details["t"].as_f64().unwrap_or(0.0);
// Check for community downvotes (quality control)
let down_votes = details["down"].as_array();
let no_down_votes = match down_votes {
None => true, // No downvotes = valid
Some(votes) => votes.is_empty(), // Empty array = no downvotes
};
// Special handling for Test Bowl track
// The track measures distance over time, so we invert the calculation
if track_id.starts_with("testBowl") {
time = 2000.0 / time; // Convert to standard time format
}
// Determine if user owns this car for output marking
let owned = my_cars.contains(&car.guid);
let guid = car.guid.clone();
// Apply quality filters before processing
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,
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.
///
/// # Arguments
///
/// * `best_cars` - Shared mutable reference to the best cars map
/// * `track_id` - The track identifier
/// * `car_rq` - The base RQ level of the car
/// * `surface` - The track surface condition (Dry, Wet, etc.)
/// * `new_record` - The new performance record to evaluate
///
/// # Algorithm
///
/// For each RQ level from car's RQ to MAX_RQ:
/// 1. **Better Time**: If new record is faster than current best, replace the list
/// 2. **Tie Time**: If new record matches current best time, add to list (if not duplicate)
/// 3. **Worse Time**: Ignore the new record
///
/// # Design Rationale
///
/// The RQ range iteration allows cars to compete at higher RQ levels than their base
/// rating, simulating how cars might perform when upgraded or in different matchups.
/// This provides more comprehensive analysis across the entire performance spectrum.
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();
// Check performance across RQ range from car's base RQ up to MAX_RQ
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 {
// New record is faster than current best
// Replace entire list - only keep the fastest car
*existing_list = vec![new_record.clone()];
} else if (new_record.time - best_time).abs() < TIME_TOLERANCE {
// Times are essentially equal (within tolerance)
// This is a tie - add the car only if not already present
// Prevents duplicate entries for the same car
if !existing_list
.iter()
.any(|r| r.car_rid == new_record.car_rid)
{
existing_list.push(new_record.clone());
}
}
// Note: If new_record.time > best_time, we ignore it
// The existing best car remains in the list
}
None => {
// First time we've seen this track/RQ/surface combination
// Initialize the list with this car as the first (and currently best) entry
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. This significantly improves performance
/// when processing large numbers of cars.
///
/// # Performance
///
/// Uses Tokio's spawn system with a semaphore to create one task per car,
/// enabling concurrent file I/O and JSON parsing operations while limiting
/// concurrency to 20 parallel tasks. The function waits for all tasks to
/// complete before returning.
///
/// # Note
///
/// This function is called by most analysis commands as it's the first step
/// in processing the timing data before generating reports.
///
/// # Returns
///
/// Returns the full car list so callers can reuse it without re-reading the file.
async fn parse_all_times(my_cars: &HashSet<String>, ctx: &AnalysisContext) -> Vec<Car> {
// Use Arc<HashSet> for efficient O(1) lookups across concurrent tasks
let my_cars = Arc::new(my_cars.clone());
let car_list = get_car_list();
let mut handles = vec![];
// Limit concurrent parsing to 20 to avoid excessive resource usage
let semaphore = Arc::new(Semaphore::new(20));
// Create one async task per car for concurrent processing
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);
}
// Wait for all parsing tasks to complete and report any failures
let results = join_all(handles).await;
for result in results {
if let Err(e) = result {
eprintln!("Warning: parsing task failed: {e}");
}
}
car_list
}
/// Fetches timing data files for all cars using controlled concurrency
///
/// Downloads JSON timing files for every car in the database while limiting
/// concurrent connections to avoid overwhelming the server. Uses a semaphore
/// to restrict simultaneous downloads to 20 concurrent requests.
///
/// # Rate Limiting
///
/// Uses a semaphore with a limit of 20 concurrent requests to be respectful
/// to the server and avoid being rate-limited or blocked.
///
/// # Performance
///
/// Balances download speed with server load by processing cars in parallel
/// but with controlled concurrency limits.
async fn fetch_times() {
// Limit concurrent downloads to 20 to avoid overwhelming the server
let semaphore = Arc::new(Semaphore::new(20));
let mut handles = vec![];
for car in get_car_list() {
let permit = semaphore.clone().acquire_owned().await.unwrap();
handles.push(tokio::spawn(async move {
fetch_car(car.rid).await;
drop(permit); // Release the semaphore permit
}));
}
// Wait for all downloads to complete
join_all(handles).await;
}
/// Downloads timing data for a single car from the remote API
///
/// Fetches the JSON timing file for a specific car and saves it locally.
/// Called by `fetch_times()` for each car in the database.
///
/// # Arguments
///
/// * `rid` - The unique car identifier used in the API URL
///
/// # File Storage
///
/// Files are saved in the `times/` directory with the format `{rid}.json`
/// where `rid` is the car's unique identifier.
///
/// # Logging
///
/// Prints progress information showing which car's data is being fetched.
async fn fetch_car(rid: 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
///
/// Reads and parses the `cars_repo.json` file to build a comprehensive
/// list of all cars available in the Top Drives database. This includes
/// all metadata needed for performance analysis.
///
/// # Returns
///
/// Returns a `Vec<Car>` containing all cars from the database with their
/// complete metadata including RID, brand, model, year, RQ, and prize status.
///
/// # File Source
///
/// * **File**: `cars_repo.json`
/// * **Format**: JSON array of car objects
/// * **Fields**: rid, brand, onlyName, year, rq, prize, guid
///
/// # Error Handling
///
/// Uses `unwrap()` for simplicity - in a production environment, proper
/// error handling should be implemented for missing or malformed data.
fn get_car_list() -> Vec<Car> {
let mut car_list: Vec<Car> = Vec::new();
// Read the cars database file
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 car_list;
}
};
let cars_data: Vec<Value> = match serde_json::from_str(&content) {
Ok(v) => v,
Err(e) => {
eprintln!("Warning: could not parse {cars_file}: {e}");
return car_list;
}
};
// Parse each car entry and build Car structures
for car in cars_data {
let new_car = Car {
rid: String::from(car["rid"].as_str().unwrap_or("No RID")),
brand: String::from(car["brand"].as_str().unwrap_or("No Brand")),
only_name: String::from(car["onlyName"].as_str().unwrap_or("No Name")),
year: car["year"].as_u64().unwrap_or(1900) as u16,
rq: car["rq"].as_u64().unwrap_or(200) as u8,
prize: car["prize"].as_bool().unwrap_or(false),
guid: String::from(car["guid"].as_str().unwrap_or("No GUID")),
};
car_list.push(new_car);
}
car_list
}
/// Downloads the tracks database file from the remote repository
///
/// Fetches the latest track definitions including track names, conditions,
/// and identifiers. This data is essential for translating internal track
/// codes to human-readable names in the analysis output.
///
/// # Remote Source
///
/// * **URL**: GitHub repository tracks database
/// * **Purpose**: Provides track name mappings and condition definitions
/// * **Update Frequency**: Should be fetched periodically for latest tracks
async fn get_tracks_file() {
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
///
/// Fetches the complete car database containing all car metadata including
/// identifiers, names, years, RQ levels, and prize status. This serves as
/// the foundation for all car-related analysis operations.
///
/// # Remote Source
///
/// * **URL**: GitHub repository cars database
/// * **Purpose**: Provides comprehensive car metadata for analysis
/// * **Update Frequency**: Should be fetched for latest car additions
async fn get_cars_file() {
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() {
// Test normal case
let result = "laguna_a00".split_once("_a");
assert_eq!(result, Some(("laguna", "00")));
// Test no delimiter
let result = "laguna".split_once("_a");
assert_eq!(result, None);
// Test multiple delimiters (should split at first occurrence)
let result = "test_a_data_a_more".split_once("_a");
assert_eq!(result, Some(("test", "_data_a_more")));
// Test empty strings
let result = "".split_once("_a");
assert_eq!(result, None);
}
#[test]
fn test_translate_track_name() {
// Test 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");
// Test unknown track (should return as-is)
assert_eq!(translate_track_name("unknown_track"), "unknown_track");
// Test drag strips
assert_eq!(translate_track_name("drag100"), "0-100mph");
assert_eq!(translate_track_name("mile4"), "1/4 Mile Drag");
}
#[test]
fn test_translate_track_type() {
// Test surface 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("11"), "Dirt Wet");
assert_eq!(translate_track_type("30"), "Ice");
assert_eq!(translate_track_type("60"), "Snow");
// Test unknown condition (should return as-is)
assert_eq!(translate_track_type("99"), "99");
}
#[test]
fn test_update_best_car_logic() {
// Verify test data is constructed correctly
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,
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 demonstrates expected behavior:
// - Better times should replace existing records
// - Tie times should be added to the list
// - Worse times should be ignored
}
#[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,
guid: "test_guid".to_string(),
};
// Test that car appears in RQ range from its base RQ to MAX_RQ
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", test_record);
let best_cars = ctx.best_cars.lock().unwrap();
// Should appear at RQ 85, 90, 95, ..., MAX_RQ
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() {
// Test demonstrates expected time validation behavior:
// - Times below 1.2 seconds should be filtered out
// - Valid times should be processed normally
// - This logic is tested indirectly through the parsing functions
let valid_time = 112.45; // Valid time
let invalid_time = 1.0; // Invalid time (too low)
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() {
// Test that we have a reasonable number of track mappings
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() {
// Test that we have mappings for all common condition codes
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() {
// Verify the time tolerance is 5ms as required
// We test this indirectly by verifying tie behavior
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,
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, // Within 0.005 tolerance
owned: 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, // Outside 0.005 tolerance
owned: 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();
// record1 and record2 should both be present (within tolerance)
// record3 should not be added (outside tolerance, and worse time)
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"));
}
}