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

@ -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);
}