//! Analysis context and aggregated car counts. //! //! Contains the mutable analysis state (`AnalysisContext`) and the //! aggregated per-car win counts (`CarCounts`) computed from it. use std::collections::{HashMap, HashSet}; use std::sync::Arc; use dashmap::DashMap; use crate::types::{BestCarRecord, Car, TripletKey}; /// Holds the mutable analysis state for a records analysis run. /// /// Replaces the former global `static BEST_CARS`. Created in `main()` and /// passed through the call chain so that no global mutable state is needed. /// /// Uses `Arc` for concurrent access from spawned tasks. /// `Arc::clone` only increments the reference count, so all tasks share /// the same underlying map. pub struct AnalysisContext { /// Best car records for each track/RQ/surface combination. /// Wrapped in `Arc` so it can be cheaply shared across async tasks. pub best_cars: Arc>>, } impl Default for AnalysisContext { fn default() -> Self { Self::new() } } impl AnalysisContext { /// Create a new, empty analysis context. pub fn new() -> Self { 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, 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>, /// 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, /// Full car list from the database (kept for any callers that need iteration). pub car_list: Vec, /// 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, } /// Computes per-car win counts from the analysis context. /// /// Returns a `CarCounts` struct that can be shared across all output functions. /// This function cannot fail — it only performs `DashMap` iteration and /// `HashMap` inserts. /// /// 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, owned_configs: HashMap, ) -> CarCounts { let mut by_rid: HashMap<(String, String), (BestCarRecord, usize)> = HashMap::new(); let mut guid_to_rid: HashMap> = HashMap::new(); for entry in ctx.best_cars.iter() { for best_car in entry.value() { 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+config. if best_car.time < rid_entry.0.time { rid_entry.0 = best_car.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 = car_list.iter().map(|c| (c.guid.clone(), c.clone())).collect(); CarCounts { by_rid, guid_to_rid, car_map, car_list, owned_configs } } #[cfg(test)] mod tests { use super::*; fn empty_owned_configs() -> HashMap { HashMap::new() } #[test] fn test_compute_car_counts_basic() { let ctx = AnalysisContext::new(); let record = BestCarRecord { car_rid: "Test_Car".to_string(), brand: "TestBrand".to_string(), only_name: "TestModel".to_string(), year: 2020, rq: 85, config: "111".to_string(), normalized_config: "332".to_string(), time: 100.0, owned: true, prize: false, guid: "guid_test".to_string(), }; // Manually insert into best_cars ctx.best_cars .insert(("Laguna Seca".to_string(), 85, "Dry".to_string()), vec![record.clone()]); ctx.best_cars .insert(("Monaco Streets".to_string(), 85, "Dry".to_string()), vec![record.clone()]); let car_list = vec![Car { rid: "Test_Car".to_string(), brand: "TestBrand".to_string(), only_name: "TestModel".to_string(), year: 2020, rq: 85, prize: false, guid: "guid_test".to_string(), }]; let 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".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 → 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")); assert_eq!(counts.car_list.len(), 1); } #[test] fn test_compute_car_counts_keeps_best_time() { let ctx = AnalysisContext::new(); let record_fast = BestCarRecord { car_rid: "Car_A".to_string(), brand: "Brand".to_string(), only_name: "Model".to_string(), year: 2020, rq: 85, config: "111".to_string(), normalized_config: "332".to_string(), time: 90.0, owned: false, prize: false, guid: "guid_a".to_string(), }; let record_slow = BestCarRecord { car_rid: "Car_A".to_string(), brand: "Brand".to_string(), only_name: "Model".to_string(), year: 2020, rq: 85, config: "233".to_string(), 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]); 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); } }