Refactor: extract lib.rs into focused sub-modules, add --output-dir CLI option

- Split monolithic src/lib.rs (~640 lines) into 7 sub-modules:
  types.rs, context.rs, user_data.rs, translate.rs, http.rs,
  csv_util.rs, test_utils.rs
- Add configurable --output-dir CLI option for CSV output files
- Implement atomic file writes (write-to-temp + rename) for all CSVs
- Change compute_car_counts() to return CarCounts directly (not Result)
- Add test-utils feature gate for shared test helpers
- Add HTTP mock tests with mockito (6 tests)
- Add proptest tests for compute_car_counts (3 property tests)
- Update integration tests to use shared test utilities
- Update documentation (API.md, ARCHITECTURE.md, DATA_STRUCTURES.md)
This commit is contained in:
Jan-Peter von Hunnius 2026-05-08 20:21:46 +02:00
parent d2c1418e5e
commit 67a79f190a
27 changed files with 1648 additions and 789 deletions

View file

@ -13,17 +13,18 @@ pub async fn run_all(
my_cars_path: &str,
cars_db_path: &str,
times_dir: &str,
output_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_best_csv(output_dir, &counts)?;
output::write_very_best_csv(output_dir, &counts, min_count)?;
output::write_trash_csv(output_dir, &counts, &my_data.owned)?;
output::write_details_csv(output_dir, &ctx)?;
output::write_wishlist_files(
&counts, &my_data.owned, &my_data.wishlist, min_count, max_wishlist,
output_dir, &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"

View file

@ -9,11 +9,11 @@ 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> {
pub async fn run_best(my_cars_path: &str, cars_db_path: &str, times_dir: &str, output_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)?;
write_best_csv(output_dir, &counts)?;
write_very_best_csv(output_dir, &counts, min_count)?;
println!("Written: best.csv, very_best.csv");
Ok(())
}

View file

@ -9,10 +9,10 @@ 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> {
pub async fn run_details(my_cars_path: &str, cars_db_path: &str, times_dir: &str, output_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)?;
write_details_csv(output_dir, &ctx)?;
println!("Written: details.csv");
Ok(())
}

View file

@ -58,6 +58,6 @@ pub async fn run_analysis(
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)?;
let counts = compute_car_counts(&ctx, car_list);
Ok((ctx, counts))
}

View file

@ -8,10 +8,15 @@ 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.
///
/// Note: `_output_dir` is unused because the track command prints to stdout
/// rather than writing a CSV file. It exists for API consistency with other
/// command signatures so the caller doesn't need per-command branching.
pub async fn run_track(
my_cars_path: &str,
cars_db_path: &str,
times_dir: &str,
_output_dir: &str,
track: &str,
condition: &str,
rq: u8,

View file

@ -8,10 +8,10 @@ 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> {
pub async fn run_trash(my_cars_path: &str, cars_db_path: &str, times_dir: &str, output_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)?;
write_trash_csv(output_dir, &counts, &my_data.owned)?;
println!("Written: trash.csv");
Ok(())
}

View file

@ -12,13 +12,14 @@ pub async fn run_wishlist(
my_cars_path: &str,
cars_db_path: &str,
times_dir: &str,
output_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,
output_dir, &counts, &my_data.owned, &my_data.wishlist, min_count, max_wishlist,
)?;
println!("Written: wishlist.csv, add.csv, remove.csv");
Ok(())

189
src/context.rs Normal file
View file

@ -0,0 +1,189 @@
//! Analysis context and aggregated car counts.
//!
//! Contains the mutable analysis state (`AnalysisContext`) and the
//! aggregated per-car win counts (`CarCounts`) computed from it.
use std::collections::HashMap;
use std::sync::Arc;
use dashmap::DashMap;
use crate::types::{BestCarRecord, Car, TripletKey};
/// Holds the mutable analysis state for a records analysis run.
///
/// 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.
/// `Arc::clone` only increments the reference count, so all tasks share
/// the same underlying map.
pub struct AnalysisContext {
/// Best car records for each track/RQ/surface combination.
/// Wrapped in `Arc` so it can be cheaply shared across async tasks.
pub best_cars: Arc<DashMap<TripletKey, Vec<BestCarRecord>>>,
}
impl Default for AnalysisContext {
fn default() -> Self {
Self::new()
}
}
impl AnalysisContext {
/// Create a new, empty analysis context.
pub fn new() -> Self {
AnalysisContext {
best_cars: Arc::new(DashMap::new()),
}
}
}
/// Aggregated per-car win counts across all track/RQ/condition combinations.
/// Computed once from `AnalysisContext` and shared across all output functions.
pub struct CarCounts {
/// Map from car_rid → (BestCarRecord, count of tracks where this car is best)
pub by_rid: HashMap<String, (BestCarRecord, usize)>,
/// Map from guid → car_rid (for O(1) ownership/wishlist lookups)
pub guid_to_rid: HashMap<String, String>,
/// Map from guid → Car (for O(1) lookups of car metadata by GUID).
/// Built once in `compute_car_counts()` and reused by trash/wishlist output.
pub car_map: HashMap<String, Car>,
/// Full car list from the database (kept for any callers that need iteration).
pub car_list: Vec<Car>,
}
/// Computes per-car win counts from the analysis context.
///
/// Returns a `CarCounts` struct that can be shared across all output functions.
/// This function cannot fail — it only performs `DashMap` iteration and
/// `HashMap` inserts.
pub fn compute_car_counts(ctx: &AnalysisContext, car_list: Vec<Car>) -> CarCounts {
let mut by_rid: HashMap<String, (BestCarRecord, usize)> = HashMap::new();
let mut guid_to_rid: HashMap<String, String> = HashMap::new();
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));
rid_entry.1 += 1;
// Keep the record with the best (lowest) time for this car.
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.
guid_to_rid.entry(best_car.guid.clone())
.or_insert_with(|| best_car.car_rid.clone());
}
}
// Build guid → Car lookup once, reused by trash/wishlist output functions.
let car_map: HashMap<String, Car> = car_list.iter()
.map(|c| (c.guid.clone(), c.clone()))
.collect();
CarCounts { by_rid, guid_to_rid, car_map, car_list }
}
#[cfg(test)]
mod tests {
use super::*;
#[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);
// 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![]);
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
}
}

66
src/csv_util.rs Normal file
View file

@ -0,0 +1,66 @@
//! CSV escaping and car comparison utilities.
//!
//! Provides shared helper functions used across multiple output modules.
use crate::types::Car;
/// 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()
}
}
/// Comparison function for sorting cars by RQ, then brand, then model name.
///
/// Used as a shared comparator to ensure consistent ordering across output functions.
pub fn compare_cars(a: &Car, b: &Car) -> std::cmp::Ordering {
a.rq.cmp(&b.rq)
.then(a.brand.cmp(&b.brand))
.then(a.only_name.cmp(&b.only_name))
}
#[cfg(test)]
mod tests {
use super::*;
#[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_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);
}
}

103
src/http.rs Normal file
View file

@ -0,0 +1,103 @@
//! HTTP file download utility with retry logic.
//!
//! Provides the [`get_file`] function for downloading files from remote URLs
//! with automatic retry on transient failures.
use std::fs;
use reqwest::get;
use crate::types::AppError;
/// Downloads a file from a URL and saves it locally.
///
/// Returns `Ok(())` on success, or an error describing what went wrong.
///
/// 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;
// Parse the URL once so malformed URLs are caught before any network I/O.
// Store as string so we avoid cloning a `Url` on each retry attempt.
let url = reqwest::Url::parse(file_url)
.map_err(|e| AppError::Network(format!("Invalid URL {file_url}: {e}")))?;
let url_str = url.as_str();
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(url_str).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}")
));
}
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}")
));
}
}
}
}

View file

@ -17,637 +17,33 @@
//! - **`load_my_cars_data()`** — loads and parses `my_cars.json`
//! - **`compare_cars()`** — sorting comparator for `Car`
//! - **`compute_car_counts()`** — aggregates per-car win counts from analysis context
//! - **`escape_csv_field()`** — CSV-safe field escaping
use std::collections::{HashMap, HashSet};
use std::fs;
use std::fs::File;
use std::io::BufReader;
use std::sync::{Arc, OnceLock};
// Sub-modules
pub mod context;
pub mod csv_util;
pub mod http;
pub mod translate;
pub mod types;
pub mod user_data;
use dashmap::DashMap;
use reqwest::get;
use serde::Deserialize;
use serde_json::Value;
/// Custom error type for the Top Drives application.
/// Shared test utilities.
///
/// 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, 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,
}
/// Represents the best performing car record for a specific track, RQ, and condition.
/// Contains all necessary information to compare car performance.
#[derive(Debug, Clone)]
pub struct BestCarRecord {
/// Car identifier from the records database
pub car_rid: String,
/// Car manufacturer/brand name
pub brand: String,
/// Car model name (without brand)
pub only_name: String,
/// Car model year
pub year: u16,
/// Rarity level of this specific car configuration
pub rq: u8,
/// Tune configuration string (e.g., "111", "233", "323")
pub config: String,
/// Track time in seconds (lower is better)
pub time: f64,
/// Whether the current user owns this car
pub owned: bool,
/// Whether this car is only available as a prize car
pub prize: bool,
/// Game-specific unique identifier for the car
pub guid: String,
}
/// Type alias for the composite key used to identify unique track conditions.
/// Consists of: (Track ID, RQ Level, Surface Condition)
pub type TripletKey = (String, u8, String);
/// Holds the mutable analysis state for a records analysis run.
/// Intended for use in unit and integration tests. Provides helper constructors
/// for [`BestCarRecord`] and [`Car`] to reduce boilerplate in test code.
///
/// 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.
/// Wrapped in `Arc` so it can be shared across async tasks.
pub best_cars: Arc<DashMap<TripletKey, Vec<BestCarRecord>>>,
}
/// Enabled by the `test-utils` feature flag. This keeps test helpers out of
/// the library's semver public API while remaining accessible to downstream
/// test crates via `top_drives = { features = ["test-utils"] }`.
#[cfg(feature = "test-utils")]
#[doc(hidden)]
pub mod test_utils;
impl AnalysisContext {
/// Create a new, empty analysis context.
#[allow(clippy::new_without_default)]
pub fn new() -> Self {
AnalysisContext {
best_cars: Arc::new(DashMap::new()),
}
}
}
/// Aggregated per-car win counts across all track/RQ/condition combinations.
/// Computed once from `AnalysisContext` and shared across all output functions.
pub struct CarCounts {
/// Map from car_rid → (BestCarRecord, count of tracks where this car is best)
pub by_rid: HashMap<String, (BestCarRecord, usize)>,
/// Map from guid → car_rid (for O(1) ownership/wishlist lookups)
pub guid_to_rid: HashMap<String, String>,
/// Map from guid → Car (for O(1) lookups of car metadata by GUID).
/// Built once in `compute_car_counts()` and reused by trash/wishlist output.
pub car_map: HashMap<String, Car>,
/// Full car list from the database (kept for any callers that need iteration).
pub car_list: Vec<Car>,
}
/// 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>) -> Result<CarCounts, AppError> {
let mut by_rid: HashMap<String, (BestCarRecord, usize)> = HashMap::new();
let mut guid_to_rid: HashMap<String, String> = HashMap::new();
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));
rid_entry.1 += 1;
// Keep the record with the best (lowest) time for this car.
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.
guid_to_rid.entry(best_car.guid.clone())
.or_insert_with(|| best_car.car_rid.clone());
}
}
// Build guid → Car lookup once, reused by trash/wishlist output functions.
let car_map: HashMap<String, Car> = car_list.iter()
.map(|c| (c.guid.clone(), c.clone()))
.collect();
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>,
/// GUIDs of wishlisted cars (from `wishlists[0].cards`)
pub wishlist: HashSet<String>,
}
/// Loads and parses `my_cars.json` once, extracting both owned and wishlist data.
///
/// Opens the game export file a single time and extracts:
/// - **Owned cars** from `playerDeck[].cardId` (filtered by `locked == true`)
/// - **Wishlist** from `wishlists[0].cards`
///
/// # File Source
///
/// * **File**: `my_cars.json` (exported from Top Drives game)
///
/// # Error Handling
///
/// Returns an `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 = 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
.get("playerDeck")
.and_then(|d| d.as_array())
.map(|deck| {
deck.iter()
.filter_map(|card| {
if card.get("locked").and_then(|v| v.as_bool()).unwrap_or(false) {
card.get("cardId").and_then(|v| v.as_str()).map(|s| s.to_string())
} else {
None
}
})
.collect()
})
.unwrap_or_default();
// Extract wishlist GUIDs from wishlists[0].cards
let wishlist: HashSet<String> = json
.get("wishlists")
.and_then(|w| w.get(0))
.and_then(|w| w.get("cards"))
.and_then(|c| c.as_array())
.map(|cards| {
cards
.iter()
.filter_map(|c| c.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
Ok(MyCarsData { owned, wishlist })
}
/// Comparison function for sorting cars by RQ, then brand, then model name.
///
/// Used as a shared comparator to ensure consistent ordering across output functions.
pub fn compare_cars(a: &Car, b: &Car) -> std::cmp::Ordering {
a.rq.cmp(&b.rq)
.then(a.brand.cmp(&b.brand))
.then(a.only_name.cmp(&b.only_name))
}
/// Downloads a file from a URL and saves it locally.
///
/// Returns `Ok(())` on success, or an error describing what went wrong.
///
/// 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;
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}")
));
}
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}")
));
}
}
}
}
/// Translates track condition codes to human-readable surface condition names.
///
/// Converts internal track condition codes (like "00", "01", "10") to descriptive
/// names that represent the actual racing surface conditions in Top Drives.
///
/// # Arguments
///
/// * `track_code` - The internal condition code from the timing data
///
/// # Returns
///
/// Returns a human-readable string describing the track surface condition:
/// - "00" → "Dry" (standard asphalt)
/// - "01" → "Wet" (rain-affected)
/// - "10" → "Dirt" (off-road conditions)
/// - And many more specialized conditions
///
/// # Coverage
///
/// Supports all known track condition codes used in the Top Drives game,
/// including special combinations like "Asphalt Dirt Wet" and "Snow Dirt Wet".
pub fn translate_track_type(track_code: &str) -> &str {
match track_code {
"00" => "Dry",
"01" => "Wet",
"10" => "Dirt",
"11" => "Dirt Wet",
"20" => "Gravel",
"30" => "Ice",
"40" => "Asphalt Dirt",
"41" => "Asphalt Dirt Wet",
"50" => "Sand",
"60" => "Snow",
"70" => "Grass",
"71" => "Grass Wet",
"b0" => "Asphalt Gravel",
"c0" => "Asphalt Sand",
"c1" => "Asphalt Sand Wet",
"d0" => "Asphalt Snow",
"e0" => "Sand Dirt",
"f0" => "Asphalt Grass Dirt",
"g0" => "Ice Snow",
"h1" => "Snow Dirt Wet",
_ => track_code,
}
}
/// Translates internal track identifiers to human-readable track names.
///
/// Converts compact internal track codes (like "laguna", "monaco", "testBowl")
/// to their full, descriptive names that appear in the Top Drives game interface.
///
/// # Arguments
///
/// * `track_code` - The internal track identifier from the timing data
///
/// # Returns
///
/// Returns the full track name as displayed in the game:
/// - "laguna" → "Laguna Seca"
/// - "monaco" → "Monaco Streets"
/// - "testBowl" → "Test Bowl"
/// - And hundreds more track mappings
///
/// # Coverage
///
/// Contains mappings for all known tracks in the Top Drives game, including
/// special tracks like drag strips, rally stages, and circuit races.
///
/// # Note
///
/// If an unknown track code is provided, the original code is returned
/// unchanged, allowing the system to gracefully handle new tracks.
pub fn translate_track_name(track_code: &str) -> String {
static TRACK_NAMES: OnceLock<HashMap<String, String>> = OnceLock::new();
let map = TRACK_NAMES.get_or_init(|| {
let json_str = include_str!("../track_names.json");
let raw: HashMap<String, String> =
serde_json::from_str(json_str).expect("track_names.json should be valid JSON");
raw
});
map.get(track_code)
.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}"),
}
}
}
// Re-export all public types and functions for backward compatibility.
// External code can use `top_drives::Car` etc. without knowing the internal module layout.
pub use context::{AnalysisContext, CarCounts, compute_car_counts};
pub use csv_util::{compare_cars, escape_csv_field};
pub use http::get_file;
pub use translate::{translate_track_name, translate_track_type};
pub use types::{AppError, BestCarRecord, Car, TripletKey};
pub use user_data::{MyCarsData, load_my_cars_data};

View file

@ -6,9 +6,18 @@
use std::collections::HashSet;
use std::fs;
use std::io::Write;
use std::path::PathBuf;
use top_drives::{BestCarRecord, CarCounts, TripletKey, AnalysisContext, AppError, compare_cars, escape_csv_field};
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/// Minimum RQ level for keeping a non-best car. Cars below this RQ that are
/// not best on any track and are not prize cars are considered "trash".
const TRASH_MIN_RQ: u8 = 80;
// ---------------------------------------------------------------------------
// Sorting helpers
// ---------------------------------------------------------------------------
@ -23,6 +32,33 @@ pub fn compare_records(a: &BestCarRecord, b: &BestCarRecord) -> std::cmp::Orderi
.then(a.only_name.cmp(&b.only_name))
}
// ---------------------------------------------------------------------------
// Atomic write helper
// ---------------------------------------------------------------------------
/// Writes to a temporary file first, then renames on success.
///
/// If the write callback returns an error, the temporary file is cleaned up
/// and no partial file is left at the target path.
fn atomic_write(
path: &PathBuf,
write_fn: impl FnOnce(&mut fs::File) -> Result<(), AppError>,
) -> Result<(), AppError> {
let tmp_path = PathBuf::from(format!("{}.tmp", path.display()));
let _ = fs::remove_file(&tmp_path); // clean stale temp file
let result: Result<(), AppError> = (|| {
let mut file = fs::File::create(&tmp_path).map_err(AppError::Io)?;
write_fn(&mut file)?;
drop(file); // ensure file is closed before rename
fs::rename(&tmp_path, path).map_err(AppError::Io)
})();
if result.is_err() {
let _ = fs::remove_file(&tmp_path); // clean up temp file on failure
}
result
}
// ---------------------------------------------------------------------------
// CSV output helpers
// ---------------------------------------------------------------------------
@ -32,8 +68,12 @@ pub fn compare_records(a: &BestCarRecord, b: &BestCarRecord) -> std::cmp::Orderi
/// 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.
///
/// Uses [`atomic_write`] internally so a failed write never leaves a partially-written
/// CSV file.
pub fn write_sorted_csv<T, G>(
path: &str,
output_dir: &str,
filename: &str,
header: &str,
entries: &[T],
cmp: impl Fn(&T, &T) -> std::cmp::Ordering,
@ -45,12 +85,14 @@ where
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(())
let path = PathBuf::from(output_dir).join(filename);
atomic_write(&path, |file| {
writeln!(file, "{header}")?;
for i in indices {
format_row(&entries[i], file)?;
}
Ok(())
})
}
/// Formats a `(BestCarRecord, usize)` row as `RQ,Brand,Model,Year,Count`.
@ -75,9 +117,10 @@ pub fn format_counted_row(entry: &(BestCarRecord, usize), file: &mut fs::File) -
/// 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> {
pub fn write_best_csv(output_dir: &str, counts: &CarCounts) -> Result<(), AppError> {
let entries: Vec<_> = counts.by_rid.values().collect();
write_sorted_csv(
output_dir,
"best.csv",
"RQ,Brand,Model,Year,Count",
&entries,
@ -88,11 +131,12 @@ pub fn write_best_csv(counts: &CarCounts) -> Result<(), AppError> {
/// 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> {
pub fn write_very_best_csv(output_dir: &str, counts: &CarCounts, min_count: usize) -> Result<(), AppError> {
let entries: Vec<_> = counts.by_rid.values()
.filter(|(_, count)| *count >= min_count)
.collect();
write_sorted_csv(
output_dir,
"very_best.csv",
"RQ,Brand,Model,Year,Count",
&entries,
@ -105,16 +149,17 @@ pub fn write_very_best_csv(counts: &CarCounts, min_count: usize) -> Result<(), A
// CSV output: trash
// ---------------------------------------------------------------------------
/// Writes trash.csv — owned cars not best on any track, RQ < 80, not prize.
/// Writes trash.csv — owned cars not best on any track, RQ < TRASH_MIN_RQ, not prize.
/// Format: RQ,Brand,Model,Year
pub fn write_trash_csv(counts: &CarCounts, owned: &HashSet<String>) -> Result<(), AppError> {
pub fn write_trash_csv(output_dir: &str, 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)
.filter(|car| !car.prize && car.rq < TRASH_MIN_RQ)
.collect();
write_sorted_csv(
output_dir,
"trash.csv",
"RQ,Brand,Model,Year",
&trash,
@ -145,7 +190,7 @@ pub fn write_trash_csv(counts: &CarCounts, owned: &HashSet<String>) -> Result<()
/// 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> {
pub fn write_details_csv(output_dir: &str, ctx: &AnalysisContext) -> Result<(), AppError> {
// Collect data from DashMap — no global lock needed
let mut sorted_data: Vec<(TripletKey, Vec<BestCarRecord>)> = ctx
.best_cars
@ -156,26 +201,28 @@ pub fn write_details_csv(ctx: &AnalysisContext) -> Result<(), AppError> {
// 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
)?;
let path = PathBuf::from(output_dir).join("details.csv");
atomic_write(&path, |file| {
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(())
Ok(())
})
}
// ---------------------------------------------------------------------------
@ -203,6 +250,7 @@ pub fn compute_ideal_wishlist(
/// Computes the ideal wishlist once and derives all three output files from it,
/// avoiding redundant computation.
pub fn write_wishlist_files(
output_dir: &str,
counts: &CarCounts,
owned: &HashSet<String>,
wishlist: &HashSet<String>,
@ -214,6 +262,7 @@ pub fn write_wishlist_files(
// --- wishlist.csv ---
write_sorted_csv(
output_dir,
"wishlist.csv",
"RQ,Brand,Model,Year,Count",
&ideal,
@ -227,6 +276,7 @@ pub fn write_wishlist_files(
.collect();
write_sorted_csv(
output_dir,
"add.csv",
"RQ,Brand,Model,Year,Count",
&to_add,
@ -255,6 +305,7 @@ pub fn write_wishlist_files(
}
write_sorted_csv(
output_dir,
"remove.csv",
"RQ,Brand,Model,Year,Count",
&to_remove,
@ -314,35 +365,7 @@ 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(),
}
}
use top_drives::test_utils::{make_record, make_car};
// =======================================================================
// compute_ideal_wishlist tests
@ -363,7 +386,7 @@ mod tests {
}
let car_list = vec![make_car("Car_A", 85, "guid_a")];
let counts = compute_car_counts(&ctx, car_list).unwrap();
let counts = compute_car_counts(&ctx, car_list);
let owned: HashSet<String> = HashSet::new();
let wishlist = compute_ideal_wishlist(&counts, &owned, 10, 300);
@ -388,7 +411,7 @@ mod tests {
}
let car_list = vec![make_car("Car_A", 85, "guid_a")];
let counts = compute_car_counts(&ctx, car_list).unwrap();
let counts = compute_car_counts(&ctx, car_list);
let owned: HashSet<String> = vec!["guid_a".to_string()].into_iter().collect();
let wishlist = compute_ideal_wishlist(&counts, &owned, 10, 300);
@ -420,7 +443,7 @@ mod tests {
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 counts = compute_car_counts(&ctx, car_list);
let owned: HashSet<String> = HashSet::new();
let wishlist = compute_ideal_wishlist(&counts, &owned, 10, 3);
@ -435,19 +458,20 @@ mod 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 output_dir = dir.path().to_str().unwrap();
let entries = vec![("beta", 2), ("alpha", 1), ("gamma", 3)];
write_sorted_csv(
path_str,
output_dir,
"test.csv",
"Name,Value",
&entries,
|a, b| a.0.cmp(b.0),
|entry, file| writeln!(file, "{},{}", entry.0, entry.1),
).unwrap();
let path = dir.path().join("test.csv");
let content = fs::read_to_string(path).unwrap();
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines[0], "Name,Value");
@ -473,17 +497,18 @@ mod tests {
let entry = (rec, 5);
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.csv");
let path_str = path.to_str().unwrap();
let output_dir = dir.path().to_str().unwrap();
write_sorted_csv(
path_str,
output_dir,
"test.csv",
"RQ,Brand,Model,Year,Count",
&[entry],
|_, _| std::cmp::Ordering::Equal,
|e, file| format_counted_row(e, file),
).unwrap();
let path = dir.path().join("test.csv");
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");
@ -513,7 +538,7 @@ mod tests {
car_d, // not best, prize car — not trash
];
let counts = compute_car_counts(&ctx, car_list).unwrap();
let counts = compute_car_counts(&ctx, car_list);
let owned: HashSet<String> = vec![
"guid_a".to_string(),
@ -526,7 +551,7 @@ mod tests {
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)
.filter(|car| !car.prize && car.rq < TRASH_MIN_RQ)
.collect();
assert_eq!(trash.len(), 1, "Only Car_B should be trash");
@ -581,7 +606,7 @@ mod tests {
#[test]
fn test_empty_analysis_context() {
let ctx = AnalysisContext::new();
let counts = compute_car_counts(&ctx, vec![]).unwrap();
let counts = compute_car_counts(&ctx, vec![]);
assert!(counts.by_rid.is_empty());
assert!(counts.guid_to_rid.is_empty());
assert!(counts.car_map.is_empty());
@ -599,7 +624,7 @@ mod tests {
);
let car = make_car("Only_Car", 90, "guid_only");
let counts = compute_car_counts(&ctx, vec![car]).unwrap();
let counts = compute_car_counts(&ctx, vec![car]);
assert_eq!(counts.by_rid.len(), 1);
let (rec, count) = counts.by_rid.get("Only_Car").unwrap();
@ -620,7 +645,7 @@ mod tests {
}
let car = make_car("Car_A", 85, "guid_a");
let counts = compute_car_counts(&ctx, vec![car]).unwrap();
let counts = compute_car_counts(&ctx, vec![car]);
let all_owned: HashSet<String> = vec!["guid_a".to_string()].into_iter().collect();
let wishlist = compute_ideal_wishlist(&counts, &all_owned, 10, 300);
@ -640,7 +665,7 @@ mod tests {
}
let car = make_car("Car_A", 85, "guid_a");
let counts = compute_car_counts(&ctx, vec![car]).unwrap();
let counts = compute_car_counts(&ctx, vec![car]);
let no_owned: HashSet<String> = HashSet::new();
let wishlist = compute_ideal_wishlist(&counts, &no_owned, 10, 300);

View file

@ -364,22 +364,7 @@ async fn fetch_car(rid: &str, times_dir: &str) -> Result<(), AppError> {
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(),
}
}
use top_drives::test_utils::make_record;
// =======================================================================
// update_best_car tests

View file

@ -58,6 +58,9 @@ const DEFAULT_TIMES_DIR: &str = "times";
/// Default path for the tracks database file.
const DEFAULT_TRACKS_DB_PATH: &str = "tracks_repo.json";
/// Default directory for output CSV files.
const DEFAULT_OUTPUT_DIR: &str = ".";
// ---------------------------------------------------------------------------
// CLI definition
// ---------------------------------------------------------------------------
@ -82,6 +85,10 @@ struct Cli {
#[arg(long, default_value = DEFAULT_TRACKS_DB_PATH)]
tracks_db: String,
/// Directory for output CSV files
#[arg(long, default_value = DEFAULT_OUTPUT_DIR)]
output_dir: String,
#[command(subcommand)]
command: Commands,
}
@ -154,32 +161,37 @@ async fn main() {
async fn run() -> Result<(), AppError> {
let cli = Cli::parse();
// Ensure the output directory exists before dispatching any command.
// create_dir_all(".") is a no-op, so no special-casing needed.
std::fs::create_dir_all(&cli.output_dir)?;
match cli.command {
Commands::Fetch => {
commands::fetch::run_fetch(&cli.cars_db, &cli.tracks_db, &cli.times_dir).await?;
}
Commands::Best { min_count } => {
commands::best::run_best(&cli.my_cars, &cli.cars_db, &cli.times_dir, min_count).await?;
commands::best::run_best(&cli.my_cars, &cli.cars_db, &cli.times_dir, &cli.output_dir, min_count).await?;
}
Commands::Trash => {
commands::trash::run_trash(&cli.my_cars, &cli.cars_db, &cli.times_dir).await?;
commands::trash::run_trash(&cli.my_cars, &cli.cars_db, &cli.times_dir, &cli.output_dir).await?;
}
Commands::Wishlist { min_count, max_wishlist } => {
commands::wishlist::run_wishlist(
&cli.my_cars, &cli.cars_db, &cli.times_dir, min_count, max_wishlist,
&cli.my_cars, &cli.cars_db, &cli.times_dir, &cli.output_dir, min_count, max_wishlist,
).await?;
}
Commands::All { min_count, max_wishlist } => {
commands::all::run_all(
&cli.my_cars, &cli.cars_db, &cli.times_dir, min_count, max_wishlist,
&cli.my_cars, &cli.cars_db, &cli.times_dir, &cli.output_dir, min_count, max_wishlist,
).await?;
}
Commands::Details => {
commands::details::run_details(&cli.my_cars, &cli.cars_db, &cli.times_dir).await?;
commands::details::run_details(&cli.my_cars, &cli.cars_db, &cli.times_dir, &cli.output_dir).await?;
}
Commands::Track { track, condition, rq } => {
commands::track::run_track(
&cli.my_cars, &cli.cars_db, &cli.times_dir, &track, &condition, rq,
&cli.my_cars, &cli.cars_db, &cli.times_dir, &cli.output_dir,
&track, &condition, rq,
).await?;
}
}

41
src/test_utils.rs Normal file
View file

@ -0,0 +1,41 @@
//! Shared test utilities for unit and integration tests.
//!
//! Provides helper constructors for [`BestCarRecord`] and [`Car`] to avoid
//! duplication across test modules.
use crate::{BestCarRecord, Car};
/// Creates a test [`BestCarRecord`] with sensible defaults.
///
/// All string fields use `"TestBrand"` / `"TestModel"` / `"111"` defaults.
/// Only the distinguishing fields (`car_rid`, `rq`, `time`, `guid`) are parameterised.
pub 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(),
}
}
/// Creates a test [`Car`] with sensible defaults.
///
/// All string fields use `"TestBrand"` / `"TestModel"` defaults.
/// Only the distinguishing fields (`rid`, `rq`, `guid`) are parameterised.
pub 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(),
}
}

152
src/translate.rs Normal file
View file

@ -0,0 +1,152 @@
//! Track name and condition code translation.
//!
//! Converts internal game codes to human-readable track names and
//! surface condition descriptions.
use std::collections::HashMap;
use std::sync::OnceLock;
/// Translates track condition codes to human-readable surface condition names.
///
/// Converts internal track condition codes (like "00", "01", "10") to descriptive
/// names that represent the actual racing surface conditions in Top Drives.
///
/// # Arguments
///
/// * `track_code` - The internal condition code from the timing data
///
/// # Returns
///
/// Returns a human-readable string describing the track surface condition:
/// - "00" → "Dry" (standard asphalt)
/// - "01" → "Wet" (rain-affected)
/// - "10" → "Dirt" (off-road conditions)
/// - And many more specialized conditions
///
/// # Coverage
///
/// Supports all known track condition codes used in the Top Drives game,
/// including special combinations like "Asphalt Dirt Wet" and "Snow Dirt Wet".
pub fn translate_track_type(track_code: &str) -> &str {
match track_code {
"00" => "Dry",
"01" => "Wet",
"10" => "Dirt",
"11" => "Dirt Wet",
"20" => "Gravel",
"30" => "Ice",
"40" => "Asphalt Dirt",
"41" => "Asphalt Dirt Wet",
"50" => "Sand",
"60" => "Snow",
"70" => "Grass",
"71" => "Grass Wet",
"b0" => "Asphalt Gravel",
"c0" => "Asphalt Sand",
"c1" => "Asphalt Sand Wet",
"d0" => "Asphalt Snow",
"e0" => "Sand Dirt",
"f0" => "Asphalt Grass Dirt",
"g0" => "Ice Snow",
"h1" => "Snow Dirt Wet",
_ => track_code,
}
}
/// Translates internal track identifiers to human-readable track names.
///
/// Converts compact internal track codes (like "laguna", "monaco", "testBowl")
/// to their full, descriptive names that appear in the Top Drives game interface.
///
/// # Arguments
///
/// * `track_code` - The internal track identifier from the timing data
///
/// # Returns
///
/// Returns the full track name as displayed in the game:
/// - "laguna" → "Laguna Seca"
/// - "monaco" → "Monaco Streets"
/// - "testBowl" → "Test Bowl"
/// - And hundreds more track mappings
///
/// # Coverage
///
/// Contains mappings for all known tracks in the Top Drives game, including
/// special tracks like drag strips, rally stages, and circuit races.
///
/// # Note
///
/// If an unknown track code is provided, the original code is returned
/// unchanged, allowing the system to gracefully handle new tracks.
pub fn translate_track_name(track_code: &str) -> String {
static TRACK_NAMES: OnceLock<HashMap<String, String>> = OnceLock::new();
let map = TRACK_NAMES.get_or_init(|| {
let json_str = include_str!("../track_names.json");
let raw: HashMap<String, String> =
serde_json::from_str(json_str).expect("track_names.json should be valid JSON");
raw
});
map.get(track_code)
.cloned()
.unwrap_or_else(|| track_code.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");
}
}
}

91
src/types.rs Normal file
View file

@ -0,0 +1,91 @@
//! Core data types and error definitions.
//!
//! Contains the fundamental types shared across the entire application:
//! car records, error types, and composite key definitions.
use serde::Deserialize;
/// 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, 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,
}
/// Represents the best performing car record for a specific track, RQ, and condition.
/// Contains all necessary information to compare car performance.
#[derive(Debug, Clone)]
pub struct BestCarRecord {
/// Car identifier from the records database
pub car_rid: String,
/// Car manufacturer/brand name
pub brand: String,
/// Car model name (without brand)
pub only_name: String,
/// Car model year
pub year: u16,
/// Rarity level of this specific car configuration
pub rq: u8,
/// Tune configuration string (e.g., "111", "233", "323")
pub config: String,
/// Track time in seconds (lower is better)
pub time: f64,
/// Whether the current user owns this car
pub owned: bool,
/// Whether this car is only available as a prize car
pub prize: bool,
/// Game-specific unique identifier for the car
pub guid: String,
}
/// Type alias for the composite key used to identify unique track conditions.
/// Consists of: (Track ID, RQ Level, Surface Condition)
pub type TripletKey = (String, u8, String);

99
src/user_data.rs Normal file
View file

@ -0,0 +1,99 @@
//! User data loading from `my_cars.json`.
//!
//! Handles parsing the game export file to extract owned car GUIDs
//! and wishlist entries.
use std::collections::HashSet;
use std::fs::File;
use std::io::BufReader;
use serde_json::Value;
use crate::types::AppError;
/// 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>,
/// GUIDs of wishlisted cars (from `wishlists[0].cards`)
pub wishlist: HashSet<String>,
}
/// Loads and parses `my_cars.json` once, extracting both owned and wishlist data.
///
/// Opens the game export file a single time and extracts:
/// - **Owned cars** from `playerDeck[].cardId` (filtered by `locked == true`)
/// - **Wishlist** from `wishlists[0].cards`
///
/// # File Source
///
/// * **File**: `my_cars.json` (exported from Top Drives game)
///
/// # Error Handling
///
/// Returns an `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 = 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
.get("playerDeck")
.and_then(|d| d.as_array())
.map(|deck| {
deck.iter()
.filter_map(|card| {
if card.get("locked").and_then(|v| v.as_bool()).unwrap_or(false) {
card.get("cardId").and_then(|v| v.as_str()).map(|s| s.to_string())
} else {
None
}
})
.collect()
})
.unwrap_or_default();
// Extract wishlist GUIDs from wishlists[0].cards
let wishlist: HashSet<String> = json
.get("wishlists")
.and_then(|w| w.get(0))
.and_then(|w| w.get("cards"))
.and_then(|c| c.as_array())
.map(|cards| {
cards
.iter()
.filter_map(|c| c.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
Ok(MyCarsData { owned, wishlist })
}
#[cfg(test)]
mod tests {
use super::*;
#[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}"),
}
}
}