feat: add normalized config tracking and improve command

- Add normalize_to_final_build() to map tune configs to final builds (323/233/332)
- Add normalized_config field to BestCarRecord
- Change CarCounts.by_rid key from String to (String, String) for per-config tracking
- Change CarCounts.guid_to_rid to HashMap<String, HashSet<(String, String)>>
- Extract owned tune configs from my_cars.json (engineMajor/weightMajor/chassisMajor)
- Add improve command: owned cars not yet at final build, sorted by track count
- Strip version prefixes (v2c_/v1c_) from config keys at parse time
- Update dedup check in update_best_car to consider normalized_config
- Add atomic_write helper for safe CSV output
- Delete completed plans/code-review-v2.md
- Apply rustfmt formatting across codebase
This commit is contained in:
Jan-Peter von Hunnius 2026-05-11 16:23:04 +02:00
parent 67a79f190a
commit bf21d3d1f6
25 changed files with 735 additions and 759 deletions

View file

@ -1,7 +1,7 @@
//! All command implementation.
//!
//! Generates all output files in a single pass: best.csv, very_best.csv,
//! trash.csv, details.csv, wishlist.csv, add.csv, and remove.csv.
//! trash.csv, details.csv, wishlist.csv, add.csv, remove.csv, and improve.csv.
use top_drives::AppError;
@ -18,16 +18,22 @@ pub async fn run_all(
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?;
let (ctx, counts) = run_analysis(&my_data, cars_db_path, times_dir).await?;
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(
output_dir, &counts, &my_data.owned, &my_data.wishlist, min_count, max_wishlist,
output_dir,
&counts,
&my_data.owned,
&my_data.wishlist,
min_count,
max_wishlist,
)?;
output::write_improve_csv(output_dir, &counts)?;
println!(
"Written: best.csv, very_best.csv, trash.csv, details.csv, wishlist.csv, add.csv, remove.csv"
"Written: best.csv, very_best.csv, trash.csv, details.csv, wishlist.csv, add.csv, remove.csv, improve.csv"
);
Ok(())
}

View file

@ -9,9 +9,15 @@ 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, output_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?;
let (_ctx, counts) = run_analysis(&my_data, cars_db_path, times_dir).await?;
write_best_csv(output_dir, &counts)?;
write_very_best_csv(output_dir, &counts, min_count)?;
println!("Written: best.csv, very_best.csv");

View file

@ -9,9 +9,14 @@ 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, output_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?;
let (ctx, _counts) = run_analysis(&my_data, cars_db_path, times_dir).await?;
write_details_csv(output_dir, &ctx)?;
println!("Written: details.csv");
Ok(())

View file

@ -3,7 +3,7 @@
//! Downloads car database, tracks database, and per-car timing data
//! from remote repositories.
use top_drives::{AppError, get_file};
use top_drives::{get_file, AppError};
use crate::parsing::fetch_times;
@ -39,7 +39,11 @@ pub async fn get_cars_file(cars_db_path: &str) -> Result<(), AppError> {
/// Downloads the car database, tracks database, and per-car timing files.
/// Fails fast on critical dependency failures — the cars DB must be available
/// before per-car timing files can be fetched.
pub async fn run_fetch(cars_db_path: &str, tracks_db_path: &str, times_dir: &str) -> Result<(), AppError> {
pub async fn run_fetch(
cars_db_path: &str,
tracks_db_path: &str,
times_dir: &str,
) -> Result<(), AppError> {
get_cars_file(cars_db_path).await?;
get_tracks_file(tracks_db_path).await?;
fetch_times(cars_db_path, times_dir).await?;

27
src/commands/improve.rs Normal file
View file

@ -0,0 +1,27 @@
//! Improve command implementation.
//!
//! Generates improve.csv — owned cars not yet in a final configuration,
//! sorted by number of best tracks (descending).
use top_drives::AppError;
use crate::commands::{load_my_cars_or_warn, run_analysis};
use crate::output::write_improve_csv;
/// Executes the improve command: generates improve.csv.
pub async fn run_improve(
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, cars_db_path, times_dir).await?;
let count = write_improve_csv(output_dir, &counts)?;
if count == 0 {
println!("No cars to improve — all owned cars are already in final or maxed configs.");
} else {
println!("Written: improve.csv ({count} cars to improve)");
}
Ok(())
}

View file

@ -7,16 +7,15 @@ pub mod all;
pub mod best;
pub mod details;
pub mod fetch;
pub mod improve;
pub mod track;
pub mod trash;
pub mod wishlist;
use std::collections::HashSet;
use std::sync::Arc;
use top_drives::{
AppError, AnalysisContext, CarCounts, MyCarsData,
compute_car_counts, load_my_cars_data,
compute_car_counts, load_my_cars_data, AnalysisContext, AppError, CarCounts, MyCarsData,
};
use crate::parsing::parse_all_times;
@ -51,13 +50,14 @@ pub fn load_my_cars_or_warn(path: &str) -> Result<MyCarsData, AppError> {
/// Returns the analysis context (for stdout commands) and the aggregated car counts
/// (for CSV output commands). The car list is stored inside `CarCounts`.
pub async fn run_analysis(
owned: &HashSet<String>,
my_data: &MyCarsData,
cars_db_path: &str,
times_dir: &str,
) -> Result<(AnalysisContext, CarCounts), AppError> {
let ctx = AnalysisContext::new();
let owned = Arc::new(owned.clone());
let owned = Arc::new(my_data.owned.clone());
let car_list = parse_all_times(owned, &ctx, cars_db_path, times_dir).await?;
let counts = compute_car_counts(&ctx, car_list);
// Move only owned_configs; construct a new MyCarsData to transfer ownership.
let counts = compute_car_counts(&ctx, car_list, my_data.owned_configs.clone());
Ok((ctx, counts))
}

View file

@ -22,6 +22,6 @@ pub async fn run_track(
rq: u8,
) -> 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?;
let (ctx, _counts) = run_analysis(&my_data, cars_db_path, times_dir).await?;
print_best_car(&ctx, track, condition, rq)
}

View file

@ -8,9 +8,14 @@ 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, output_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?;
let (_ctx, counts) = run_analysis(&my_data, cars_db_path, times_dir).await?;
write_trash_csv(output_dir, &counts, &my_data.owned)?;
println!("Written: trash.csv");
Ok(())

View file

@ -17,9 +17,14 @@ pub async fn run_wishlist(
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?;
let (_ctx, counts) = run_analysis(&my_data, cars_db_path, times_dir).await?;
write_wishlist_files(
output_dir, &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(())

View file

@ -3,7 +3,7 @@
//! Contains the mutable analysis state (`AnalysisContext`) and the
//! aggregated per-car win counts (`CarCounts`) computed from it.
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use dashmap::DashMap;
@ -33,24 +33,29 @@ impl Default for AnalysisContext {
impl AnalysisContext {
/// Create a new, empty analysis context.
pub fn new() -> Self {
AnalysisContext {
best_cars: Arc::new(DashMap::new()),
}
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.
///
/// The composite key `(car_rid, normalized_config)` ensures that the same car
/// with different final builds (e.g., "323" vs "332") is tracked separately.
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 (car_rid, normalized_config) → (BestCarRecord, count of tracks where this car+config is best)
pub by_rid: HashMap<(String, String), (BestCarRecord, usize)>,
/// Map from guid → set of (car_rid, normalized_config) for O(1) ownership/wishlist lookups.
/// A guid may map to multiple entries if the same car is best with different configs.
pub guid_to_rid: HashMap<String, HashSet<(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>,
/// Actual tune config for each owned car (guid → config string from playerDeck).
/// Used by the improve command to determine which cars are not yet at a final build.
pub owned_configs: HashMap<String, String>,
}
/// Computes per-car win counts from the analysis context.
@ -58,39 +63,46 @@ pub struct CarCounts {
/// 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();
///
/// Groups by `(car_rid, normalized_config)` so that the same car with different
/// final builds is counted as separate entries.
pub fn compute_car_counts(
ctx: &AnalysisContext,
car_list: Vec<Car>,
owned_configs: HashMap<String, String>,
) -> CarCounts {
let mut by_rid: HashMap<(String, String), (BestCarRecord, usize)> = HashMap::new();
let mut guid_to_rid: HashMap<String, HashSet<(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));
let key = (best_car.car_rid.clone(), best_car.normalized_config.clone());
let rid_entry = by_rid.entry(key.clone()).or_insert_with(|| (best_car.clone(), 0usize));
rid_entry.1 += 1;
// Keep the record with the best (lowest) time for this car.
// Keep the record with the best (lowest) time for this car+config.
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());
// Track all (rid, config) pairs for each guid.
guid_to_rid.entry(best_car.guid.clone()).or_default().insert(key);
}
}
// 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();
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 }
CarCounts { by_rid, guid_to_rid, car_map, car_list, owned_configs }
}
#[cfg(test)]
mod tests {
use super::*;
fn empty_owned_configs() -> HashMap<String, String> {
HashMap::new()
}
#[test]
fn test_compute_car_counts_basic() {
let ctx = AnalysisContext::new();
@ -101,6 +113,7 @@ mod tests {
year: 2020,
rq: 85,
config: "111".to_string(),
normalized_config: "332".to_string(),
time: 100.0,
owned: true,
prize: false,
@ -108,14 +121,10 @@ mod tests {
};
// 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()],
);
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(),
@ -127,16 +136,19 @@ mod tests {
guid: "guid_test".to_string(),
}];
let counts = compute_car_counts(&ctx, car_list);
let my_data = empty_owned_configs();
let counts = compute_car_counts(&ctx, car_list, my_data);
// 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();
let (rec, count) = counts.by_rid.get(&("Test_Car".to_string(), "332".to_string())).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()));
// guid_to_rid should map guid → set of (rid, config)
let guid_entries = counts.guid_to_rid.get("guid_test").unwrap();
assert_eq!(guid_entries.len(), 1);
assert!(guid_entries.contains(&("Test_Car".to_string(), "332".to_string())));
// car_map should have the car
assert!(counts.car_map.contains_key("guid_test"));
@ -154,6 +166,7 @@ mod tests {
year: 2020,
rq: 85,
config: "111".to_string(),
normalized_config: "332".to_string(),
time: 90.0,
owned: false,
prize: false,
@ -166,24 +179,27 @@ mod tests {
year: 2020,
rq: 85,
config: "233".to_string(),
normalized_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],
);
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
let my_data = empty_owned_configs();
let counts = compute_car_counts(&ctx, vec![], my_data);
// Different normalized configs → separate entries
assert_eq!(counts.by_rid.len(), 2);
let (rec_fast, count_fast) =
counts.by_rid.get(&("Car_A".to_string(), "332".to_string())).unwrap();
assert_eq!(*count_fast, 1);
assert_eq!(rec_fast.time, 90.0);
let (rec_slow, count_slow) =
counts.by_rid.get(&("Car_A".to_string(), "233".to_string())).unwrap();
assert_eq!(*count_slow, 1);
assert_eq!(rec_slow.time, 110.0);
}
}

View file

@ -20,9 +20,7 @@ pub fn escape_csv_field(field: &str) -> String {
///
/// 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))
a.rq.cmp(&b.rq).then(a.brand.cmp(&b.brand)).then(a.only_name.cmp(&b.only_name))
}
#[cfg(test)]
@ -44,16 +42,31 @@ mod tests {
#[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(),
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(),
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(),
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

View file

@ -44,15 +44,14 @@ pub async fn get_file(file_url: &str, local_file_path: &str) -> Result<(), AppEr
status,
attempt + 1,
);
tokio::time::sleep(
std::time::Duration::from_millis(500 * (attempt + 1) as u64)
).await;
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}")
));
return Err(AppError::Network(format!("HTTP {status} from {file_url}")));
}
match response.text().await {
Ok(content) => {
@ -62,23 +61,24 @@ pub async fn get_file(file_url: &str, local_file_path: &str) -> Result<(), AppEr
"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;
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")
));
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}")
));
return Err(AppError::Network(format!(
"Error reading response from {file_url}: {e}"
)));
}
}
}
@ -88,15 +88,16 @@ pub async fn get_file(file_url: &str, local_file_path: &str) -> Result<(), AppEr
"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;
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}")
));
return Err(AppError::Network(format!(
"Failed to fetch {file_url} after {MAX_FETCH_RETRIES} retries: {error}"
)));
}
}
}

View file

@ -41,9 +41,12 @@ pub mod test_utils;
// 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 context::{compute_car_counts, AnalysisContext, CarCounts};
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};
pub use types::{normalize_to_final_build, AppError, BestCarRecord, Car, TripletKey};
pub use user_data::{load_my_cars_data, MyCarsData};
#[cfg(feature = "test-utils")]
pub use test_utils::empty_my_data;

View file

@ -3,12 +3,15 @@
//! Provides generic CSV writing infrastructure and comparison functions
//! used across multiple output commands.
use std::collections::HashSet;
use std::collections::{HashMap, 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};
use top_drives::{
compare_cars, escape_csv_field, normalize_to_final_build, AnalysisContext, AppError,
BestCarRecord, CarCounts, TripletKey,
};
// ---------------------------------------------------------------------------
// Constants
@ -22,12 +25,14 @@ const TRASH_MIN_RQ: u8 = 80;
// Sorting helpers
// ---------------------------------------------------------------------------
/// Comparison function for sorting `BestCarRecord`s by RQ, then brand, then model name.
/// Comparison function for sorting `BestCarRecord`s by RQ, then normalized config,
/// then brand, then model name.
///
/// Avoids cloning into a `Car` just for sorting — uses the same ordering as
/// `compare_cars` but operates directly on `BestCarRecord`.
pub fn compare_records(a: &BestCarRecord, b: &BestCarRecord) -> std::cmp::Ordering {
a.rq.cmp(&b.rq)
.then(a.normalized_config.cmp(&b.normalized_config))
.then(a.brand.cmp(&b.brand))
.then(a.only_name.cmp(&b.only_name))
}
@ -95,18 +100,22 @@ where
})
}
/// Formats a `(BestCarRecord, usize)` row as `RQ,Brand,Model,Year,Count`.
/// Formats a `(BestCarRecord, usize)` row as `RQ,Brand,Model,Year,FinalBuild,Count`.
/// Shared by `write_best_csv`, `write_very_best_csv`, and wishlist output functions.
/// Fields are properly CSV-escaped to handle commas or quotes in names.
pub fn format_counted_row(entry: &(BestCarRecord, usize), file: &mut fs::File) -> std::io::Result<()> {
pub fn format_counted_row(
entry: &(BestCarRecord, usize),
file: &mut fs::File,
) -> std::io::Result<()> {
let (rec, count) = entry;
writeln!(
file,
"{},{},{},{},{}",
"{},{},{},{},{},{}",
rec.rq,
escape_csv_field(&rec.brand),
escape_csv_field(&rec.only_name),
rec.year,
rec.normalized_config,
count
)
}
@ -115,30 +124,32 @@ pub fn format_counted_row(entry: &(BestCarRecord, usize), file: &mut fs::File) -
// CSV output: best / very_best
// ---------------------------------------------------------------------------
/// Writes best.csv — all cars that are best on at least one track, sorted by RQ.
/// Format: RQ,Brand,Model,Year,Count
/// Writes best.csv — all cars that are best on at least one track, sorted by RQ then config.
/// Format: RQ,Brand,Model,Year,FinalBuild,Count
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",
"RQ,Brand,Model,Year,FinalBuild,Count",
&entries,
|a, b| compare_records(&a.0, &b.0),
|entry, file| format_counted_row(entry, file),
)
}
/// Writes very_best.csv — cars best on >= min_count tracks, sorted by RQ.
/// Format: RQ,Brand,Model,Year,Count
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();
/// Writes very_best.csv — cars best on >= min_count tracks, sorted by RQ then config.
/// Format: RQ,Brand,Model,Year,FinalBuild,Count
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",
"RQ,Brand,Model,Year,FinalBuild,Count",
&entries,
|a, b| compare_records(&a.0, &b.0),
|entry, file| format_counted_row(entry, file),
@ -151,8 +162,13 @@ pub fn write_very_best_csv(output_dir: &str, counts: &CarCounts, min_count: usiz
/// 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(output_dir: &str, counts: &CarCounts, owned: &HashSet<String>) -> Result<(), AppError> {
let trash: Vec<_> = owned.iter()
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 < TRASH_MIN_RQ)
@ -164,14 +180,16 @@ pub fn write_trash_csv(output_dir: &str, counts: &CarCounts, owned: &HashSet<Str
"RQ,Brand,Model,Year",
&trash,
|a, b| compare_cars(a, b),
|car, file| writeln!(
file,
"{},{},{},{}",
car.rq,
escape_csv_field(&car.brand),
escape_csv_field(&car.only_name),
car.year
),
|car, file| {
writeln!(
file,
"{},{},{},{}",
car.rq,
escape_csv_field(&car.brand),
escape_csv_field(&car.only_name),
car.year
)
},
)
}
@ -192,11 +210,8 @@ pub fn write_trash_csv(output_dir: &str, counts: &CarCounts, owned: &HashSet<Str
/// ```
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
.iter()
.map(|entry| (entry.key().clone(), entry.value().clone()))
.collect();
let mut sorted_data: Vec<(TripletKey, Vec<BestCarRecord>)> =
ctx.best_cars.iter().map(|entry| (entry.key().clone(), entry.value().clone())).collect();
// 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)));
@ -209,15 +224,15 @@ pub fn write_details_csv(output_dir: &str, ctx: &AnalysisContext) -> Result<(),
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
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
best_car.year, // Car year
best_car.config, // Tune configuration
best_car.time // Track time
)?;
}
}
@ -230,16 +245,37 @@ pub fn write_details_csv(output_dir: &str, ctx: &AnalysisContext) -> Result<(),
// ---------------------------------------------------------------------------
/// Computes the ideal wishlist: very_best unowned cars, capped at max_wishlist, sorted by RQ.
///
/// A car qualifies only when **one config** reaches `min_count` tracks — counts are
/// NOT summed across different configs. If multiple configs for the same car qualify,
/// the one with the highest count is kept as the representative entry.
pub fn compute_ideal_wishlist(
counts: &CarCounts,
owned: &HashSet<String>,
min_count: usize,
max_wishlist: usize,
) -> Vec<(BestCarRecord, usize)> {
let mut candidates: Vec<_> = counts.by_rid.values()
.filter(|(record, count)| *count >= min_count && !owned.contains(&record.guid))
.cloned()
.collect();
// Track the best qualifying entry per GUID (highest per-config count).
let mut guid_best_entry: HashMap<String, (BestCarRecord, usize)> = HashMap::new();
for (record, count) in counts.by_rid.values() {
if owned.contains(&record.guid) {
continue;
}
// Filter by per-config count — a single config must reach min_count
if *count < min_count {
continue;
}
// Keep the entry with the highest per-config count (or first on tie).
let entry = guid_best_entry
.entry(record.guid.clone())
.or_insert_with(|| (record.clone(), *count));
if count > &entry.1 {
*entry = (record.clone(), *count);
}
}
let mut candidates: Vec<_> = guid_best_entry.into_values().collect();
candidates.sort_by(|a, b| compare_records(&a.0, &b.0));
candidates.truncate(max_wishlist);
candidates
@ -264,21 +300,20 @@ pub fn write_wishlist_files(
write_sorted_csv(
output_dir,
"wishlist.csv",
"RQ,Brand,Model,Year,Count",
"RQ,Brand,Model,Year,FinalBuild,Count",
&ideal,
|a, b| compare_records(&a.0, &b.0),
format_counted_row,
)?;
// --- add.csv (ideal cars not yet on the wishlist) ---
let to_add: Vec<_> = ideal.iter()
.filter(|(record, _)| !wishlist.contains(&record.guid))
.collect();
let to_add: Vec<_> =
ideal.iter().filter(|(record, _)| !wishlist.contains(&record.guid)).collect();
write_sorted_csv(
output_dir,
"add.csv",
"RQ,Brand,Model,Year,Count",
"RQ,Brand,Model,Year,FinalBuild,Count",
&to_add,
|a, b| compare_records(&a.0, &b.0),
|entry, file| format_counted_row(entry, file),
@ -287,11 +322,19 @@ pub fn write_wishlist_files(
// --- remove.csv (wishlist cars not in the ideal set) ---
// Single pass: collect found entries and count dropped ones together.
let mut dropped_count = 0usize;
let to_remove: Vec<_> = wishlist.iter()
let to_remove: Vec<_> = wishlist
.iter()
.filter(|guid| !ideal_guids.contains(*guid))
.filter_map(|guid| {
if let Some(rid) = counts.guid_to_rid.get(guid) {
counts.by_rid.get(rid).cloned()
if let Some(keys) = counts.guid_to_rid.get(guid) {
// Pick the entry with the highest count (most impactful config)
keys.iter()
.max_by_key(|key| counts.by_rid.get(key).map(|(_, c)| *c).unwrap_or(0))
.and_then(|key| counts.by_rid.get(key).cloned())
.or_else(|| {
dropped_count += 1;
None
})
} else {
dropped_count += 1;
None
@ -307,7 +350,7 @@ pub fn write_wishlist_files(
write_sorted_csv(
output_dir,
"remove.csv",
"RQ,Brand,Model,Year,Count",
"RQ,Brand,Model,Year,FinalBuild,Count",
&to_remove,
|a, b| compare_records(&a.0, &b.0),
format_counted_row,
@ -316,6 +359,113 @@ pub fn write_wishlist_files(
Ok(())
}
// ---------------------------------------------------------------------------
// CSV output: improve
// ---------------------------------------------------------------------------
/// Writes improve.csv — owned locked cars whose actual config is not yet a final build
/// (323/233/332), sorted by number of best tracks descending (most impactful upgrades first).
///
/// Uses the **actual player config** from `my_cars.json` (engineMajor/weightMajor/chassisMajor)
/// rather than the timing data configs. This avoids false positives where a car already at a
/// final build appears in the list because timing data contains non-final configs.
///
/// The `Target` column shows the normalized final build that the player should upgrade to.
/// The `Count` column shows how many tracks this car is best on (across all its timing configs).
///
/// Format: RQ,Brand,Model,Year,Target,Count
pub fn write_improve_csv(
output_dir: &str,
counts: &CarCounts,
) -> Result<usize, AppError> {
/// A car that needs upgrading to a final build, with its target config and track count.
struct ImproveEntry {
brand: String,
only_name: String,
year: u16,
rq: u8,
target: String,
track_count: usize,
}
// Iterate over owned cars from the player's actual config data.
// For each owned car, check if its actual config is NOT a final build.
let mut entries: Vec<ImproveEntry> = Vec::new();
for (guid, actual_config) in &counts.owned_configs {
// Normalize the player's actual config
let target = normalize_to_final_build(actual_config);
// If normalization didn't change the config, it's already a final build or maxed — skip
if target == *actual_config {
continue;
}
// Look up the car metadata
let Some(car) = counts.car_map.get(guid) else {
eprintln!(
"Warning: owned car {guid} not found in cars database — skipped for improve"
);
continue;
};
// Count how many tracks this car is best on across ALL its normalized configs.
// This gives a "total impact" metric: upgrading this car benefits all tracks
// where any of its configs are best, not just those matching a single target.
let track_count: usize = counts
.guid_to_rid
.get(guid)
.map(|keys| {
keys.iter()
.filter_map(|key| counts.by_rid.get(key).map(|(_, c)| *c))
.sum()
})
.unwrap_or(0);
entries.push(ImproveEntry {
brand: car.brand.clone(),
only_name: car.only_name.clone(),
year: car.year,
rq: car.rq,
target,
track_count,
});
}
let count = entries.len();
// Sort by count descending (most tracks first), then by RQ/brand/model
write_sorted_csv(
output_dir,
"improve.csv",
"RQ,Brand,Model,Year,Target,Count",
&entries,
|a, b| {
// Primary: count descending
b.track_count
.cmp(&a.track_count)
.then(a.rq.cmp(&b.rq))
.then(a.target.cmp(&b.target))
.then(a.brand.cmp(&b.brand))
.then(a.only_name.cmp(&b.only_name))
},
|entry, file| {
writeln!(
file,
"{},{},{},{},{},{}",
entry.rq,
escape_csv_field(&entry.brand),
escape_csv_field(&entry.only_name),
entry.year,
entry.target,
entry.track_count
)
},
)?;
Ok(count)
}
// ---------------------------------------------------------------------------
// Stdout output: track-specific query
// ---------------------------------------------------------------------------
@ -331,7 +481,12 @@ pub fn write_wishlist_files(
/// * `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)
pub fn print_best_car(ctx: &AnalysisContext, track: &str, condition: &str, rq: u8) -> Result<(), AppError> {
pub fn print_best_car(
ctx: &AnalysisContext,
track: &str,
condition: &str,
rq: u8,
) -> Result<(), AppError> {
let key = (track.to_string(), rq, condition.to_string());
if let Some(best_list) = ctx.best_cars.get(&key) {
@ -348,10 +503,7 @@ pub fn print_best_car(ctx: &AnalysisContext, track: &str, condition: &str, rq: u
);
}
} else {
println!(
"No best car found for Track: {}, Condition: {}, RQ: {}",
track, condition, rq
);
println!("No best car found for Track: {}, Condition: {}, RQ: {}", track, condition, rq);
}
Ok(())
}
@ -364,8 +516,8 @@ pub fn print_best_car(ctx: &AnalysisContext, track: &str, condition: &str, rq: u
mod tests {
use super::*;
use std::collections::HashSet;
use top_drives::{Car, compute_car_counts};
use top_drives::test_utils::{make_record, make_car};
use top_drives::test_utils::{make_car, make_record};
use top_drives::{compute_car_counts, empty_my_data, Car};
// =======================================================================
// compute_ideal_wishlist tests
@ -378,15 +530,13 @@ mod tests {
{
for i in 0..15 {
ctx.best_cars.insert(
(format!("Track{i}"), 85, "Dry".to_string()),
vec![rec.clone()],
);
ctx.best_cars
.insert((format!("Track{i}"), 85, "Dry".to_string()), vec![rec.clone()]);
}
}
let car_list = vec![make_car("Car_A", 85, "guid_a")];
let counts = compute_car_counts(&ctx, car_list);
let counts = compute_car_counts(&ctx, car_list, empty_my_data());
let owned: HashSet<String> = HashSet::new();
let wishlist = compute_ideal_wishlist(&counts, &owned, 10, 300);
@ -403,15 +553,13 @@ mod tests {
{
for i in 0..15 {
ctx.best_cars.insert(
(format!("Track{i}"), 85, "Dry".to_string()),
vec![rec.clone()],
);
ctx.best_cars
.insert((format!("Track{i}"), 85, "Dry".to_string()), vec![rec.clone()]);
}
}
let car_list = vec![make_car("Car_A", 85, "guid_a")];
let counts = compute_car_counts(&ctx, car_list);
let counts = compute_car_counts(&ctx, car_list, empty_my_data());
let owned: HashSet<String> = vec!["guid_a".to_string()].into_iter().collect();
let wishlist = compute_ideal_wishlist(&counts, &owned, 10, 300);
@ -432,18 +580,15 @@ mod tests {
100.0 + j as f64,
&format!("guid_{j}"),
);
ctx.best_cars.insert(
(format!("Track{i}"), 80 + j, "Dry".to_string()),
vec![rec],
);
ctx.best_cars
.insert((format!("Track{i}"), 80 + j, "Dry".to_string()), vec![rec]);
}
}
}
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);
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, empty_my_data());
let owned: HashSet<String> = HashSet::new();
let wishlist = compute_ideal_wishlist(&counts, &owned, 10, 3);
@ -469,7 +614,8 @@ mod tests {
&entries,
|a, b| a.0.cmp(b.0),
|entry, file| writeln!(file, "{},{}", entry.0, entry.1),
).unwrap();
)
.unwrap();
let path = dir.path().join("test.csv");
let content = fs::read_to_string(path).unwrap();
@ -489,6 +635,7 @@ mod tests {
year: 2020,
rq: 85,
config: "111".to_string(),
normalized_config: "332".to_string(),
time: 100.0,
owned: false,
prize: false,
@ -502,16 +649,17 @@ mod tests {
write_sorted_csv(
output_dir,
"test.csv",
"RQ,Brand,Model,Year,Count",
"RQ,Brand,Model,Year,FinalBuild,Count",
&[entry],
|_, _| std::cmp::Ordering::Equal,
|e, file| format_counted_row(e, file),
).unwrap();
)
.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");
assert_eq!(lines[1], "85,\"Brand, Inc.\",\"Model \"\"Sport\"\"\",2020,332,5");
}
#[test]
@ -521,10 +669,7 @@ mod tests {
// Car_A is best on a track, Car_B is not
let rec_a = make_record("Car_A", 70, 100.0, "guid_a");
{
ctx.best_cars.insert(
("Track1".to_string(), 70, "Dry".to_string()),
vec![rec_a],
);
ctx.best_cars.insert(("Track1".to_string(), 70, "Dry".to_string()), vec![rec_a]);
}
// Make Car_D a prize car
@ -538,17 +683,20 @@ mod tests {
car_d, // not best, prize car — not trash
];
let counts = compute_car_counts(&ctx, car_list);
let counts = compute_car_counts(&ctx, car_list, empty_my_data());
let owned: HashSet<String> = vec![
"guid_a".to_string(),
"guid_b".to_string(),
"guid_c".to_string(),
"guid_d".to_string(),
].into_iter().collect();
]
.into_iter()
.collect();
// Test the filtering logic directly (same logic as write_trash_csv)
let trash: Vec<_> = owned.iter()
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 < TRASH_MIN_RQ)
@ -579,6 +727,7 @@ mod tests {
year: 2020,
rq: 85,
config: "111".to_string(),
normalized_config: "332".to_string(),
time: 100.0,
owned: false,
prize: false,
@ -591,6 +740,7 @@ mod tests {
year: 2020,
rq: 85,
config: "111".to_string(),
normalized_config: "332".to_string(),
time: 100.0,
owned: false,
prize: false,
@ -606,7 +756,7 @@ mod tests {
#[test]
fn test_empty_analysis_context() {
let ctx = AnalysisContext::new();
let counts = compute_car_counts(&ctx, vec![]);
let counts = compute_car_counts(&ctx, vec![], empty_my_data());
assert!(counts.by_rid.is_empty());
assert!(counts.guid_to_rid.is_empty());
assert!(counts.car_map.is_empty());
@ -618,16 +768,13 @@ mod tests {
let ctx = AnalysisContext::new();
let rec = make_record("Only_Car", 90, 88.5, "guid_only");
ctx.best_cars.insert(
("Track1".to_string(), 90, "Dry".to_string()),
vec![rec],
);
ctx.best_cars.insert(("Track1".to_string(), 90, "Dry".to_string()), vec![rec]);
let car = make_car("Only_Car", 90, "guid_only");
let counts = compute_car_counts(&ctx, vec![car]);
let counts = compute_car_counts(&ctx, vec![car], empty_my_data());
assert_eq!(counts.by_rid.len(), 1);
let (rec, count) = counts.by_rid.get("Only_Car").unwrap();
let (rec, count) = counts.by_rid.get(&("Only_Car".to_string(), "332".to_string())).unwrap();
assert_eq!(*count, 1);
assert_eq!(rec.car_rid, "Only_Car");
}
@ -638,14 +785,11 @@ mod tests {
let rec = make_record("Car_A", 85, 100.0, "guid_a");
for i in 0..15 {
ctx.best_cars.insert(
(format!("Track{i}"), 85, "Dry".to_string()),
vec![rec.clone()],
);
ctx.best_cars.insert((format!("Track{i}"), 85, "Dry".to_string()), vec![rec.clone()]);
}
let car = make_car("Car_A", 85, "guid_a");
let counts = compute_car_counts(&ctx, vec![car]);
let counts = compute_car_counts(&ctx, vec![car], empty_my_data());
let all_owned: HashSet<String> = vec!["guid_a".to_string()].into_iter().collect();
let wishlist = compute_ideal_wishlist(&counts, &all_owned, 10, 300);
@ -658,14 +802,11 @@ mod tests {
let rec = make_record("Car_A", 85, 100.0, "guid_a");
for i in 0..15 {
ctx.best_cars.insert(
(format!("Track{i}"), 85, "Dry".to_string()),
vec![rec.clone()],
);
ctx.best_cars.insert((format!("Track{i}"), 85, "Dry".to_string()), vec![rec.clone()]);
}
let car = make_car("Car_A", 85, "guid_a");
let counts = compute_car_counts(&ctx, vec![car]);
let counts = compute_car_counts(&ctx, vec![car], empty_my_data());
let no_owned: HashSet<String> = HashSet::new();
let wishlist = compute_ideal_wishlist(&counts, &no_owned, 10, 300);

View file

@ -19,10 +19,8 @@ pub fn get_car_list(cars_db_path: &str) -> Result<Vec<Car>, AppError> {
}
})?;
serde_json::from_str(&content).map_err(|e| AppError::Parse {
file: cars_db_path.to_string(),
reason: e.to_string(),
})
serde_json::from_str(&content)
.map_err(|e| AppError::Parse { file: cars_db_path.to_string(), reason: e.to_string() })
}
// ---------------------------------------------------------------------------
@ -39,7 +37,10 @@ mod tests {
assert!(result.is_err(), "Should return error for missing file");
match result.unwrap_err() {
AppError::MissingFile(msg) => {
assert!(msg.contains("nonexistent_cars_db.json"), "Error should mention the file path");
assert!(
msg.contains("nonexistent_cars_db.json"),
"Error should mention the file path"
);
}
other => panic!("Expected MissingFile error, got: {other}"),
}

View file

@ -16,8 +16,8 @@ use tokio::sync::Semaphore;
use tokio::task;
use top_drives::{
AppError, AnalysisContext, BestCarRecord, Car, TripletKey,
translate_track_name, translate_track_type,
normalize_to_final_build, translate_track_name, translate_track_type, AnalysisContext,
AppError, BestCarRecord, Car, TripletKey,
};
use self::car_list::get_car_list;
@ -90,16 +90,22 @@ pub fn parse_car_times(
let json_data: Value = match serde_json::from_str(&file_content) {
Ok(v) => v,
Err(e) => {
return Err(AppError::Parse {
file: local_file_path,
reason: e.to_string(),
});
return Err(AppError::Parse { file: local_file_path, reason: e.to_string() });
}
};
// Process each configuration variant (111, 233, 323, etc.)
if let Some(configurations) = json_data["data"].as_object() {
for (config, entry) in configurations {
for (raw_config, entry) in configurations {
// Strip version prefix (e.g., "v2c_" from "v2c_233") at read time.
// Downstream code only ever sees bare digit strings.
// Use explicit prefix matching to avoid mis-stripping keys that
// naturally contain underscores.
let config = raw_config
.strip_prefix("v2c_")
.or_else(|| raw_config.strip_prefix("v1c_"))
.unwrap_or(raw_config)
.to_string();
// Process timing data for each track
if let Some(times) = entry["times"].as_object() {
for (track_id, details) in times {
@ -129,6 +135,7 @@ pub fn parse_car_times(
let track_name = translate_track_name(cleaned_track_id);
let surface = translate_track_type(track_type);
let normalized = normalize_to_final_build(&config);
update_best_car(
best_cars,
&track_name,
@ -141,6 +148,7 @@ pub fn parse_car_times(
year: car.year,
rq: car.rq,
config: config.clone(),
normalized_config: normalized,
time,
owned,
prize: car.prize,
@ -201,9 +209,10 @@ pub fn update_best_car(
*existing_list = vec![new_record.clone()];
}
} else if (new_record.time - best_time).abs() < TIME_TOLERANCE
&& !existing_list
.iter()
.any(|r| r.car_rid == new_record.car_rid)
&& !existing_list.iter().any(|r| {
r.car_rid == new_record.car_rid
&& r.normalized_config == new_record.normalized_config
})
{
if is_last {
existing_list.push(new_record);
@ -264,9 +273,11 @@ pub async fn parse_all_times(
Err(_) => return Err(AppError::Internal("Semaphore closed".into())),
};
// Run the synchronous parsing on a blocking thread while holding the permit.
task::spawn_blocking(move || {
parse_car_times(car, &my_cars, &best_cars, &times_dir)
}).await.unwrap_or_else(|e| Err(AppError::Internal(format!("Blocking task join error: {e}"))))
task::spawn_blocking(move || parse_car_times(car, &my_cars, &best_cars, &times_dir))
.await
.unwrap_or_else(|e| {
Err(AppError::Internal(format!("Blocking task join error: {e}")))
})
});
handles.push(handle);
}
@ -297,7 +308,9 @@ pub async fn parse_all_times(
if parse_errors == car_list.len() && !car_list.is_empty() {
return Err(AppError::Parse {
file: times_dir.to_string(),
reason: format!("All {parse_errors} car(s) failed to parse — data directory may be corrupted"),
reason: format!(
"All {parse_errors} car(s) failed to parse — data directory may be corrupted"
),
});
}
@ -319,7 +332,10 @@ pub async fn fetch_times(cars_db_path: &str, times_dir: &str) -> Result<(), AppE
let mut handles = vec![];
for car in get_car_list(cars_db_path)? {
let permit = semaphore.clone().acquire_owned().await
let permit = semaphore
.clone()
.acquire_owned()
.await
.map_err(|e| AppError::Network(format!("Semaphore closed: {e}")))?;
let times_dir = times_dir.to_string();
handles.push(tokio::spawn(async move {
@ -335,12 +351,16 @@ pub async fn fetch_times(cars_db_path: &str, times_dir: &str) -> Result<(), AppE
Ok(Ok(())) => {}
Ok(Err(e)) => {
eprintln!("{e}");
if first_error.is_none() { first_error = Some(e); }
if first_error.is_none() {
first_error = Some(e);
}
}
Err(e) => {
let msg = AppError::Network(format!("Task join error: {e}"));
eprintln!("{msg}");
if first_error.is_none() { first_error = Some(msg); }
if first_error.is_none() {
first_error = Some(msg);
}
}
}
}
@ -439,10 +459,7 @@ mod tests {
for rq in 85..=MAX_RQ {
let key = ("laguna".to_string(), rq, "Dry".to_string());
assert!(
ctx.best_cars.contains_key(&key),
"Car should appear at RQ level {rq}"
);
assert!(ctx.best_cars.contains_key(&key), "Car should appear at RQ level {rq}");
}
// Should NOT appear below its RQ
let key_below = ("laguna".to_string(), 84, "Dry".to_string());
@ -575,10 +592,7 @@ mod tests {
parse_car_times(car, &my_cars, &ctx.best_cars, times_dir).unwrap();
// Test Bowl time should be inverted: 2000.0 / 50.0 = 40.0
let records = ctx
.best_cars
.get(&("Test Bowl".to_string(), 85, "Dry".to_string()))
.unwrap();
let records = ctx.best_cars.get(&("Test Bowl".to_string(), 85, "Dry".to_string())).unwrap();
assert_eq!(records[0].time, 40.0);
}

View file

@ -127,11 +127,9 @@ enum Commands {
/// Generate details.csv with all timing data
Details,
/// Show best car for a specific track/condition/RQ combination
Track {
track: String,
condition: String,
rq: u8,
},
Track { track: String, condition: String, rq: u8 },
/// Generate improve.csv (owned cars not in final config, sorted by best tracks)
Improve,
}
// ---------------------------------------------------------------------------
@ -170,29 +168,70 @@ async fn run() -> Result<(), AppError> {
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, &cli.output_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, &cli.output_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, &cli.output_dir, min_count, max_wishlist,
).await?;
&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, &cli.output_dir, min_count, max_wishlist,
).await?;
&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, &cli.output_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, &cli.output_dir,
&track, &condition, rq,
).await?;
&cli.my_cars,
&cli.cars_db,
&cli.times_dir,
&cli.output_dir,
&track,
&condition,
rq,
)
.await?;
}
Commands::Improve => {
commands::improve::run_improve(
&cli.my_cars,
&cli.cars_db,
&cli.times_dir,
&cli.output_dir,
)
.await?;
}
}
Ok(())

View file

@ -3,6 +3,8 @@
//! Provides helper constructors for [`BestCarRecord`] and [`Car`] to avoid
//! duplication across test modules.
use std::collections::HashMap;
use crate::{BestCarRecord, Car};
/// Creates a test [`BestCarRecord`] with sensible defaults.
@ -17,6 +19,7 @@ pub fn make_record(car_rid: &str, rq: u8, time: f64, guid: &str) -> BestCarRecor
year: 2020,
rq,
config: "111".to_string(),
normalized_config: "332".to_string(),
time,
owned: false,
prize: false,
@ -39,3 +42,8 @@ pub fn make_car(rid: &str, rq: u8, guid: &str) -> Car {
guid: guid.to_string(),
}
}
/// Creates an empty owned configs map for tests that don't need ownership data.
pub fn empty_my_data() -> HashMap<String, String> {
HashMap::new()
}

View file

@ -89,9 +89,7 @@ pub fn translate_track_name(track_code: &str) -> String {
raw
});
map.get(track_code)
.cloned()
.unwrap_or_else(|| track_code.to_string())
map.get(track_code).cloned().unwrap_or_else(|| track_code.to_string())
}
#[cfg(test)]
@ -145,7 +143,11 @@ mod tests {
for condition in test_conditions {
let name = translate_track_type(condition);
assert_ne!(name, condition, "Condition {} should have a proper name mapping", condition);
assert_ne!(
name, condition,
"Condition {} should have a proper name mapping",
condition
);
assert!(!name.is_empty(), "Condition name should not be empty");
}
}

View file

@ -74,8 +74,12 @@ pub struct BestCarRecord {
pub year: u16,
/// Rarity level of this specific car configuration
pub rq: u8,
/// Tune configuration string (e.g., "111", "233", "323")
/// Original tune configuration string from JSON (e.g., "111", "221", "233")
pub config: String,
/// Normalized config mapped to the closest final build ("323", "233", or "332").
/// Final builds are permutations of digits summing to 8. Non-standard configs
/// are normalized by repeatedly incrementing the smallest digit until the sum is 8.
pub normalized_config: String,
/// Track time in seconds (lower is better)
pub time: f64,
/// Whether the current user owns this car
@ -89,3 +93,59 @@ pub struct BestCarRecord {
/// 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);
/// Normalizes a tune configuration to the closest "final build".
///
/// The three final builds are "323", "233", and "332" (all permutations of digits
/// summing to 8). For any non-standard config, this function repeatedly increments
/// the smallest digit until the digit sum reaches 8, producing one of the three
/// final builds.
///
/// Expects bare digit strings (e.g., `"233"`, `"111"`). Version prefixes (e.g.,
/// `"v2c_233"`) are stripped at read time by the parsing layer before reaching
/// this function.
///
/// If the config cannot be parsed (wrong length, non-digit characters, or digit
/// sum exceeding 8), the original config string is returned unchanged.
///
/// # Examples
///
/// ```
/// use top_drives::normalize_to_final_build;
/// assert_eq!(normalize_to_final_build("323"), "323");
/// assert_eq!(normalize_to_final_build("111"), "332");
/// assert_eq!(normalize_to_final_build("221"), "332");
/// assert_eq!(normalize_to_final_build("122"), "332");
/// assert_eq!(normalize_to_final_build("113"), "323");
/// assert_eq!(normalize_to_final_build("233"), "233");
/// assert_eq!(normalize_to_final_build("332"), "332");
/// assert_eq!(normalize_to_final_build("879"), "879"); // maxed: returned as-is
/// ```
pub fn normalize_to_final_build(config: &str) -> String {
let bytes = config.as_bytes();
if bytes.len() != 3 || !bytes.iter().all(|&b| b.is_ascii_digit()) {
return config.to_string();
}
let mut digits = [0u8; 3];
for (i, &b) in bytes.iter().enumerate() {
digits[i] = b - b'0';
}
let sum: i32 = digits.iter().map(|&d| d as i32).sum();
if sum >= 8 {
// Already a final build (sum == 8) or maxed (sum > 8) — return as-is.
return config.to_string();
}
let mut deficit = 8 - sum;
while deficit > 0 {
// Find the position of the smallest digit (first occurrence on tie)
let min_pos = (0..3).min_by_key(|&i| digits[i]).unwrap();
digits[min_pos] += 1;
deficit -= 1;
}
digits.iter().map(|&d| (b'0' + d) as char).collect()
}

View file

@ -3,7 +3,7 @@
//! Handles parsing the game export file to extract owned car GUIDs
//! and wishlist entries.
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::fs::File;
use std::io::BufReader;
@ -21,6 +21,10 @@ pub struct MyCarsData {
pub owned: HashSet<String>,
/// GUIDs of wishlisted cars (from `wishlists[0].cards`)
pub wishlist: HashSet<String>,
/// Actual tune config for each owned car (guid → "{engineMajor}{weightMajor}{chassisMajor}").
/// Only populated for locked cards. Used by the improve command to determine
/// which cars are not yet at a final build.
pub owned_configs: HashMap<String, String>,
}
/// Loads and parses `my_cars.json` once, extracting both owned and wishlist data.
@ -49,22 +53,33 @@ pub fn load_my_cars_data(path: &str) -> Result<MyCarsData, AppError> {
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())
// Extract owned car GUIDs and their actual configs from playerDeck
let mut owned: HashSet<String> = HashSet::new();
let mut owned_configs: HashMap<String, String> = HashMap::new();
if let Some(deck) = json.get("playerDeck").and_then(|d| d.as_array()) {
for card in deck {
if card.get("locked").and_then(|v| v.as_bool()).unwrap_or(false) {
if let Some(guid) = card.get("cardId").and_then(|v| v.as_str()) {
owned.insert(guid.to_string());
// Extract actual tune config: engineMajor + weightMajor + chassisMajor.
// Skip cards with incomplete tune data to avoid false "000" entries
// that would incorrectly appear as needing upgrade.
let engine = card.get("engineMajor").and_then(|v| v.as_u64());
let weight = card.get("weightMajor").and_then(|v| v.as_u64());
let chassis = card.get("chassisMajor").and_then(|v| v.as_u64());
if let (Some(e), Some(w), Some(c)) = (engine, weight, chassis) {
let config = format!("{e}{w}{c}");
owned_configs.insert(guid.to_string(), config);
} else {
None
eprintln!(
"Warning: locked card {guid} has incomplete tune data — skipped"
);
}
})
.collect()
})
.unwrap_or_default();
}
}
}
}
// Extract wishlist GUIDs from wishlists[0].cards
let wishlist: HashSet<String> = json
@ -72,15 +87,10 @@ pub fn load_my_cars_data(path: &str) -> Result<MyCarsData, AppError> {
.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()
})
.map(|cards| cards.iter().filter_map(|c| c.as_str().map(|s| s.to_string())).collect())
.unwrap_or_default();
Ok(MyCarsData { owned, wishlist })
Ok(MyCarsData { owned, wishlist, owned_configs })
}
#[cfg(test)]