//! User data loading from `my_cars.json`. //! //! Handles parsing the game export file to extract owned car GUIDs //! and wishlist entries. use std::collections::{HashMap, HashSet}; use std::fs::File; use std::io::BufReader; use serde_json::Value; use crate::types::AppError; /// Parsed user data from `my_cars.json` /// /// Holds both the owned car GUIDs and wishlist GUIDs extracted from a single /// file read, avoiding duplicate I/O. #[derive(Debug, Default)] pub struct MyCarsData { /// GUIDs of cars the user owns (from `playerDeck[].cardId` where `locked == true`) pub owned: HashSet, /// GUIDs of wishlisted cars (from `wishlists[0].cards`) pub wishlist: HashSet, /// Actual tune configs for each owned car (guid → list of configs from all copies). /// Multiple copies of the same car model share the same guid, so this Vec /// captures all their individual configs. Previously a single String lost /// data from all but the last-processed copy. pub owned_configs: HashMap>, /// Number of copies owned for each car GUID. /// Multiple copies of the same car model share the same guid. /// `owned` HashSet deduplicates, so this counts duplicates. pub copy_count: HashMap, /// GUIDs of deck cards that are NOT locked (guid → number of unlocked copies). /// Union of garage and storage cards; used for the garage-space projection. pub unlocked: HashMap, /// Unlocked cards in the garage (`state != 0`, `locked == false`; guid → /// copy count). Candidates for protect.csv. pub garage_unlocked: HashMap, /// Cards in the storage (`state == 0`, any lock state; guid → copy count). /// Candidates for storage.csv recommendations. pub storage: HashMap, /// Total garage slot capacity (from `user.garageSlots`), 0 if absent. pub garage_slots: usize, /// Occupied garage slots (from `user.garageSlotsUsed`), 0 if absent. pub garage_slots_used: usize, } /// Loads and parses `my_cars.json` once, extracting both owned and wishlist data. /// /// Opens the game export file a single time and extracts: /// - **Owned cars** from `playerDeck[].cardId` (filtered by `locked == true`) /// - **Wishlist** from `wishlists[0].cards` /// /// # File Source /// /// * **File**: `my_cars.json` (exported from Top Drives game) /// /// # Error Handling /// /// Returns an `AppError` if the file cannot be opened or parsed. /// Use `MyCarsData::default()` for graceful degradation when the file is optional. pub fn load_my_cars_data(path: &str) -> Result { let file = File::open(path).map_err(|e| { if e.kind() == std::io::ErrorKind::NotFound { AppError::MissingFile(format!("{path}: {e}")) } else { AppError::Io(e) } })?; let reader = BufReader::new(file); 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 and their actual configs from playerDeck let mut owned: HashSet = HashSet::new(); let mut owned_configs: HashMap> = HashMap::new(); let mut copy_count: HashMap = HashMap::new(); let mut unlocked: HashMap = HashMap::new(); let mut garage_unlocked: HashMap = HashMap::new(); let mut storage: HashMap = HashMap::new(); if let Some(deck) = json.get("playerDeck").and_then(|d| d.as_array()) { for card in deck { let Some(guid) = card.get("cardId").and_then(|v| v.as_str()) else { continue; }; let guid = guid.to_string(); let locked = card.get("locked").and_then(|v| v.as_bool()).unwrap_or(false); // state 0 = storage ("Lager"), anything else = garage. let in_storage = card.get("state").and_then(|v| v.as_i64()) == Some(0); if locked { owned.insert(guid.clone()); *copy_count.entry(guid.clone()).or_insert(0) += 1; // 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.entry(guid.clone()).or_default().push(config); } else { log::warn!("locked card {guid} has incomplete tune data — skipped"); } } else { *unlocked.entry(guid.clone()).or_insert(0) += 1; if !in_storage { *garage_unlocked.entry(guid.clone()).or_insert(0) += 1; } } if in_storage { *storage.entry(guid).or_insert(0) += 1; } } } let garage_slots = json.get("user").and_then(|u| u.get("garageSlots")).and_then(|v| v.as_u64()).unwrap_or(0) as usize; let garage_slots_used = json .get("user") .and_then(|u| u.get("garageSlotsUsed")) .and_then(|v| v.as_u64()) .unwrap_or(0) as usize; // Extract wishlist GUIDs from wishlists[0].cards let wishlist: HashSet = json .get("wishlists") .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()) .unwrap_or_default(); Ok(MyCarsData { owned, wishlist, owned_configs, copy_count, unlocked, garage_unlocked, storage, garage_slots, garage_slots_used, }) } #[cfg(test)] mod tests { use super::*; #[test] fn test_load_my_cars_data_missing_file() { let result = load_my_cars_data("nonexistent_file.json"); assert!(result.is_err()); match result.unwrap_err() { AppError::MissingFile(msg) => assert!(msg.contains("nonexistent_file.json")), other => panic!("Expected MissingFile error, got: {other}"), } } #[test] fn test_load_my_cars_data_parses_deck_and_garage() { let json = r#"{ "user": { "garageSlots": 100, "garageSlotsUsed": 90 }, "playerDeck": [ { "cardId": "g1", "state": 1, "locked": true, "engineMajor": 3, "weightMajor": 2, "chassisMajor": 3 }, { "cardId": "g2", "state": 0, "locked": false }, { "cardId": "g2", "state": 0, "locked": false }, { "cardId": "g3", "state": 1, "locked": false }, { "cardId": "g4", "state": 0, "locked": true } ], "wishlists": [ { "cards": ["g9"] } ] }"#; let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("my_cars.json"); std::fs::write(&path, json).unwrap(); let data = load_my_cars_data(path.to_str().unwrap()).unwrap(); assert!(data.owned.contains("g1")); assert!(data.owned.contains("g4"), "locked storage cards still count as owned"); assert_eq!( data.owned_configs.get("g1").map(|c| c.as_slice()), Some(&["323".to_string()][..]) ); assert!(!data.owned.contains("g2"), "unlocked cards must not count as owned"); assert_eq!(data.unlocked.get("g2"), Some(&2), "two unlocked copies"); assert_eq!(data.unlocked.get("g3"), Some(&1)); assert_eq!(data.garage_unlocked.get("g3"), Some(&1), "unlocked garage card"); assert!(!data.garage_unlocked.contains_key("g2"), "storage card is not garage"); assert_eq!(data.storage.get("g2"), Some(&2), "two storage copies"); assert_eq!(data.storage.get("g4"), Some(&1), "locked storage card"); assert!(data.wishlist.contains("g9")); assert_eq!(data.garage_slots, 100); assert_eq!(data.garage_slots_used, 90); } #[test] fn test_load_my_cars_data_without_garage_info() { let json = r#"{ "playerDeck": [] }"#; let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("my_cars.json"); std::fs::write(&path, json).unwrap(); let data = load_my_cars_data(path.to_str().unwrap()).unwrap(); assert_eq!(data.garage_slots, 0); assert_eq!(data.garage_slots_used, 0); assert!(data.unlocked.is_empty()); } }