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:
parent
39d1e1d71b
commit
58b8d23e68
17 changed files with 255 additions and 120 deletions
131
Cargo.lock
generated
131
Cargo.lock
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 = []
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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(())
|
||||||
|
|
|
||||||
|
|
@ -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.");
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
32
src/http.rs
32
src/http.rs
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
15
src/types.rs
15
src/types.rs
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue