diff --git a/Cargo.toml b/Cargo.toml index 8373ee6..ad23993 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,9 +7,12 @@ edition = "2021" name = "top_drives" path = "src/lib.rs" +# Bin-only (logger init in records.rs); required-features keeps the lib crate's +# downstream consumers free of env_logger unless they opt in. [[bin]] name = "records" path = "src/records.rs" +required-features = ["bin-logging"] [dependencies] reqwest = "0.12.15" @@ -21,10 +24,11 @@ clap = { version = "4", features = ["derive"] } thiserror = "2" dashmap = "6" log = "0.4" -env_logger = "0.11" +env_logger = { version = "0.11", optional = true } [features] -default = [] +default = ["bin-logging", "test-utils"] +bin-logging = ["dep:env_logger"] test-utils = [] [dev-dependencies] diff --git a/src/output/mod.rs b/src/output/mod.rs index 9d94812..1cc539f 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -6,7 +6,7 @@ use std::collections::{HashMap, HashSet}; use std::fs; use std::io::Write; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use top_drives::{ compare_cars, escape_csv_field, is_final_build, AnalysisContext, AppError, BestCarRecord, Car, @@ -55,7 +55,7 @@ pub fn compare_records(a: &BestCarRecord, b: &BestCarRecord) -> std::cmp::Orderi /// If the write callback returns an error, the temporary file is cleaned up /// and no partial file is left at the target path. fn atomic_write( - path: &PathBuf, + path: &Path, write_fn: impl FnOnce(&mut fs::File) -> Result<(), AppError>, ) -> Result<(), AppError> { let tmp_path = PathBuf::from(format!("{}.tmp", path.display())); @@ -179,11 +179,6 @@ pub fn write_very_best_csv( /// Non-final builds are excluded from category 2 because they /// belong in `improve.csv` instead. Prize cars and RQ ≥ 80 are always excluded. /// -/// **Known gap:** an owned non-final car that is not best on any track in *any* -/// config appears in neither trash.csv (not final yet) nor improve.csv (no best -/// config to suggest as a target). Such cars stay under the radar until they -/// either reach a final build or are manually reviewed. -/// /// Format: RQ,Brand,Model,Year,Config pub fn write_trash_csv( output_dir: &str, diff --git a/src/parsing/mod.rs b/src/parsing/mod.rs index 3788c1a..26e14a1 100644 --- a/src/parsing/mod.rs +++ b/src/parsing/mod.rs @@ -336,6 +336,15 @@ pub async fn parse_all_times( }); } + // 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) } @@ -394,7 +403,7 @@ 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}"); - println!("Fetching {url}."); + log::info!("Fetching {url}."); top_drives::get_file(&url, &local_file_path).await } @@ -544,6 +553,55 @@ mod tests { 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(); diff --git a/src/types.rs b/src/types.rs index 2d18f57..62f6b54 100644 --- a/src/types.rs +++ b/src/types.rs @@ -164,3 +164,43 @@ pub fn is_final_build(config: &str) -> bool { } bytes.iter().map(|&b| b - b'0').sum::() >= 8 } + +#[cfg(test)] +mod tests { + use super::is_final_build; + + #[test] + fn final_builds_have_digit_sum_eight() { + assert!(is_final_build("323")); + assert!(is_final_build("332")); + assert!(is_final_build("233")); + assert!(is_final_build("521")); + assert!(is_final_build("800")); + } + + #[test] + fn maxed_builds_beyond_eight_are_final() { + assert!(is_final_build("879")); + assert!(is_final_build("999")); + assert!(is_final_build("990")); + } + + #[test] + fn unfinished_builds_below_eight_are_not_final() { + assert!(!is_final_build("111")); + assert!(!is_final_build("221")); + assert!(!is_final_build("610")); + assert!(!is_final_build("070")); + } + + #[test] + fn malformed_configs_are_not_final() { + assert!(!is_final_build("")); + assert!(!is_final_build("12")); + assert!(!is_final_build("1234")); + assert!(!is_final_build("abc")); + assert!(!is_final_build("3a3")); + assert!(!is_final_build(" 33")); + assert!(!is_final_build("33 ")); + } +}