//! Core parsing logic for car timing data. //! //! Handles per-car JSON timing file parsing, best-car record updates, //! and orchestrated concurrent parsing of all timing files. pub mod car_list; use std::collections::HashSet; use std::fs; use std::sync::Arc; use dashmap::DashMap; use futures::future::join_all; use serde_json::Value; use tokio::sync::Semaphore; use tokio::task; use top_drives::{ normalize_to_final_build, translate_track_name, translate_track_type, AnalysisContext, AppError, BestCarRecord, Car, TripletKey, }; use self::car_list::get_car_list; // --------------------------------------------------------------------------- // Constants (shared across parsing modules) // --------------------------------------------------------------------------- /// Maximum RQ level in the Top Drives database. /// Used as the upper bound when iterating RQ ranges and as a fallback for parse errors. pub const MAX_RQ: u8 = 113; /// Minimum valid track time in seconds. Times below this threshold are discarded. pub const MIN_VALID_TIME: f64 = 1.2; /// Tolerance for considering two times as equal (5ms). pub const TIME_TOLERANCE: f64 = 0.005; /// Multiplier constant for Test Bowl time inversion. /// Test Bowl tracks use speed rather than time, so we invert: `FACTOR / time`. pub const TEST_BOWL_FACTOR: f64 = 2000.0; /// Maximum number of concurrent download/parse tasks. /// /// IMPORTANT: Keep ≤ 50 to avoid exhausting tokio's default blocking thread pool (512 threads), /// since each concurrent permit holder also occupies a `spawn_blocking` thread. pub const CONCURRENCY_LIMIT: usize = 20; // Compile-time guards: a bad edit to any constant above fails the build here // instead of producing subtly wrong behavior at runtime. const _: () = { assert!(MAX_RQ > 0); assert!(MIN_VALID_TIME > 0.0); assert!(TIME_TOLERANCE > 0.0); assert!(TEST_BOWL_FACTOR > 0.0); assert!(CONCURRENCY_LIMIT > 0); }; // --------------------------------------------------------------------------- // Core parsing: per-car timing data // --------------------------------------------------------------------------- /// Parses timing data for a single car from its JSON file. /// /// **Blocking** — performs synchronous file I/O and CPU-intensive JSON parsing. /// Must be called from [`tokio::task::spawn_blocking`], never directly from an async context, /// as it would block the tokio runtime thread. /// /// This is the core function responsible for processing individual car performance data. /// It reads the car's JSON file, extracts timing information for each track and condition, /// filters out invalid or downvoted times, and updates the analysis context. /// /// # Arguments /// /// * `car` - The car data structure containing metadata about the car to process /// * `my_cars` - Set of GUIDs the user owns (used to mark ownership) /// * `best_cars` - Shared concurrent map of best car records (`DashMap`) /// * `times_dir` - Directory containing per-car timing JSON files pub fn parse_car_times( car: Car, my_cars: &HashSet, best_cars: &DashMap>, times_dir: &str, ) -> Result<(), AppError> { // Construct file path for this car's timing data let file_name = car.rid.to_owned() + ".json"; let local_file_path = format!("{times_dir}/{file_name}"); // Read file content let file_content = match fs::read_to_string(&local_file_path) { Ok(content) => content, // File not found is expected for most cars (no timing data recorded) — silent skip. Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), Err(e) => { log::warn!("could not read {local_file_path}: {e}"); return Ok(()); } }; // Parse JSON data 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() }); } }; // Process each configuration variant (111, 233, 323, etc.) if let Some(configurations) = json_data["data"].as_object() { for (raw_config, config_data) in configurations { // Strip version prefix (e.g., "v2c_" from "v2c_233", "v2b_" from "v2b_111") // at read time. Downstream code only ever sees bare digit strings. // Generic approach: find the last '_' and take what follows it, // but only if the remainder is exactly 3 ASCII digits. Otherwise // keep the key unchanged (handles bare configs like "233" and // unexpected formats gracefully). let config = raw_config .rsplit_once('_') .filter(|(_, suffix)| { suffix.len() == 3 && suffix.bytes().all(|b| b.is_ascii_digit()) }) .map(|(_, suffix)| suffix.to_string()) .unwrap_or_else(|| raw_config.clone()); // Process timing data for each track if let Some(times) = config_data["times"].as_object() { for (track_id, details) in times { // Parse track ID to separate base track name from surface condition. // Use rsplit_once to split on the LAST "_a" so track names that // contain "_a" (e.g. "airplaneHangars") are handled correctly. if let Some((cleaned_track_id, track_type)) = track_id.rsplit_once("_a") { let Some(mut time) = details["t"].as_f64() else { log::warn!( "skipping {}/{}: missing or non-numeric time field", car.rid, track_id ); continue; }; let down_votes = details["down"].as_array(); let no_down_votes = match down_votes { None => true, Some(votes) => votes.is_empty(), }; if track_id.starts_with("testBowl") { if time > 0.0 { time = TEST_BOWL_FACTOR / time; } else { continue; // skip invalid zero time } } let owned = my_cars.contains(&car.guid); if time > MIN_VALID_TIME && no_down_votes { 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, car.rq, surface, BestCarRecord { car_rid: car.rid.clone(), brand: car.brand.clone(), only_name: car.only_name.clone(), year: car.year, rq: car.rq, config: config.clone(), normalized_config: normalized, time, owned, prize: car.prize, guid: car.guid.clone(), }, ); } } } } } } Ok(()) } // --------------------------------------------------------------------------- // Core parsing: update best car records // --------------------------------------------------------------------------- /// Updates the best cars database with a new performance record. /// /// Implements the core logic for determining the best performing cars across different /// RQ ranges. For each RQ level from the car's base RQ up to MAX_RQ, this /// function determines whether the new record represents an improvement or tie. /// /// # Allocation Optimization /// /// The record is cloned at most once (for the first insertion), and subsequent /// RQ levels reuse the entry via `DashMap`'s `entry()` API, avoiding per-RQ /// clone overhead for the common "insert new" case. /// /// String keys (`track_id`, `surface`) are pre-allocated once and cloned per /// iteration, avoiding repeated `to_string()` formatting overhead. pub fn update_best_car( best_cars: &DashMap>, track_id: &str, car_rq: u8, surface: &str, new_record: BestCarRecord, ) { let track_id_owned = track_id.to_string(); let surface_owned = surface.to_string(); for rq in car_rq..=MAX_RQ { let is_last = rq == MAX_RQ; let key = (track_id_owned.clone(), rq, surface_owned.clone()); match best_cars.entry(key) { dashmap::mapref::entry::Entry::Occupied(mut entry) => { let existing_list = entry.get_mut(); let best_time = existing_list.first().map(|r| r.time).unwrap_or(f64::MAX); if new_record.time < best_time { if is_last { *existing_list = vec![new_record]; return; } else { *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 && r.normalized_config == new_record.normalized_config }) { if is_last { existing_list.push(new_record); return; } else { existing_list.push(new_record.clone()); } } // else: record is discarded for this RQ — no clone needed } dashmap::mapref::entry::Entry::Vacant(entry) => { if is_last { entry.insert(vec![new_record]); return; } else { entry.insert(vec![new_record.clone()]); } } } } } // --------------------------------------------------------------------------- // Orchestrated parsing // --------------------------------------------------------------------------- /// Orchestrates concurrent parsing of all car timing files. /// /// Spawns a separate async task for each car in the database. The semaphore is /// acquired in the async context (avoiding `block_on` inside `spawn_blocking`, /// which risks deadlocking the blocking thread pool), then the synchronous /// parsing work runs inside `spawn_blocking` while the permit is held. /// /// Takes `Arc>` to avoid cloning the entire owned-car set on /// every call. pub async fn parse_all_times( my_cars: Arc>, ctx: &AnalysisContext, cars_db_path: &str, times_dir: &str, ) -> Result, AppError> { let car_list = get_car_list(cars_db_path)?; let mut handles = vec![]; let semaphore = Arc::new(Semaphore::new(CONCURRENCY_LIMIT)); let best_cars = Arc::clone(&ctx.best_cars); for car in car_list.iter() { let my_cars = Arc::clone(&my_cars); let best_cars = Arc::clone(&best_cars); let car = car.clone(); let sem = Arc::clone(&semaphore); let times_dir = times_dir.to_string(); let handle = task::spawn(async move { // Acquire the semaphore asynchronously — propagate error if closed. let _permit = match sem.acquire().await { Ok(p) => p, 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, ×_dir)) .await .unwrap_or_else(|e| { Err(AppError::Internal(format!("Blocking task join error: {e}"))) }) }); handles.push(handle); } let results = join_all(handles).await; let mut parse_errors = 0usize; for result in results { match result { // Outer JoinError from tokio::spawn Err(e) => { log::warn!("parsing task failed: {e}"); parse_errors += 1; } // parse_car_times or semaphore returned an error Ok(Err(e)) => { log::warn!("{e}"); parse_errors += 1; } // parse_car_times succeeded Ok(Ok(())) => {} } } if parse_errors > 0 { log::warn!("{parse_errors} car(s) had parse errors during analysis"); } // If every car failed to parse, the data is unusable — propagate the error. 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" ), }); } // Missing timing files are silent (expected for most cars), so a wrong or // empty --times-dir would otherwise produce empty CSVs without any hint. if ctx.best_cars.is_empty() && !car_list.is_empty() { log::warn!( "no timing data found in {times_dir} for {} car(s) — all output CSVs will be empty", car_list.len() ); } Ok(car_list) } // --------------------------------------------------------------------------- // Data fetching // --------------------------------------------------------------------------- /// Remote URL for the per-car timing API. const CAR_TIMING_API_BASE: &str = "https://api.topdrivesrecords.com/car/"; /// Fetches timing data files for all cars using controlled concurrency. /// /// Returns the first error encountered, or `Ok(())` if all downloads succeeded. pub async fn fetch_times(cars_db_path: &str, times_dir: &str) -> Result<(), AppError> { let semaphore = Arc::new(Semaphore::new(CONCURRENCY_LIMIT)); let mut handles = vec![]; for car in get_car_list(cars_db_path)? { 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 { let _permit = permit; // held until end of block fetch_car(&car.rid, ×_dir).await })); } let results = join_all(handles).await; let mut first_error: Option = None; for result in results { match result { Ok(Ok(())) => {} Ok(Err(e)) => { log::warn!("{e}"); if first_error.is_none() { first_error = Some(e); } } Err(e) => { let msg = AppError::Network(format!("Task join error: {e}")); log::warn!("{msg}"); if first_error.is_none() { first_error = Some(msg); } } } } first_error.map_or(Ok(()), Err) } /// Downloads timing data for a single car from the remote API. async fn fetch_car(rid: &str, times_dir: &str) -> Result<(), AppError> { let file_name = rid.to_owned() + ".json"; let url = format!("{CAR_TIMING_API_BASE}{rid}"); let local_file_path = format!("{times_dir}/{file_name}"); log::info!("Fetching {url}."); top_drives::get_file(&url, &local_file_path).await } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use std::collections::HashSet; use top_drives::test_utils::make_record; // ======================================================================= // update_best_car tests // ======================================================================= #[test] fn test_update_best_car_inserts_new_record() { let ctx = AnalysisContext::new(); let record = make_record("Car_A", 85, 100.0, "guid_a"); update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record); let key = ("laguna".to_string(), 85, "Dry".to_string()); assert!(ctx.best_cars.contains_key(&key)); let records = ctx.best_cars.get(&key).unwrap(); assert_eq!(records.len(), 1); assert_eq!(records[0].car_rid, "Car_A"); } #[test] fn test_update_best_car_replaces_slower_record() { let ctx = AnalysisContext::new(); let slow = make_record("Car_A", 85, 110.0, "guid_a"); let fast = make_record("Car_B", 85, 95.0, "guid_b"); update_best_car(&ctx.best_cars, "laguna", 85, "Dry", slow); update_best_car(&ctx.best_cars, "laguna", 85, "Dry", fast); let records = ctx.best_cars.get(&("laguna".to_string(), 85, "Dry".to_string())).unwrap(); assert_eq!(records.len(), 1); assert_eq!(records[0].car_rid, "Car_B"); // faster car wins } #[test] fn test_update_best_car_tie_within_tolerance() { let ctx = AnalysisContext::new(); let record1 = make_record("Car_A", 85, 100.0, "guid_a"); let record2 = make_record("Car_B", 85, 100.003, "guid_b"); // within 0.005 update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record1); update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record2); let records = ctx.best_cars.get(&("laguna".to_string(), 85, "Dry".to_string())).unwrap(); assert_eq!(records.len(), 2, "Tied records should both be kept"); assert!(records.iter().any(|r| r.car_rid == "Car_A")); assert!(records.iter().any(|r| r.car_rid == "Car_B")); } #[test] fn test_update_best_car_outside_tolerance_replaces() { let ctx = AnalysisContext::new(); let record1 = make_record("Car_A", 85, 100.0, "guid_a"); let record3 = make_record("Car_C", 85, 100.006, "guid_c"); // outside 0.005 update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record1); update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record3); let records = ctx.best_cars.get(&("laguna".to_string(), 85, "Dry".to_string())).unwrap(); assert_eq!(records.len(), 1, "Record outside tolerance should not tie"); assert_eq!(records[0].car_rid, "Car_A"); // original kept (same best time) } #[test] fn test_update_best_car_rq_range_propagation() { let ctx = AnalysisContext::new(); let record = make_record("Car_A", 85, 100.0, "guid_a"); update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record); 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}"); } // Should NOT appear below its RQ let key_below = ("laguna".to_string(), 84, "Dry".to_string()); assert!(!ctx.best_cars.contains_key(&key_below)); } #[test] fn test_update_best_car_duplicate_rid_not_added_in_tie() { let ctx = AnalysisContext::new(); let record1 = make_record("Car_A", 85, 100.0, "guid_a"); let record2 = make_record("Car_A", 85, 100.001, "guid_a"); // same car, within tolerance update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record1); update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record2); let records = ctx.best_cars.get(&("laguna".to_string(), 85, "Dry".to_string())).unwrap(); assert_eq!(records.len(), 1, "Same car_rid should not appear twice in tie list"); } #[test] fn test_update_best_car_same_car_different_config_slower_not_added() { // A slower config of the same car is simply discarded (existing rule). let ctx = AnalysisContext::new(); let mut record_323 = make_record("Car_A", 85, 100.0, "guid_a"); record_323.normalized_config = "323".to_string(); let mut record_332 = make_record("Car_A", 85, 100.4, "guid_a"); record_332.normalized_config = "332".to_string(); update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record_323); update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record_332); let records = ctx.best_cars.get(&("laguna".to_string(), 85, "Dry".to_string())).unwrap(); assert_eq!(records.len(), 1); assert_eq!(records[0].normalized_config, "323"); } #[test] fn test_update_best_car_same_car_different_config_faster_replaces() { // A strictly faster config of the same car replaces the entry. let ctx = AnalysisContext::new(); let mut record_323 = make_record("Car_A", 85, 100.0, "guid_a"); record_323.normalized_config = "323".to_string(); let mut record_332 = make_record("Car_A", 85, 99.0, "guid_a"); record_332.normalized_config = "332".to_string(); update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record_323); update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record_332); let records = ctx.best_cars.get(&("laguna".to_string(), 85, "Dry".to_string())).unwrap(); assert_eq!(records.len(), 1); assert_eq!(records[0].normalized_config, "332", "Faster config replaces the slower one"); } // ======================================================================= // parse_car_times tests // ======================================================================= #[test] fn test_parse_car_times_valid_json() { let ctx = AnalysisContext::new(); let car = Car { rid: "TestCar_2020".to_string(), brand: "TestBrand".to_string(), only_name: "TestModel".to_string(), year: 2020, rq: 85, prize: false, guid: "guid_test".to_string(), }; // Create a temporary directory and timing file let dir = tempfile::tempdir().unwrap(); let times_dir = dir.path().to_str().unwrap(); let file_path = dir.path().join("TestCar_2020.json"); let json_content = r#"{ "data": { "111": { "times": { "laguna_a00": { "t": 112.45, "u": "Player1" }, "mnCity_a01": { "t": 45.23, "u": "Player2" } } } } }"#; fs::write(&file_path, json_content).unwrap(); let my_cars: HashSet = HashSet::new(); parse_car_times(car, &my_cars, &ctx.best_cars, times_dir).unwrap(); // Should have entries for RQ 85..=113 for both tracks assert!(ctx.best_cars.contains_key(&("Laguna Seca".to_string(), 85, "Dry".to_string()))); assert!(ctx.best_cars.contains_key(&("Monaco Streets".to_string(), 85, "Wet".to_string()))); } #[test] fn test_parse_car_times_skips_missing_or_non_numeric_time() { let ctx = AnalysisContext::new(); let car = Car { rid: "TestCar_2020".to_string(), brand: "TestBrand".to_string(), only_name: "TestModel".to_string(), year: 2020, rq: 85, prize: false, guid: "guid_test".to_string(), }; let dir = tempfile::tempdir().unwrap(); let times_dir = dir.path().to_str().unwrap(); let file_path = dir.path().join("TestCar_2020.json"); // "t" missing entirely, null, and a string — all must be skipped // without panicking or inserting a record. let json_content = r#"{ "data": { "111": { "times": { "laguna_a00": { "u": "Player1" }, "mnCity_a01": { "t": null, "u": "Player2" }, "testBowl_a02": { "t": "112.45", "u": "Player3" }, "butte_a01": { "t": 50.0, "u": "Player4" } } } } }"#; fs::write(&file_path, json_content).unwrap(); let my_cars: HashSet = HashSet::new(); parse_car_times(car, &my_cars, &ctx.best_cars, times_dir).unwrap(); // Only the valid butte entry survives. assert!(!ctx.best_cars.contains_key(&("Laguna Seca".to_string(), 85, "Dry".to_string()))); assert!(!ctx.best_cars.contains_key(&( "Monaco Streets".to_string(), 85, "Wet".to_string() ))); assert!( ctx.best_cars.contains_key(&("Butte".to_string(), 85, "Wet".to_string())), "valid time entry should still be parsed" ); } #[test] fn test_parse_car_times_filters_downvoted() { let ctx = AnalysisContext::new(); let car = Car { rid: "TestCar_2020".to_string(), brand: "TestBrand".to_string(), only_name: "TestModel".to_string(), year: 2020, rq: 85, prize: false, guid: "guid_test".to_string(), }; let dir = tempfile::tempdir().unwrap(); let times_dir = dir.path().to_str().unwrap(); let file_path = dir.path().join("TestCar_2020.json"); let json_content = r#"{ "data": { "111": { "times": { "laguna_a00": { "t": 112.45, "u": "Player1", "down": ["Player3"] } } } } }"#; fs::write(&file_path, json_content).unwrap(); let my_cars: HashSet = HashSet::new(); parse_car_times(car, &my_cars, &ctx.best_cars, times_dir).unwrap(); // Downvoted time should be filtered out assert!( !ctx.best_cars.contains_key(&("Laguna Seca".to_string(), 85, "Dry".to_string())), "Downvoted times should be excluded" ); } #[test] fn test_parse_car_times_test_bowl_inversion() { let ctx = AnalysisContext::new(); let car = Car { rid: "TestCar_2020".to_string(), brand: "TestBrand".to_string(), only_name: "TestModel".to_string(), year: 2020, rq: 85, prize: false, guid: "guid_test".to_string(), }; let dir = tempfile::tempdir().unwrap(); let times_dir = dir.path().to_str().unwrap(); let file_path = dir.path().join("TestCar_2020.json"); let json_content = r#"{ "data": { "111": { "times": { "testBowl_a00": { "t": 50.0, "u": "Player1" } } } } }"#; fs::write(&file_path, json_content).unwrap(); let my_cars: HashSet = HashSet::new(); 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(); assert_eq!(records[0].time, 40.0); } #[test] fn test_parse_car_times_missing_file() { let ctx = AnalysisContext::new(); let car = Car { rid: "NonExistent".to_string(), brand: "TestBrand".to_string(), only_name: "TestModel".to_string(), year: 2020, rq: 85, prize: false, guid: "guid_test".to_string(), }; let my_cars: HashSet = HashSet::new(); // Should not panic — just print a warning and return Ok(()) parse_car_times(car, &my_cars, &ctx.best_cars, "/nonexistent/times").unwrap(); assert_eq!(ctx.best_cars.len(), 0, "No entries for missing file"); } // ======================================================================= // Track name / type tests (from lib) // ======================================================================= #[test] fn test_translate_track_name_known_tracks() { 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"); } #[test] fn test_translate_track_type_known_conditions() { 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("99"), "99"); } // ======================================================================= // Prefix stripping tests // ======================================================================= #[test] fn test_parse_car_times_strips_v2b_prefix() { let ctx = AnalysisContext::new(); let car = Car { rid: "TestCar_2020".to_string(), brand: "TestBrand".to_string(), only_name: "TestModel".to_string(), year: 2020, rq: 85, prize: false, guid: "guid_test".to_string(), }; let dir = tempfile::tempdir().unwrap(); let times_dir = dir.path().to_str().unwrap(); let file_path = dir.path().join("TestCar_2020.json"); // Use "v2b_233" prefix — previously not stripped let json_content = r#"{ "data": { "v2b_233": { "times": { "laguna_a00": { "t": 100.0, "u": "Player1" } } } } }"#; fs::write(&file_path, json_content).unwrap(); let my_cars: HashSet = HashSet::new(); parse_car_times(car, &my_cars, &ctx.best_cars, times_dir).unwrap(); // Verify the config was stripped to "233" (not "v2b_233") let records = ctx.best_cars.get(&("Laguna Seca".to_string(), 85, "Dry".to_string())).unwrap(); assert_eq!(records[0].config, "233", "v2b_ prefix should be stripped"); assert_eq!(records[0].normalized_config, "233", "233 is already a final build"); } #[test] fn test_parse_car_times_strips_various_prefixes() { let ctx = AnalysisContext::new(); let car = Car { rid: "TestCar_2021".to_string(), brand: "TestBrand".to_string(), only_name: "TestModel".to_string(), year: 2021, rq: 90, prize: false, guid: "guid_test2".to_string(), }; let dir = tempfile::tempdir().unwrap(); let times_dir = dir.path().to_str().unwrap(); let file_path = dir.path().join("TestCar_2021.json"); // Mix of prefix styles: v2c_, v1c_, v3a_, and bare let json_content = r#"{ "data": { "v2c_111": { "times": { "laguna_a00": { "t": 110.0, "u": "P1" } } }, "v3a_233": { "times": { "laguna_a00": { "t": 95.0, "u": "P2" } } }, "323": { "times": { "mnCity_a01": { "t": 88.0, "u": "P3" } } } } }"#; fs::write(&file_path, json_content).unwrap(); let my_cars: HashSet = HashSet::new(); parse_car_times(car, &my_cars, &ctx.best_cars, times_dir).unwrap(); // Best on Laguna Seca should be the v3a_233 entry (95.0 beats 110.0) let records = ctx.best_cars.get(&("Laguna Seca".to_string(), 90, "Dry".to_string())).unwrap(); assert_eq!(records[0].config, "233", "v3a_ prefix should be stripped"); assert_eq!(records[0].time, 95.0); // Monaco Streets from bare "323" let records = ctx.best_cars.get(&("Monaco Streets".to_string(), 90, "Wet".to_string())).unwrap(); assert_eq!(records[0].config, "323", "bare config should pass through"); } }