Address full-codebase review: logging boundary, clone elimination, robustness

Library no longer prints to stderr:
- Replace every eprintln! with log::warn! (lib: user_data, http; bin:
  parsing, output, commands); initialize env_logger in main with a
  warn-level default so output is unchanged unless RUST_LOG is set
- Warn on missing/non-numeric "t" time fields instead of silently
  coercing them to 0.0; suppress the per-car NotFound warning (most
  cars legitimately have no timing file), keep warnings for real I/O
  errors

Ownership and clone cleanup:
- CarCounts no longer duplicates the car database: drop the car_list
  field and build car_map by consuming car_list (each Car stored once);
  iterate car_map.values() where the list was previously used
- run_analysis takes MyCarsData by value: owned moves into the parse
  Arc, owned_configs/copy_count move into CarCounts; only the three
  commands that need owned/wishlist afterwards retain their own copy
- is_final_build checks the digits directly (no per-call String
  allocation) and treats malformed configs as non-final so they
  surface in improve.csv; owned_final_configs becomes a borrowed
  rid-to-configs map so the target-exclusion check allocates nothing
- Normalize sum arithmetic to u8 in normalize_to_final_build

Docs and structure:
- Document the first-seen-wins tie-breaking in compute_ideal_wishlist
  and the non-final/non-best coverage gap in write_trash_csv
- Extract compare_by_rq_config_brand_name shared by compare_records
  and the remove.csv sort; drop the unused _output_dir parameter from
  run_track; add a retry_delay helper de-duplicating the three HTTP
  backoff sites; rename the shadowed `entry` binding to config_data
This commit is contained in:
Jan-Peter von Hunnius 2026-08-15 19:29:51 +02:00
parent 39d1e1d71b
commit 58b8d23e68
17 changed files with 255 additions and 120 deletions

131
Cargo.lock generated
View file

@ -110,6 +110,12 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.11.1" version = "2.11.1"
@ -245,6 +251,37 @@ dependencies = [
"parking_lot_core", "parking_lot_core",
] ]
[[package]]
name = "defmt"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
dependencies = [
"bitflags 1.3.2",
"defmt-macros",
]
[[package]]
name = "defmt-macros"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
dependencies = [
"defmt-parser",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "defmt-parser"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
dependencies = [
"thiserror",
]
[[package]] [[package]]
name = "displaydoc" name = "displaydoc"
version = "0.2.5" version = "0.2.5"
@ -265,6 +302,29 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "env_filter"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217"
dependencies = [
"log",
"regex",
]
[[package]]
name = "env_logger"
version = "0.11.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6"
dependencies = [
"anstream",
"anstyle",
"env_filter",
"jiff",
"log",
]
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@ -761,6 +821,42 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jiff"
version = "0.2.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc"
dependencies = [
"defmt",
"jiff-core",
"jiff-static",
"log",
"portable-atomic",
"portable-atomic-util",
"serde_core",
]
[[package]]
name = "jiff-core"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09"
dependencies = [
"defmt",
]
[[package]]
name = "jiff-static"
version = "0.2.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204"
dependencies = [
"jiff-core",
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.97" version = "0.3.97"
@ -904,7 +1000,7 @@ version = "0.10.79"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.11.1",
"cfg-if", "cfg-if",
"foreign-types", "foreign-types",
"libc", "libc",
@ -982,6 +1078,21 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "portable-atomic"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
[[package]]
name = "portable-atomic-util"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
dependencies = [
"portable-atomic",
]
[[package]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.5" version = "0.1.5"
@ -1027,7 +1138,7 @@ checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
dependencies = [ dependencies = [
"bit-set", "bit-set",
"bit-vec", "bit-vec",
"bitflags", "bitflags 2.11.1",
"num-traits", "num-traits",
"rand", "rand",
"rand_chacha", "rand_chacha",
@ -1109,7 +1220,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.11.1",
] ]
[[package]] [[package]]
@ -1201,7 +1312,7 @@ version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.11.1",
"errno", "errno",
"libc", "libc",
"linux-raw-sys", "linux-raw-sys",
@ -1286,7 +1397,7 @@ version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.11.1",
"core-foundation 0.10.1", "core-foundation 0.10.1",
"core-foundation-sys", "core-foundation-sys",
"libc", "libc",
@ -1453,7 +1564,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.11.1",
"core-foundation 0.9.4", "core-foundation 0.9.4",
"system-configuration-sys", "system-configuration-sys",
] ]
@ -1577,7 +1688,9 @@ version = "0.3.0"
dependencies = [ dependencies = [
"clap", "clap",
"dashmap", "dashmap",
"env_logger",
"futures", "futures",
"log",
"mockito", "mockito",
"proptest", "proptest",
"reqwest", "reqwest",
@ -1610,7 +1723,7 @@ version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28f0d049ccfaa566e14e9663d304d8577427b368cb4710a20528690287a738b" checksum = "a28f0d049ccfaa566e14e9663d304d8577427b368cb4710a20528690287a738b"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.11.1",
"bytes", "bytes",
"futures-util", "futures-util",
"http", "http",
@ -1838,7 +1951,7 @@ version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.11.1",
"hashbrown 0.15.5", "hashbrown 0.15.5",
"indexmap", "indexmap",
"semver", "semver",
@ -2035,7 +2148,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bitflags", "bitflags 2.11.1",
"indexmap", "indexmap",
"log", "log",
"serde", "serde",

View file

@ -20,6 +20,8 @@ futures = "0.3.31"
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
thiserror = "2" thiserror = "2"
dashmap = "6" dashmap = "6"
log = "0.4"
env_logger = "0.11"
[features] [features]
default = [] default = []

View file

@ -18,19 +18,14 @@ pub async fn run_all(
max_wishlist: usize, max_wishlist: usize,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
let my_data = load_my_cars_or_warn(my_cars_path)?; let my_data = load_my_cars_or_warn(my_cars_path)?;
let (ctx, counts) = run_analysis(&my_data, cars_db_path, times_dir).await?; let owned = my_data.owned.clone();
let wishlist = my_data.wishlist.clone();
let (ctx, counts) = run_analysis(my_data, cars_db_path, times_dir).await?;
output::write_best_csv(output_dir, &counts)?; output::write_best_csv(output_dir, &counts)?;
output::write_very_best_csv(output_dir, &counts, min_count)?; output::write_very_best_csv(output_dir, &counts, min_count)?;
output::write_trash_csv(output_dir, &counts, &my_data.owned)?; output::write_trash_csv(output_dir, &counts, &owned)?;
output::write_details_csv(output_dir, &ctx)?; output::write_details_csv(output_dir, &ctx)?;
output::write_wishlist_files( output::write_wishlist_files(output_dir, &counts, &owned, &wishlist, min_count, max_wishlist)?;
output_dir,
&counts,
&my_data.owned,
&my_data.wishlist,
min_count,
max_wishlist,
)?;
output::write_improve_csv(output_dir, &counts)?; output::write_improve_csv(output_dir, &counts)?;
println!( println!(
"Written: best.csv, very_best.csv, trash.csv, details.csv, wishlist.csv, add.csv, remove.csv, improve.csv" "Written: best.csv, very_best.csv, trash.csv, details.csv, wishlist.csv, add.csv, remove.csv, improve.csv"

View file

@ -17,7 +17,7 @@ pub async fn run_best(
min_count: usize, min_count: usize,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
let my_data = load_my_cars_or_warn(my_cars_path)?; let my_data = load_my_cars_or_warn(my_cars_path)?;
let (_ctx, counts) = run_analysis(&my_data, cars_db_path, times_dir).await?; let (_ctx, counts) = run_analysis(my_data, cars_db_path, times_dir).await?;
write_best_csv(output_dir, &counts)?; write_best_csv(output_dir, &counts)?;
write_very_best_csv(output_dir, &counts, min_count)?; write_very_best_csv(output_dir, &counts, min_count)?;
println!("Written: best.csv, very_best.csv"); println!("Written: best.csv, very_best.csv");

View file

@ -16,7 +16,7 @@ pub async fn run_details(
output_dir: &str, output_dir: &str,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
let my_data = load_my_cars_or_warn(my_cars_path)?; let my_data = load_my_cars_or_warn(my_cars_path)?;
let (ctx, _counts) = run_analysis(&my_data, cars_db_path, times_dir).await?; let (ctx, _counts) = run_analysis(my_data, cars_db_path, times_dir).await?;
write_details_csv(output_dir, &ctx)?; write_details_csv(output_dir, &ctx)?;
println!("Written: details.csv"); println!("Written: details.csv");
Ok(()) Ok(())

View file

@ -16,7 +16,7 @@ pub async fn run_improve(
output_dir: &str, output_dir: &str,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
let my_data = load_my_cars_or_warn(my_cars_path)?; let my_data = load_my_cars_or_warn(my_cars_path)?;
let (_ctx, counts) = run_analysis(&my_data, cars_db_path, times_dir).await?; let (_ctx, counts) = run_analysis(my_data, cars_db_path, times_dir).await?;
let count = write_improve_csv(output_dir, &counts)?; let count = write_improve_csv(output_dir, &counts)?;
if count == 0 { if count == 0 {
println!("No cars to improve — all owned cars are already in final or maxed configs."); println!("No cars to improve — all owned cars are already in final or maxed configs.");

View file

@ -33,8 +33,8 @@ pub fn load_my_cars_or_warn(path: &str) -> Result<MyCarsData, AppError> {
match load_my_cars_data(path) { match load_my_cars_data(path) {
Ok(data) => Ok(data), Ok(data) => Ok(data),
Err(AppError::MissingFile(msg)) => { Err(AppError::MissingFile(msg)) => {
eprintln!("Warning: {msg}"); log::warn!("{msg}");
eprintln!(" Analysis will run without ownership data."); log::warn!("Analysis will run without ownership data.");
Ok(MyCarsData::default()) Ok(MyCarsData::default())
} }
Err(e) => Err(e), Err(e) => Err(e),
@ -45,23 +45,24 @@ pub fn load_my_cars_or_warn(path: &str) -> Result<MyCarsData, AppError> {
// Analysis orchestration // Analysis orchestration
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Common analysis setup: loads user data, parses all timing files, and computes car counts. /// Common analysis setup: parses all timing files and computes car counts.
///
/// Takes `my_data` by value: `owned` is shared with the parse tasks via `Arc`,
/// and `owned_configs`/`copy_count` are moved into [`CarCounts`]. Callers that
/// need `owned` or `wishlist` after analysis must retain their own copy before
/// calling this function.
/// ///
/// Returns the analysis context (for stdout commands) and the aggregated car counts /// Returns the analysis context (for stdout commands) and the aggregated car counts
/// (for CSV output commands). The car list is stored inside `CarCounts`. /// (for CSV output commands).
pub async fn run_analysis( pub async fn run_analysis(
my_data: &MyCarsData, my_data: MyCarsData,
cars_db_path: &str, cars_db_path: &str,
times_dir: &str, times_dir: &str,
) -> Result<(AnalysisContext, CarCounts), AppError> { ) -> Result<(AnalysisContext, CarCounts), AppError> {
let ctx = AnalysisContext::new(); let ctx = AnalysisContext::new();
let owned = Arc::new(my_data.owned.clone()); let MyCarsData { owned, wishlist: _, owned_configs, copy_count } = my_data;
let owned = Arc::new(owned);
let car_list = parse_all_times(owned, &ctx, cars_db_path, times_dir).await?; let car_list = parse_all_times(owned, &ctx, cars_db_path, times_dir).await?;
let counts = compute_car_counts( let counts = compute_car_counts(&ctx, car_list, owned_configs, copy_count);
&ctx,
car_list,
my_data.owned_configs.clone(),
my_data.copy_count.clone(),
);
Ok((ctx, counts)) Ok((ctx, counts))
} }

View file

@ -9,19 +9,16 @@ use crate::output::print_best_car;
/// Executes the track command: shows best car for a specific track/condition/RQ. /// Executes the track command: shows best car for a specific track/condition/RQ.
/// ///
/// Note: `_output_dir` is unused because the track command prints to stdout /// Prints to stdout rather than writing a CSV file, so no output directory is needed.
/// rather than writing a CSV file. It exists for API consistency with other
/// command signatures so the caller doesn't need per-command branching.
pub async fn run_track( pub async fn run_track(
my_cars_path: &str, my_cars_path: &str,
cars_db_path: &str, cars_db_path: &str,
times_dir: &str, times_dir: &str,
_output_dir: &str,
track: &str, track: &str,
condition: &str, condition: &str,
rq: u8, rq: u8,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
let my_data = load_my_cars_or_warn(my_cars_path)?; let my_data = load_my_cars_or_warn(my_cars_path)?;
let (ctx, _counts) = run_analysis(&my_data, cars_db_path, times_dir).await?; let (ctx, _counts) = run_analysis(my_data, cars_db_path, times_dir).await?;
print_best_car(&ctx, track, condition, rq) print_best_car(&ctx, track, condition, rq)
} }

View file

@ -17,8 +17,9 @@ pub async fn run_trash(
output_dir: &str, output_dir: &str,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
let my_data = load_my_cars_or_warn(my_cars_path)?; let my_data = load_my_cars_or_warn(my_cars_path)?;
let (_ctx, counts) = run_analysis(&my_data, cars_db_path, times_dir).await?; let owned = my_data.owned.clone();
write_trash_csv(output_dir, &counts, &my_data.owned)?; let (_ctx, counts) = run_analysis(my_data, cars_db_path, times_dir).await?;
write_trash_csv(output_dir, &counts, &owned)?;
println!("Written: trash.csv"); println!("Written: trash.csv");
Ok(()) Ok(())
} }

View file

@ -17,15 +17,10 @@ pub async fn run_wishlist(
max_wishlist: usize, max_wishlist: usize,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
let my_data = load_my_cars_or_warn(my_cars_path)?; let my_data = load_my_cars_or_warn(my_cars_path)?;
let (_ctx, counts) = run_analysis(&my_data, cars_db_path, times_dir).await?; let owned = my_data.owned.clone();
write_wishlist_files( let wishlist = my_data.wishlist.clone();
output_dir, let (_ctx, counts) = run_analysis(my_data, cars_db_path, times_dir).await?;
&counts, write_wishlist_files(output_dir, &counts, &owned, &wishlist, min_count, max_wishlist)?;
&my_data.owned,
&my_data.wishlist,
min_count,
max_wishlist,
)?;
println!("Written: wishlist.csv, add.csv, remove.csv"); println!("Written: wishlist.csv, add.csv, remove.csv");
Ok(()) Ok(())
} }

View file

@ -50,9 +50,9 @@ pub struct CarCounts {
pub guid_to_rid: HashMap<String, HashSet<(String, String)>>, pub guid_to_rid: HashMap<String, HashSet<(String, String)>>,
/// Map from guid → Car (for O(1) lookups of car metadata by GUID). /// 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. /// Built once in `compute_car_counts()` and reused by trash/wishlist output.
/// Sole owner of the car database — iterate `car_map.values()` where a
/// car list was previously used.
pub car_map: HashMap<String, Car>, pub car_map: HashMap<String, Car>,
/// Full car list from the database (kept for any callers that need iteration).
pub car_list: Vec<Car>,
/// Actual tune configs for each owned car (guid → list of configs from all copies). /// Actual tune configs for each owned car (guid → list of configs from all copies).
/// Used by the improve command to determine which cars are not yet at a final build. /// Used by the improve command to determine which cars are not yet at a final build.
pub owned_configs: HashMap<String, Vec<String>>, pub owned_configs: HashMap<String, Vec<String>>,
@ -94,10 +94,10 @@ pub fn compute_car_counts(
} }
// Build guid → Car lookup once, reused by trash/wishlist output functions. // Build guid → Car lookup once, reused by trash/wishlist output functions.
let car_map: HashMap<String, Car> = // Consumes car_list so each Car is stored exactly once.
car_list.iter().map(|c| (c.guid.clone(), c.clone())).collect(); let car_map: HashMap<String, Car> = car_list.into_iter().map(|c| (c.guid.clone(), c)).collect();
CarCounts { by_rid, guid_to_rid, car_map, car_list, owned_configs, copy_count } CarCounts { by_rid, guid_to_rid, car_map, owned_configs, copy_count }
} }
#[cfg(test)] #[cfg(test)]
@ -158,7 +158,7 @@ mod tests {
// car_map should have the car // car_map should have the car
assert!(counts.car_map.contains_key("guid_test")); assert!(counts.car_map.contains_key("guid_test"));
assert_eq!(counts.car_list.len(), 1); assert_eq!(counts.car_map.len(), 1);
} }
#[test] #[test]

View file

@ -9,6 +9,11 @@ use reqwest::get;
use crate::types::AppError; use crate::types::AppError;
/// Linear backoff between retry attempts: 500ms, 1000ms, 1500ms, ...
fn retry_delay(attempt: usize) -> std::time::Duration {
std::time::Duration::from_millis(500 * (attempt as u64 + 1))
}
/// Downloads a file from a URL and saves it locally. /// Downloads a file from a URL and saves it locally.
/// ///
/// Returns `Ok(())` on success, or an error describing what went wrong. /// Returns `Ok(())` on success, or an error describing what went wrong.
@ -39,15 +44,12 @@ pub async fn get_file(file_url: &str, local_file_path: &str) -> Result<(), AppEr
if !status.is_success() { if !status.is_success() {
// Only retry on server errors (5xx); client errors (4xx) are permanent. // Only retry on server errors (5xx); client errors (4xx) are permanent.
if status.is_server_error() && attempt < MAX_FETCH_RETRIES { if status.is_server_error() && attempt < MAX_FETCH_RETRIES {
eprintln!( log::warn!(
"Warning: HTTP {} from {file_url} (attempt {}/{MAX_FETCH_RETRIES}), retrying...", "HTTP {} from {file_url} (attempt {}/{MAX_FETCH_RETRIES}), retrying...",
status, status,
attempt + 1, attempt + 1,
); );
tokio::time::sleep(std::time::Duration::from_millis( tokio::time::sleep(retry_delay(attempt)).await;
500 * (attempt + 1) as u64,
))
.await;
attempt += 1; attempt += 1;
continue; continue;
} }
@ -57,14 +59,11 @@ pub async fn get_file(file_url: &str, local_file_path: &str) -> Result<(), AppEr
Ok(content) => { Ok(content) => {
if content.is_empty() { if content.is_empty() {
if attempt < MAX_FETCH_RETRIES { if attempt < MAX_FETCH_RETRIES {
eprintln!( log::warn!(
"Warning: empty response from {file_url} (attempt {}/{MAX_FETCH_RETRIES}), retrying...", "empty response from {file_url} (attempt {}/{MAX_FETCH_RETRIES}), retrying...",
attempt + 1, attempt + 1,
); );
tokio::time::sleep(std::time::Duration::from_millis( tokio::time::sleep(retry_delay(attempt)).await;
500 * (attempt + 1) as u64,
))
.await;
attempt += 1; attempt += 1;
continue; continue;
} }
@ -84,14 +83,11 @@ pub async fn get_file(file_url: &str, local_file_path: &str) -> Result<(), AppEr
} }
Err(error) => { Err(error) => {
if attempt < MAX_FETCH_RETRIES { if attempt < MAX_FETCH_RETRIES {
eprintln!( log::warn!(
"Warning: failed to fetch {file_url} (attempt {}/{MAX_FETCH_RETRIES}): {error}", "failed to fetch {file_url} (attempt {}/{MAX_FETCH_RETRIES}): {error}",
attempt + 1, attempt + 1,
); );
tokio::time::sleep(std::time::Duration::from_millis( tokio::time::sleep(retry_delay(attempt)).await;
500 * (attempt + 1) as u64,
))
.await;
attempt += 1; attempt += 1;
continue; continue;
} }

View file

@ -25,16 +25,25 @@ const TRASH_MIN_RQ: u8 = 80;
// Sorting helpers // Sorting helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Field-wise ordering shared by record-based CSV outputs:
/// RQ, then normalized config, then brand, then model name.
fn compare_by_rq_config_brand_name(
(rq_a, config_a, brand_a, name_a): (&u8, &str, &str, &str),
(rq_b, config_b, brand_b, name_b): (&u8, &str, &str, &str),
) -> std::cmp::Ordering {
rq_a.cmp(rq_b).then(config_a.cmp(config_b)).then(brand_a.cmp(brand_b)).then(name_a.cmp(name_b))
}
/// Comparison function for sorting `BestCarRecord`s by RQ, then normalized config, /// Comparison function for sorting `BestCarRecord`s by RQ, then normalized config,
/// then brand, then model name. /// then brand, then model name.
/// ///
/// Avoids cloning into a `Car` just for sorting — uses the same ordering as /// Avoids cloning into a `Car` just for sorting — uses the same ordering as
/// `compare_cars` but operates directly on `BestCarRecord`. /// `compare_cars` but operates directly on `BestCarRecord`.
pub fn compare_records(a: &BestCarRecord, b: &BestCarRecord) -> std::cmp::Ordering { pub fn compare_records(a: &BestCarRecord, b: &BestCarRecord) -> std::cmp::Ordering {
a.rq.cmp(&b.rq) compare_by_rq_config_brand_name(
.then(a.normalized_config.cmp(&b.normalized_config)) (&a.rq, &a.normalized_config, &a.brand, &a.only_name),
.then(a.brand.cmp(&b.brand)) (&b.rq, &b.normalized_config, &b.brand, &b.only_name),
.then(a.only_name.cmp(&b.only_name)) )
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -167,9 +176,14 @@ pub fn write_very_best_csv(
/// 2. Its actual tune config is a final/maxed build (sum ≥ 8) that is not /// 2. Its actual tune config is a final/maxed build (sum ≥ 8) that is not
/// best on any track — even if other configs of the same car model are best. /// best on any track — even if other configs of the same car model are best.
/// ///
/// Non-final builds (sum < 8) are excluded from category 2 because they /// Non-final builds are excluded from category 2 because they
/// belong in `improve.csv` instead. Prize cars and RQ ≥ 80 are always excluded. /// 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 /// Format: RQ,Brand,Model,Year,Config
pub fn write_trash_csv( pub fn write_trash_csv(
output_dir: &str, output_dir: &str,
@ -286,7 +300,9 @@ pub fn write_details_csv(output_dir: &str, ctx: &AnalysisContext) -> Result<(),
/// ///
/// A car qualifies only when **one config** reaches `min_count` tracks — counts are /// A car qualifies only when **one config** reaches `min_count` tracks — counts are
/// NOT summed across different configs. If multiple configs for the same car qualify, /// NOT summed across different configs. If multiple configs for the same car qualify,
/// the one with the highest count is kept as the representative entry. /// the one with the highest count is kept as the representative entry; on equal
/// counts the first record seen in `by_rid` iteration order wins (deterministic
/// but arbitrary, since `by_rid` is a `HashMap`).
/// ///
/// **Owned cars** are included only if the player has a **final build** that differs /// **Owned cars** are included only if the player has a **final build** that differs
/// from the qualifying config. This allows wishlisting a second copy for a different /// from the qualifying config. This allows wishlisting a second copy for a different
@ -454,9 +470,7 @@ pub fn write_wishlist_files(
}) })
.collect(); .collect();
if dropped_count > 0 { if dropped_count > 0 {
eprintln!( log::warn!("{dropped_count} wishlist car(s) not found in cars database and omitted from remove.csv");
"Warning: {dropped_count} wishlist car(s) not found in cars database and omitted from remove.csv"
);
} }
write_sorted_csv( write_sorted_csv(
@ -465,10 +479,10 @@ pub fn write_wishlist_files(
"RQ,Brand,Model,Year,FinalBuild,Count", "RQ,Brand,Model,Year,FinalBuild,Count",
&to_remove, &to_remove,
|a, b| { |a, b| {
a.rq.cmp(&b.rq) compare_by_rq_config_brand_name(
.then(a.normalized_config.cmp(&b.normalized_config)) (&a.rq, &a.normalized_config, &a.brand, &a.only_name),
.then(a.brand.cmp(&b.brand)) (&b.rq, &b.normalized_config, &b.brand, &b.only_name),
.then(a.only_name.cmp(&b.only_name)) )
}, },
|entry, file| { |entry, file| {
writeln!( writeln!(
@ -519,14 +533,19 @@ pub fn write_improve_csv(output_dir: &str, counts: &CarCounts) -> Result<usize,
// Pre-compute configs already owned as final builds, grouped by car_rid. // Pre-compute configs already owned as final builds, grouped by car_rid.
// This lets us exclude targets the player already has on another copy. // This lets us exclude targets the player already has on another copy.
let owned_final_configs: HashSet<(String, String)> = counts // Borrowed keys/values — the hot-path membership check below allocates nothing.
let owned_final_configs: HashMap<&str, HashSet<&str>> = counts
.owned_configs .owned_configs
.iter() .iter()
.filter_map(|(guid, configs)| counts.car_map.get(guid).map(|car| (car, configs))) .filter_map(|(guid, configs)| counts.car_map.get(guid).map(|car| (car, configs)))
.flat_map(|(car, configs)| { .flat_map(|(car, configs)| {
configs.iter().filter(|c| is_final_build(c)).map(|c| (car.rid.clone(), c.clone())) let rid = car.rid.as_str();
configs.iter().filter(|c| is_final_build(c)).map(move |c| (rid, c.as_str()))
}) })
.collect(); .fold(HashMap::new(), |mut map, (rid, config)| {
map.entry(rid).or_default().insert(config);
map
});
// Iterate over owned cars from the player's actual config data. // Iterate over owned cars from the player's actual config data.
// For each owned car, check if its actual config is NOT a final build. // For each owned car, check if its actual config is NOT a final build.
@ -540,25 +559,25 @@ pub fn write_improve_csv(output_dir: &str, counts: &CarCounts) -> Result<usize,
} }
let Some(car) = counts.car_map.get(guid) else { let Some(car) = counts.car_map.get(guid) else {
eprintln!("Warning: owned car {guid} not found in cars database — skipped for improve"); log::warn!("owned car {guid} not found in cars database — skipped for improve");
continue; continue;
}; };
// Collect target configs, excluding any already owned on another copy of this car. // Collect target configs, excluding any already owned on another copy of this car.
// Keep only the best remaining target (highest track count). // Keep only the best remaining target (highest track count).
if let Some(keys) = counts.guid_to_rid.get(guid) { if let Some(keys) = counts.guid_to_rid.get(guid) {
let best_target = keys let best_target =
.iter() keys.iter()
.filter_map(|key| { .filter_map(|key| {
let (record, track_count) = counts.by_rid.get(key)?; let (record, track_count) = counts.by_rid.get(key)?;
if owned_final_configs if owned_final_configs.get(car.rid.as_str()).is_some_and(|finals| {
.contains(&(car.rid.clone(), record.normalized_config.clone())) finals.contains(record.normalized_config.as_str())
{ }) {
return None; return None;
} }
Some((record.normalized_config.clone(), *track_count)) Some((record.normalized_config.clone(), *track_count))
}) })
.max_by_key(|(_, count)| *count); .max_by_key(|(_, target_count)| *target_count);
if let Some((target, track_count)) = best_target { if let Some((target, track_count)) = best_target {
entries.push(ImproveEntry { entries.push(ImproveEntry {
@ -1018,7 +1037,6 @@ mod tests {
assert!(counts.by_rid.is_empty()); assert!(counts.by_rid.is_empty());
assert!(counts.guid_to_rid.is_empty()); assert!(counts.guid_to_rid.is_empty());
assert!(counts.car_map.is_empty()); assert!(counts.car_map.is_empty());
assert!(counts.car_list.is_empty());
} }
#[test] #[test]

View file

@ -89,9 +89,10 @@ pub fn parse_car_times(
// Read file content // Read file content
let file_content = match fs::read_to_string(&local_file_path) { let file_content = match fs::read_to_string(&local_file_path) {
Ok(content) => content, 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) => { Err(e) => {
// File not found is expected for some cars — treat as non-fatal log::warn!("could not read {local_file_path}: {e}");
eprintln!("Warning: could not read {local_file_path}: {e}");
return Ok(()); return Ok(());
} }
}; };
@ -106,7 +107,7 @@ pub fn parse_car_times(
// Process each configuration variant (111, 233, 323, etc.) // Process each configuration variant (111, 233, 323, etc.)
if let Some(configurations) = json_data["data"].as_object() { if let Some(configurations) = json_data["data"].as_object() {
for (raw_config, entry) in configurations { for (raw_config, config_data) in configurations {
// Strip version prefix (e.g., "v2c_" from "v2c_233", "v2b_" from "v2b_111") // Strip version prefix (e.g., "v2c_" from "v2c_233", "v2b_" from "v2b_111")
// at read time. Downstream code only ever sees bare digit strings. // at read time. Downstream code only ever sees bare digit strings.
// Generic approach: find the last '_' and take what follows it, // Generic approach: find the last '_' and take what follows it,
@ -121,13 +122,20 @@ pub fn parse_car_times(
.map(|(_, suffix)| suffix.to_string()) .map(|(_, suffix)| suffix.to_string())
.unwrap_or_else(|| raw_config.clone()); .unwrap_or_else(|| raw_config.clone());
// Process timing data for each track // Process timing data for each track
if let Some(times) = entry["times"].as_object() { if let Some(times) = config_data["times"].as_object() {
for (track_id, details) in times { for (track_id, details) in times {
// Parse track ID to separate base track name from surface condition. // Parse track ID to separate base track name from surface condition.
// Use rsplit_once to split on the LAST "_a" so track names that // Use rsplit_once to split on the LAST "_a" so track names that
// contain "_a" (e.g. "airplaneHangars") are handled correctly. // contain "_a" (e.g. "airplaneHangars") are handled correctly.
if let Some((cleaned_track_id, track_type)) = track_id.rsplit_once("_a") { if let Some((cleaned_track_id, track_type)) = track_id.rsplit_once("_a") {
let mut time = details["t"].as_f64().unwrap_or(0.0); 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 down_votes = details["down"].as_array();
let no_down_votes = match down_votes { let no_down_votes = match down_votes {
@ -302,12 +310,12 @@ pub async fn parse_all_times(
match result { match result {
// Outer JoinError from tokio::spawn // Outer JoinError from tokio::spawn
Err(e) => { Err(e) => {
eprintln!("Warning: parsing task failed: {e}"); log::warn!("parsing task failed: {e}");
parse_errors += 1; parse_errors += 1;
} }
// parse_car_times or semaphore returned an error // parse_car_times or semaphore returned an error
Ok(Err(e)) => { Ok(Err(e)) => {
eprintln!("Warning: {e}"); log::warn!("{e}");
parse_errors += 1; parse_errors += 1;
} }
// parse_car_times succeeded // parse_car_times succeeded
@ -315,7 +323,7 @@ pub async fn parse_all_times(
} }
} }
if parse_errors > 0 { if parse_errors > 0 {
eprintln!("Warning: {parse_errors} car(s) had parse errors during analysis"); 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 every car failed to parse, the data is unusable — propagate the error.
@ -364,14 +372,14 @@ pub async fn fetch_times(cars_db_path: &str, times_dir: &str) -> Result<(), AppE
match result { match result {
Ok(Ok(())) => {} Ok(Ok(())) => {}
Ok(Err(e)) => { Ok(Err(e)) => {
eprintln!("{e}"); log::warn!("{e}");
if first_error.is_none() { if first_error.is_none() {
first_error = Some(e); first_error = Some(e);
} }
} }
Err(e) => { Err(e) => {
let msg = AppError::Network(format!("Task join error: {e}")); let msg = AppError::Network(format!("Task join error: {e}"));
eprintln!("{msg}"); log::warn!("{msg}");
if first_error.is_none() { if first_error.is_none() {
first_error = Some(msg); first_error = Some(msg);
} }

View file

@ -150,6 +150,9 @@ enum Commands {
/// ``` /// ```
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
// Default to warn level so library/bin warnings stay visible without RUST_LOG;
// RUST_LOG can still silence or raise verbosity.
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init();
if let Err(e) = run().await { if let Err(e) = run().await {
eprintln!("Error: {e}"); eprintln!("Error: {e}");
std::process::exit(1); std::process::exit(1);
@ -217,7 +220,6 @@ async fn run() -> Result<(), AppError> {
&cli.my_cars, &cli.my_cars,
&cli.cars_db, &cli.cars_db,
&cli.times_dir, &cli.times_dir,
&cli.output_dir,
&track, &track,
&condition, &condition,
rq, rq,

View file

@ -132,7 +132,7 @@ pub fn normalize_to_final_build(config: &str) -> String {
digits[i] = b - b'0'; digits[i] = b - b'0';
} }
let sum: i32 = digits.iter().map(|&d| d as i32).sum(); let sum: u8 = digits.iter().sum();
if sum >= 8 { if sum >= 8 {
// Already a final build (sum == 8) or maxed (sum > 8) — return as-is. // Already a final build (sum == 8) or maxed (sum > 8) — return as-is.
return config.to_string(); return config.to_string();
@ -152,8 +152,15 @@ pub fn normalize_to_final_build(config: &str) -> String {
/// Returns `true` if the config is a final build (digit sum == 8) or maxed (sum > 8). /// Returns `true` if the config is a final build (digit sum == 8) or maxed (sum > 8).
/// ///
/// Configs with sum < 8 are non-final and still have upgrade steps remaining. /// Configs with sum < 8 are non-final and still have upgrade steps remaining.
/// Uses [`normalize_to_final_build`] internally: if normalization returns the /// Malformed configs (not exactly 3 ASCII digits) are treated as non-final so
/// original string unchanged, the config is already at or beyond a final build. /// they surface in improve.csv instead of being silently classified as finished.
///
/// Checks the digits directly — no allocation, unlike going through
/// [`normalize_to_final_build`].
pub fn is_final_build(config: &str) -> bool { pub fn is_final_build(config: &str) -> bool {
normalize_to_final_build(config) == config let bytes = config.as_bytes();
if bytes.len() != 3 || !bytes.iter().all(|&b| b.is_ascii_digit()) {
return false;
}
bytes.iter().map(|&b| b - b'0').sum::<u8>() >= 8
} }

View file

@ -79,7 +79,7 @@ pub fn load_my_cars_data(path: &str) -> Result<MyCarsData, AppError> {
let config = format!("{e}{w}{c}"); let config = format!("{e}{w}{c}");
owned_configs.entry(guid.to_string()).or_default().push(config); owned_configs.entry(guid.to_string()).or_default().push(config);
} else { } else {
eprintln!("Warning: locked card {guid} has incomplete tune data — skipped"); log::warn!("locked card {guid} has incomplete tune data — skipped");
} }
} }
} }