Release v0.3.0: CSV output, wishlist commands, improved error handling

- Replace stdout output with structured CSV file generation
- Add wishlist, all commands with --min-count and --max-wishlist flags
- Introduce CarCounts struct for shared analysis computation
- Improve error handling: return Result instead of unwrap/panic
- Add prize field to BestCarRecord
- Remove TODO.md
This commit is contained in:
Jan-Peter von Hunnius 2026-05-07 20:13:30 +02:00
parent 08c96d9981
commit 9d57b18a4e
8 changed files with 697 additions and 594 deletions

View file

@ -8,12 +8,14 @@
//! - **`BestCarRecord` struct** — best performing car record for a specific track/RQ/condition
//! - **`TripletKey` type alias** — composite key `(track_id, rq, surface)`
//! - **`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`
//! - **`translate_track_name()`** — internal track code → human-readable name
//! - **`translate_track_type()`** — condition code → surface name
//! - **`get_file()`** — async file download utility
//! - **`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
use std::collections::{HashMap, HashSet};
use std::fs;
@ -64,6 +66,8 @@ pub struct BestCarRecord {
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,
}
@ -84,6 +88,7 @@ pub struct AnalysisContext {
impl AnalysisContext {
/// Create a new, empty analysis context.
#[allow(clippy::new_without_default)]
pub fn new() -> Self {
AnalysisContext {
best_cars: Arc::new(Mutex::new(HashMap::new())),
@ -91,6 +96,53 @@ impl AnalysisContext {
}
}
/// 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>) -> CarCounts {
let best_cars = ctx.best_cars.lock()
.expect("best_cars mutex poisoned — another thread panicked");
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
.entry(best_car.car_rid.clone())
.or_insert_with(|| (best_car.clone(), 0usize));
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();
}
// 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 }
}
/// Parsed user data from `my_cars.json`
///
/// Holds both the owned car GUIDs and wishlist GUIDs extracted from a single
@ -185,46 +237,22 @@ pub fn compare_cars(a: &Car, b: &Car) -> std::cmp::Ordering {
/// Downloads a file from a URL and saves it locally.
///
/// Generic utility function for fetching files from remote URLs. Used by
/// both `get_cars_file()` and `get_tracks_file()` to download the latest
/// database files from the repository.
///
/// # Arguments
///
/// * `file_url` - The remote URL to download from
/// * `local_file_path` - Local file path where the downloaded content should be saved
///
/// # Behavior
///
/// 1. Removes any existing file at the local path
/// 2. Downloads the file from the remote URL
/// 3. Saves the content to the local file path
/// 4. Reports any errors that occur during the process
///
/// # Error Handling
///
/// Prints error messages for network failures but does not panic, allowing
/// the application to continue with cached data if available.
pub async fn get_file(file_url: &str, local_file_path: &str) {
// Remove existing file to ensure fresh download
/// 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);
match get(file_url).await {
Ok(response) => {
// Download successful, save to local file
match response.text().await {
Ok(content) => {
if let Err(e) = fs::write(local_file_path, content) {
eprintln!("Error writing {local_file_path}: {e}");
}
fs::write(local_file_path, content)
.map_err(|e| format!("Error writing {local_file_path}: {e}"))
}
Err(e) => eprintln!("Error reading response from {file_url}: {e}"),
Err(e) => Err(format!("Error reading response from {file_url}: {e}")),
}
}
Err(error) => {
// Download failed, print error but don't panic
eprintln!("Failed to fetch the file. Status: {}", error.to_string());
}
Err(error) => Err(format!("Failed to fetch {file_url}: {error}")),
}
}