diff --git a/Cargo.toml b/Cargo.toml index ad23993..ed0866c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ bin-logging = ["dep:env_logger"] test-utils = [] [dev-dependencies] -top_drives = { path = ".", features = ["test-utils"] } +top_drives = { path = ".", features = ["test-utils"], default-features = false } tempfile = "3.10.1" proptest = "1" mockito = "1" diff --git a/src/output/mod.rs b/src/output/mod.rs index 1cc539f..a13305e 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -249,6 +249,13 @@ pub fn write_trash_csv( /// of track, surface condition, and RQ level. Results are sorted by RQ then track ID /// for consistent output ordering. /// +/// # Memory +/// +/// Takes a full snapshot of the `best_cars` DashMap (cloning every key and record +/// list) so it can be sorted and written without holding DashMap shard locks. +/// Peak memory roughly doubles for the duration of the write — accepted here +/// because this runs once at the end of analysis, not in a hot loop. +/// /// # Output Format /// ```csv /// RQ,TrackID,Condition,CarRQ,Brand,Model,Year,Config,Time diff --git a/src/records.rs b/src/records.rs index ee3f435..327688f 100644 --- a/src/records.rs +++ b/src/records.rs @@ -127,7 +127,14 @@ 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 { + /// Human-readable track name (e.g. "Laguna Seca", not the raw code "laguna") + track: String, + /// Human-readable surface condition (e.g. "Dry", "Wet"; not the raw code "00") + condition: String, + /// RQ level (car's RQ up to 113) + rq: u8, + }, /// Generate improve.csv (owned cars not in final config, sorted by best tracks) Improve, } @@ -152,6 +159,7 @@ enum Commands { async fn main() { // Default to warn level so library/bin warnings stay visible without RUST_LOG; // RUST_LOG can still silence or raise verbosity. + #[cfg(feature = "bin-logging")] env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init(); if let Err(e) = run().await { eprintln!("Error: {e}"); diff --git a/src/user_data.rs b/src/user_data.rs index 39d119a..cbae7cd 100644 --- a/src/user_data.rs +++ b/src/user_data.rs @@ -67,8 +67,9 @@ pub fn load_my_cars_data(path: &str) -> Result { 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()); - *copy_count.entry(guid.to_string()).or_insert(0) += 1; + let guid = guid.to_string(); + 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. @@ -77,7 +78,7 @@ pub fn load_my_cars_data(path: &str) -> Result { 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.to_string()).or_default().push(config); + owned_configs.entry(guid).or_default().push(config); } else { log::warn!("locked card {guid} has incomplete tune data — skipped"); } diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 6bc80a5..18514e6 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -240,6 +240,7 @@ fn test_load_my_cars_data_with_fixture() { let json = r#"{ "playerDeck": [ { "cardId": "guid_a", "locked": true, "engineMajor": 3, "weightMajor": 2, "chassisMajor": 3 }, + { "cardId": "guid_a", "locked": true, "engineMajor": 2, "weightMajor": 3, "chassisMajor": 3 }, { "cardId": "guid_b", "locked": false, "engineMajor": 2, "weightMajor": 3, "chassisMajor": 3 }, { "cardId": "guid_c", "locked": true, "engineMajor": 1, "weightMajor": 1, "chassisMajor": 1 } ], @@ -251,15 +252,19 @@ fn test_load_my_cars_data_with_fixture() { let data = load_my_cars_data(path.to_str().unwrap()).unwrap(); - // Only locked cards are owned + // Only locked cards are owned (set deduplicates duplicate guids) assert_eq!(data.owned.len(), 2); assert!(data.owned.contains("guid_a")); assert!(data.owned.contains("guid_c")); assert!(!data.owned.contains("guid_b")); - // owned_configs only for locked cards with complete tune data - assert_eq!(data.owned_configs.len(), 2); - assert_eq!(data.owned_configs.get("guid_a"), Some(&vec!["323".to_string()])); + // copy_count tracks duplicates: guid_a has two copies, guid_c one + assert_eq!(data.copy_count.get("guid_a"), Some(&2)); + assert_eq!(data.copy_count.get("guid_c"), Some(&1)); + assert!(!data.copy_count.contains_key("guid_b")); + + // owned_configs collects one entry per copy with complete tune data + assert_eq!(data.owned_configs.get("guid_a"), Some(&vec!["323".to_string(), "233".to_string()])); assert_eq!(data.owned_configs.get("guid_c"), Some(&vec!["111".to_string()])); // Unlocked card should not be in owned_configs assert!(!data.owned_configs.contains_key("guid_b")); @@ -289,6 +294,11 @@ fn test_load_my_cars_data_incomplete_tune_data() { // All locked cards are owned assert_eq!(data.owned.len(), 3); + // copy_count counts every locked card, even without complete tune data + assert_eq!(data.copy_count.get("guid_full"), Some(&1)); + assert_eq!(data.copy_count.get("guid_partial"), Some(&1)); + assert_eq!(data.copy_count.get("guid_empty"), Some(&1)); + // Only the card with complete tune data has a config entry assert_eq!(data.owned_configs.len(), 1); assert_eq!(data.owned_configs.get("guid_full"), Some(&vec!["233".to_string()]));