refactor: modular architecture with unified error handling and DashMap concurrency

- Break monolithic records.rs into commands/, output/, parsing/ modules
- Add lib.rs with shared types (AppError, Car, BestCarRecord, etc.)
- Replace Arc<Mutex<HashMap>> with DashMap for concurrent access
- Add unified AppError enum with thiserror
- Add configurable CLI paths (--my-cars, --cars-db, --times-dir, --tracks-db)
- Add serde deserialization for Car struct
- Add CSV escaping for fields with commas/quotes/newlines
- Fix track name splitting: split_once -> rsplit_once for _a handling
- Add integration tests, proptest property-based tests, and unit tests
- Add rustfmt.toml configuration
- Update documentation (API, ARCHITECTURE, DATA_STRUCTURES, README)
This commit is contained in:
Jan-Peter von Hunnius 2026-05-08 16:19:01 +02:00
parent 9d57b18a4e
commit a9a46ee551
24 changed files with 3541 additions and 1470 deletions

View file

@ -10,6 +10,7 @@
//! - **`AnalysisContext` struct** — holds mutable analysis state (replaces global static)
//! - **`CarCounts` struct** — aggregated per-car win counts
//! - **`MyCarsData` struct** — parsed user data from `my_cars.json`
//! - **`AppError` enum** — unified error type for the application
//! - **`translate_track_name()`** — internal track code → human-readable name
//! - **`translate_track_type()`** — condition code → surface name
//! - **`get_file()`** — async file download utility
@ -21,26 +22,63 @@ use std::collections::{HashMap, HashSet};
use std::fs;
use std::fs::File;
use std::io::BufReader;
use std::sync::{Arc, Mutex, OnceLock};
use std::sync::{Arc, OnceLock};
use dashmap::DashMap;
use reqwest::get;
use serde::Deserialize;
use serde_json::Value;
/// Custom error type for the Top Drives application.
///
/// Provides a unified error handling strategy across all modules,
/// replacing the mix of `Result<(), String>`, `std::io::Result`, and
/// silent `eprintln!` + early return patterns.
#[derive(Debug, thiserror::Error)]
pub enum AppError {
/// An I/O error occurred (file not found, permission denied, etc.)
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
/// A network error occurred during data fetching
#[error("Network error: {0}")]
Network(String),
/// A JSON parsing error in a specific file
#[error("Parse error in {file}: {reason}")]
Parse {
/// The file that failed to parse
file: String,
/// The reason for the parse failure
reason: String,
},
/// A required data file is missing
#[error("Missing data file: {0}")]
MissingFile(String),
/// An internal runtime error (e.g., semaphore closed, task join failure)
#[error("Internal error: {0}")]
Internal(String),
}
/// Core car information structure containing basic car metadata.
/// Used throughout the application to represent car entities.
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Deserialize)]
pub struct Car {
/// Unique car identifier used in the records database
pub rid: String,
/// Car manufacturer/brand name
pub brand: String,
/// Car model name (without brand)
#[serde(rename = "onlyName")]
pub only_name: String,
/// Car model year
pub year: u16,
/// Rarity level (10-113, affects performance brackets)
pub rq: u8,
/// Whether this car is only available as a prize car
#[serde(default)]
pub prize: bool,
/// Game-specific unique identifier for the car
pub guid: String,
@ -80,10 +118,13 @@ pub type TripletKey = (String, u8, String);
///
/// Replaces the former global `static BEST_CARS`. Created in `main()` and
/// passed through the call chain so that no global mutable state is needed.
///
/// Uses `Arc<DashMap>` for concurrent access from spawned tasks.
/// `DashMap` provides sharded internal locking for high-concurrency access.
pub struct AnalysisContext {
/// Best car records for each track/RQ/surface combination, protected by
/// `Arc<Mutex<...>>` for concurrent access from spawned tasks.
pub best_cars: Arc<Mutex<HashMap<TripletKey, Vec<BestCarRecord>>>>,
/// Best car records for each track/RQ/surface combination.
/// Wrapped in `Arc` so it can be shared across async tasks.
pub best_cars: Arc<DashMap<TripletKey, Vec<BestCarRecord>>>,
}
impl AnalysisContext {
@ -91,7 +132,7 @@ impl AnalysisContext {
#[allow(clippy::new_without_default)]
pub fn new() -> Self {
AnalysisContext {
best_cars: Arc::new(Mutex::new(HashMap::new())),
best_cars: Arc::new(DashMap::new()),
}
}
}
@ -112,21 +153,19 @@ pub struct CarCounts {
/// Computes per-car win counts from the analysis context.
/// Returns a `CarCounts` struct that can be shared across all output functions.
pub fn compute_car_counts(ctx: &AnalysisContext, car_list: Vec<Car>) -> CarCounts {
let best_cars = ctx.best_cars.lock()
.expect("best_cars mutex poisoned — another thread panicked");
pub fn compute_car_counts(ctx: &AnalysisContext, car_list: Vec<Car>) -> Result<CarCounts, AppError> {
let mut by_rid: HashMap<String, (BestCarRecord, usize)> = HashMap::new();
let mut guid_to_rid: HashMap<String, String> = HashMap::new();
for (_key, best_list) in best_cars.iter() {
for best_car in best_list {
let entry = by_rid
for entry in ctx.best_cars.iter() {
for best_car in entry.value() {
let rid_entry = by_rid
.entry(best_car.car_rid.clone())
.or_insert_with(|| (best_car.clone(), 0usize));
entry.1 += 1;
rid_entry.1 += 1;
// Keep the record with the best (lowest) time for this car.
if best_car.time < entry.0.time {
entry.0 = best_car.clone();
if best_car.time < rid_entry.0.time {
rid_entry.0 = best_car.clone();
}
// HashMap iteration order is non-deterministic, but car_rid and guid
// have a 1:1 relationship in the data model, so any mapping is correct.
@ -140,13 +179,14 @@ pub fn compute_car_counts(ctx: &AnalysisContext, car_list: Vec<Car>) -> CarCount
.map(|c| (c.guid.clone(), c.clone()))
.collect();
CarCounts { by_rid, guid_to_rid, car_map, car_list }
Ok(CarCounts { by_rid, guid_to_rid, car_map, car_list })
}
/// Parsed user data from `my_cars.json`
///
/// Holds both the owned car GUIDs and wishlist GUIDs extracted from a single
/// file read, avoiding duplicate I/O.
#[derive(Debug, Default)]
pub struct MyCarsData {
/// GUIDs of cars the user owns (from `playerDeck[].cardId` where `locked == true`)
pub owned: HashSet<String>,
@ -166,31 +206,19 @@ pub struct MyCarsData {
///
/// # Error Handling
///
/// Returns an empty `MyCarsData` (both sets empty) with a warning if the file
/// is missing or cannot be parsed, matching the graceful degradation pattern
/// used throughout the codebase.
pub fn load_my_cars_data() -> MyCarsData {
let file = match File::open("my_cars.json") {
Ok(f) => f,
Err(e) => {
eprintln!("Warning: could not open my_cars.json: {e}");
return MyCarsData {
owned: HashSet::new(),
wishlist: HashSet::new(),
};
/// Returns an `AppError` if the file cannot be opened or parsed.
/// Use `MyCarsData::default()` for graceful degradation when the file is optional.
pub fn load_my_cars_data(path: &str) -> Result<MyCarsData, AppError> {
let file = File::open(path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
AppError::MissingFile(format!("{path}: {e}"))
} else {
AppError::Io(e)
}
};
})?;
let reader = BufReader::new(file);
let json: Value = match serde_json::from_reader(reader) {
Ok(v) => v,
Err(e) => {
eprintln!("Warning: could not parse my_cars.json: {e}");
return MyCarsData {
owned: HashSet::new(),
wishlist: HashSet::new(),
};
}
};
let json: Value = serde_json::from_reader(reader)
.map_err(|e| AppError::Parse { file: path.to_string(), reason: e.to_string() })?;
// Extract owned car GUIDs from playerDeck
let owned: HashSet<String> = json
@ -223,7 +251,7 @@ pub fn load_my_cars_data() -> MyCarsData {
})
.unwrap_or_default();
MyCarsData { owned, wishlist }
Ok(MyCarsData { owned, wishlist })
}
/// Comparison function for sorting cars by RQ, then brand, then model name.
@ -238,21 +266,87 @@ pub fn compare_cars(a: &Car, b: &Car) -> std::cmp::Ordering {
/// Downloads a file from a URL and saves it locally.
///
/// Returns `Ok(())` on success, or an error describing what went wrong.
/// Removes any existing file at the local path before downloading.
pub async fn get_file(file_url: &str, local_file_path: &str) -> Result<(), String> {
let _ = fs::remove_file(local_file_path);
///
/// Writes to a temporary file first, then renames on success, so a failed
/// download never deletes the existing file.
///
/// Retries up to `MAX_FETCH_RETRIES` times on transient failures (server errors,
/// connection errors, or empty responses). Permanent HTTP errors (4xx) fail immediately.
pub async fn get_file(file_url: &str, local_file_path: &str) -> Result<(), AppError> {
/// Maximum number of retries when the server returns an empty response.
const MAX_FETCH_RETRIES: usize = 3;
match get(file_url).await {
Ok(response) => {
match response.text().await {
Ok(content) => {
fs::write(local_file_path, content)
.map_err(|e| format!("Error writing {local_file_path}: {e}"))
let tmp_path = format!("{local_file_path}.tmp");
let _ = fs::remove_file(&tmp_path); // clean stale temp file, OK to ignore
let mut attempt = 0usize;
loop {
match get(file_url).await {
Ok(response) => {
let status = response.status();
if !status.is_success() {
// Only retry on server errors (5xx); client errors (4xx) are permanent.
if status.is_server_error() && attempt < MAX_FETCH_RETRIES {
eprintln!(
"Warning: HTTP {} from {file_url} (attempt {}/{MAX_FETCH_RETRIES}), retrying...",
status,
attempt + 1,
);
tokio::time::sleep(
std::time::Duration::from_millis(500 * (attempt + 1) as u64)
).await;
attempt += 1;
continue;
}
return Err(AppError::Network(
format!("HTTP {status} from {file_url}")
));
}
Err(e) => Err(format!("Error reading response from {file_url}: {e}")),
match response.text().await {
Ok(content) => {
if content.is_empty() {
if attempt < MAX_FETCH_RETRIES {
eprintln!(
"Warning: empty response from {file_url} (attempt {}/{MAX_FETCH_RETRIES}), retrying...",
attempt + 1,
);
tokio::time::sleep(
std::time::Duration::from_millis(500 * (attempt + 1) as u64)
).await;
attempt += 1;
continue;
}
return Err(AppError::Network(
format!("Empty response from {file_url} after {MAX_FETCH_RETRIES} retries")
));
}
fs::write(&tmp_path, &content).map_err(AppError::Io)?;
return fs::rename(&tmp_path, local_file_path).map_err(AppError::Io);
}
Err(e) => {
return Err(AppError::Network(
format!("Error reading response from {file_url}: {e}")
));
}
}
}
Err(error) => {
if attempt < MAX_FETCH_RETRIES {
eprintln!(
"Warning: failed to fetch {file_url} (attempt {}/{MAX_FETCH_RETRIES}): {error}",
attempt + 1,
);
tokio::time::sleep(
std::time::Duration::from_millis(500 * (attempt + 1) as u64)
).await;
attempt += 1;
continue;
}
return Err(AppError::Network(
format!("Failed to fetch {file_url} after {MAX_FETCH_RETRIES} retries: {error}")
));
}
}
Err(error) => Err(format!("Failed to fetch {file_url}: {error}")),
}
}
@ -343,3 +437,217 @@ pub fn translate_track_name(track_code: &str) -> String {
.cloned()
.unwrap_or_else(|| track_code.to_string())
}
/// Escapes a field for safe inclusion in a CSV file.
///
/// If the field contains a comma, quote, newline, or carriage return,
/// it is wrapped in double-quotes with internal quotes doubled.
pub fn escape_csv_field(field: &str) -> String {
if field.contains(',') || field.contains('"') || field.contains('\n') || field.contains('\r') {
format!("\"{}\"", field.replace('"', "\"\""))
} else {
field.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_translate_track_name() {
assert_eq!(translate_track_name("laguna"), "Laguna Seca");
assert_eq!(translate_track_name("mnCity"), "Monaco Streets");
assert_eq!(translate_track_name("testBowl"), "Test Bowl");
assert_eq!(translate_track_name("unknown_track"), "unknown_track");
assert_eq!(translate_track_name("drag100"), "0-100mph");
assert_eq!(translate_track_name("mile4"), "1/4 Mile Drag");
}
#[test]
fn test_translate_track_type() {
assert_eq!(translate_track_type("00"), "Dry");
assert_eq!(translate_track_type("01"), "Wet");
assert_eq!(translate_track_type("10"), "Dirt");
assert_eq!(translate_track_type("11"), "Dirt Wet");
assert_eq!(translate_track_type("30"), "Ice");
assert_eq!(translate_track_type("60"), "Snow");
assert_eq!(translate_track_type("99"), "99");
}
#[test]
fn test_track_name_completeness() {
let test_tracks = vec![
"laguna",
"mnCity",
"testBowl",
"drag100",
"mile4",
"airplaneHangars",
"dealsGap",
"northloop",
];
for track in test_tracks {
let name = translate_track_name(track);
assert_ne!(name, track, "Track {} should have a proper name mapping", track);
assert!(!name.is_empty(), "Track name should not be empty");
}
}
#[test]
fn test_condition_completeness() {
let test_conditions = vec!["00", "01", "10", "11", "20", "30", "40", "41", "50", "60"];
for condition in test_conditions {
let name = translate_track_type(condition);
assert_ne!(name, condition, "Condition {} should have a proper name mapping", condition);
assert!(!name.is_empty(), "Condition name should not be empty");
}
}
#[test]
fn test_compare_cars_ordering() {
let car_a = Car {
rid: "A".to_string(), brand: "BMW".to_string(), only_name: "M3".to_string(),
year: 2020, rq: 80, prize: false, guid: "guid_a".to_string(),
};
let car_b = Car {
rid: "B".to_string(), brand: "Audi".to_string(), only_name: "RS6".to_string(),
year: 2021, rq: 90, prize: false, guid: "guid_b".to_string(),
};
let car_c = Car {
rid: "C".to_string(), brand: "BMW".to_string(), only_name: "M5".to_string(),
year: 2022, rq: 80, prize: false, guid: "guid_c".to_string(),
};
// Lower RQ sorts first
assert_eq!(compare_cars(&car_a, &car_b), std::cmp::Ordering::Less);
// Same RQ, same brand, then model alphabetically (M3 < M5)
assert_eq!(compare_cars(&car_a, &car_c), std::cmp::Ordering::Less);
// Higher RQ sorts last
assert_eq!(compare_cars(&car_b, &car_a), std::cmp::Ordering::Greater);
}
#[test]
fn test_compute_car_counts_basic() {
let ctx = AnalysisContext::new();
let record = BestCarRecord {
car_rid: "Test_Car".to_string(),
brand: "TestBrand".to_string(),
only_name: "TestModel".to_string(),
year: 2020,
rq: 85,
config: "111".to_string(),
time: 100.0,
owned: true,
prize: false,
guid: "guid_test".to_string(),
};
// Manually insert into best_cars
{
ctx.best_cars.insert(
("Laguna Seca".to_string(), 85, "Dry".to_string()),
vec![record.clone()],
);
ctx.best_cars.insert(
("Monaco Streets".to_string(), 85, "Dry".to_string()),
vec![record.clone()],
);
}
let car_list = vec![Car {
rid: "Test_Car".to_string(),
brand: "TestBrand".to_string(),
only_name: "TestModel".to_string(),
year: 2020,
rq: 85,
prize: false,
guid: "guid_test".to_string(),
}];
let counts = compute_car_counts(&ctx, car_list).unwrap();
// Should have exactly 1 entry in by_rid
assert_eq!(counts.by_rid.len(), 1);
let (rec, count) = counts.by_rid.get("Test_Car").unwrap();
assert_eq!(count, &2); // best on 2 tracks
assert_eq!(rec.car_rid, "Test_Car");
// guid_to_rid should map guid → rid
assert_eq!(counts.guid_to_rid.get("guid_test"), Some(&"Test_Car".to_string()));
// car_map should have the car
assert!(counts.car_map.contains_key("guid_test"));
assert_eq!(counts.car_list.len(), 1);
}
#[test]
fn test_compute_car_counts_keeps_best_time() {
let ctx = AnalysisContext::new();
let record_fast = BestCarRecord {
car_rid: "Car_A".to_string(),
brand: "Brand".to_string(),
only_name: "Model".to_string(),
year: 2020,
rq: 85,
config: "111".to_string(),
time: 90.0,
owned: false,
prize: false,
guid: "guid_a".to_string(),
};
let record_slow = BestCarRecord {
car_rid: "Car_A".to_string(),
brand: "Brand".to_string(),
only_name: "Model".to_string(),
year: 2020,
rq: 85,
config: "233".to_string(),
time: 110.0,
owned: false,
prize: false,
guid: "guid_a".to_string(),
};
{
ctx.best_cars.insert(
("Track1".to_string(), 85, "Dry".to_string()),
vec![record_fast],
);
ctx.best_cars.insert(
("Track2".to_string(), 85, "Dry".to_string()),
vec![record_slow],
);
}
let counts = compute_car_counts(&ctx, vec![]).unwrap();
let (rec, count) = counts.by_rid.get("Car_A").unwrap();
assert_eq!(*count, 2);
assert_eq!(rec.time, 90.0); // Should keep the faster time
}
#[test]
fn test_escape_csv_field() {
// Simple field — no escaping needed
assert_eq!(escape_csv_field("hello"), "hello");
// Field with comma
assert_eq!(escape_csv_field("hello,world"), "\"hello,world\"");
// Field with quote
assert_eq!(escape_csv_field("say \"hi\""), "\"say \"\"hi\"\"\"");
// Field with newline
assert_eq!(escape_csv_field("line1\nline2"), "\"line1\nline2\"");
}
#[test]
fn test_load_my_cars_data_missing_file() {
let result = load_my_cars_data("nonexistent_file.json");
assert!(result.is_err());
match result.unwrap_err() {
AppError::MissingFile(msg) => assert!(msg.contains("nonexistent_file.json")),
other => panic!("Expected MissingFile error, got: {other}"),
}
}
}