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"
|
||||
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.1"
|
||||
|
|
@ -245,6 +251,37 @@ dependencies = [
|
|||
"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]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.5"
|
||||
|
|
@ -265,6 +302,29 @@ dependencies = [
|
|||
"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]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
|
|
@ -761,6 +821,42 @@ version = "1.0.18"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "js-sys"
|
||||
version = "0.3.97"
|
||||
|
|
@ -904,7 +1000,7 @@ version = "0.10.79"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.11.1",
|
||||
"cfg-if",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
|
|
@ -982,6 +1078,21 @@ version = "0.3.33"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
|
|
@ -1027,7 +1138,7 @@ checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
|
|||
dependencies = [
|
||||
"bit-set",
|
||||
"bit-vec",
|
||||
"bitflags",
|
||||
"bitflags 2.11.1",
|
||||
"num-traits",
|
||||
"rand",
|
||||
"rand_chacha",
|
||||
|
|
@ -1109,7 +1220,7 @@ version = "0.5.18"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.11.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1201,7 +1312,7 @@ version = "1.1.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.11.1",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
|
|
@ -1286,7 +1397,7 @@ version = "3.7.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.11.1",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
|
|
@ -1453,7 +1564,7 @@ version = "0.7.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.11.1",
|
||||
"core-foundation 0.9.4",
|
||||
"system-configuration-sys",
|
||||
]
|
||||
|
|
@ -1577,7 +1688,9 @@ version = "0.3.0"
|
|||
dependencies = [
|
||||
"clap",
|
||||
"dashmap",
|
||||
"env_logger",
|
||||
"futures",
|
||||
"log",
|
||||
"mockito",
|
||||
"proptest",
|
||||
"reqwest",
|
||||
|
|
@ -1610,7 +1723,7 @@ version = "0.6.9"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a28f0d049ccfaa566e14e9663d304d8577427b368cb4710a20528690287a738b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.11.1",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
|
|
@ -1838,7 +1951,7 @@ version = "0.244.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.11.1",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
"semver",
|
||||
|
|
@ -2035,7 +2148,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"bitflags 2.11.1",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ futures = "0.3.31"
|
|||
clap = { version = "4", features = ["derive"] }
|
||||
thiserror = "2"
|
||||
dashmap = "6"
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
|
|
|||
|
|
@ -18,19 +18,14 @@ pub async fn run_all(
|
|||
max_wishlist: usize,
|
||||
) -> Result<(), AppError> {
|
||||
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_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_wishlist_files(
|
||||
output_dir,
|
||||
&counts,
|
||||
&my_data.owned,
|
||||
&my_data.wishlist,
|
||||
min_count,
|
||||
max_wishlist,
|
||||
)?;
|
||||
output::write_wishlist_files(output_dir, &counts, &owned, &wishlist, min_count, max_wishlist)?;
|
||||
output::write_improve_csv(output_dir, &counts)?;
|
||||
println!(
|
||||
"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,
|
||||
) -> Result<(), AppError> {
|
||||
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_very_best_csv(output_dir, &counts, min_count)?;
|
||||
println!("Written: best.csv, very_best.csv");
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ pub async fn run_details(
|
|||
output_dir: &str,
|
||||
) -> Result<(), AppError> {
|
||||
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)?;
|
||||
println!("Written: details.csv");
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ pub async fn run_improve(
|
|||
output_dir: &str,
|
||||
) -> Result<(), AppError> {
|
||||
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)?;
|
||||
if count == 0 {
|
||||
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) {
|
||||
Ok(data) => Ok(data),
|
||||
Err(AppError::MissingFile(msg)) => {
|
||||
eprintln!("Warning: {msg}");
|
||||
eprintln!(" Analysis will run without ownership data.");
|
||||
log::warn!("{msg}");
|
||||
log::warn!("Analysis will run without ownership data.");
|
||||
Ok(MyCarsData::default())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
|
|
@ -45,23 +45,24 @@ pub fn load_my_cars_or_warn(path: &str) -> Result<MyCarsData, AppError> {
|
|||
// 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
|
||||
/// (for CSV output commands). The car list is stored inside `CarCounts`.
|
||||
/// (for CSV output commands).
|
||||
pub async fn run_analysis(
|
||||
my_data: &MyCarsData,
|
||||
my_data: MyCarsData,
|
||||
cars_db_path: &str,
|
||||
times_dir: &str,
|
||||
) -> Result<(AnalysisContext, CarCounts), AppError> {
|
||||
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 counts = compute_car_counts(
|
||||
&ctx,
|
||||
car_list,
|
||||
my_data.owned_configs.clone(),
|
||||
my_data.copy_count.clone(),
|
||||
);
|
||||
let counts = compute_car_counts(&ctx, car_list, owned_configs, copy_count);
|
||||
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.
|
||||
///
|
||||
/// Note: `_output_dir` is unused because the track command prints to stdout
|
||||
/// rather than writing a CSV file. It exists for API consistency with other
|
||||
/// command signatures so the caller doesn't need per-command branching.
|
||||
/// Prints to stdout rather than writing a CSV file, so no output directory is needed.
|
||||
pub async fn run_track(
|
||||
my_cars_path: &str,
|
||||
cars_db_path: &str,
|
||||
times_dir: &str,
|
||||
_output_dir: &str,
|
||||
track: &str,
|
||||
condition: &str,
|
||||
rq: u8,
|
||||
) -> Result<(), AppError> {
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,8 +17,9 @@ pub async fn run_trash(
|
|||
output_dir: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let my_data = load_my_cars_or_warn(my_cars_path)?;
|
||||
let (_ctx, counts) = run_analysis(&my_data, cars_db_path, times_dir).await?;
|
||||
write_trash_csv(output_dir, &counts, &my_data.owned)?;
|
||||
let owned = my_data.owned.clone();
|
||||
let (_ctx, counts) = run_analysis(my_data, cars_db_path, times_dir).await?;
|
||||
write_trash_csv(output_dir, &counts, &owned)?;
|
||||
println!("Written: trash.csv");
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,15 +17,10 @@ pub async fn run_wishlist(
|
|||
max_wishlist: usize,
|
||||
) -> Result<(), AppError> {
|
||||
let my_data = load_my_cars_or_warn(my_cars_path)?;
|
||||
let (_ctx, counts) = run_analysis(&my_data, cars_db_path, times_dir).await?;
|
||||
write_wishlist_files(
|
||||
output_dir,
|
||||
&counts,
|
||||
&my_data.owned,
|
||||
&my_data.wishlist,
|
||||
min_count,
|
||||
max_wishlist,
|
||||
)?;
|
||||
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?;
|
||||
write_wishlist_files(output_dir, &counts, &owned, &wishlist, min_count, max_wishlist)?;
|
||||
println!("Written: wishlist.csv, add.csv, remove.csv");
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,9 +50,9 @@ pub struct CarCounts {
|
|||
pub guid_to_rid: HashMap<String, HashSet<(String, String)>>,
|
||||
/// 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.
|
||||
/// Sole owner of the car database — iterate `car_map.values()` where a
|
||||
/// car list was previously used.
|
||||
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).
|
||||
/// Used by the improve command to determine which cars are not yet at a final build.
|
||||
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.
|
||||
let car_map: HashMap<String, Car> =
|
||||
car_list.iter().map(|c| (c.guid.clone(), c.clone())).collect();
|
||||
// Consumes car_list so each Car is stored exactly once.
|
||||
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)]
|
||||
|
|
@ -158,7 +158,7 @@ mod tests {
|
|||
|
||||
// car_map should have the car
|
||||
assert!(counts.car_map.contains_key("guid_test"));
|
||||
assert_eq!(counts.car_list.len(), 1);
|
||||
assert_eq!(counts.car_map.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
32
src/http.rs
32
src/http.rs
|
|
@ -9,6 +9,11 @@ use reqwest::get;
|
|||
|
||||
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.
|
||||
///
|
||||
/// 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() {
|
||||
// Only retry on server errors (5xx); client errors (4xx) are permanent.
|
||||
if status.is_server_error() && attempt < MAX_FETCH_RETRIES {
|
||||
eprintln!(
|
||||
"Warning: HTTP {} from {file_url} (attempt {}/{MAX_FETCH_RETRIES}), retrying...",
|
||||
log::warn!(
|
||||
"HTTP {} from {file_url} (attempt {}/{MAX_FETCH_RETRIES}), retrying...",
|
||||
status,
|
||||
attempt + 1,
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(
|
||||
500 * (attempt + 1) as u64,
|
||||
))
|
||||
.await;
|
||||
tokio::time::sleep(retry_delay(attempt)).await;
|
||||
attempt += 1;
|
||||
continue;
|
||||
}
|
||||
|
|
@ -57,14 +59,11 @@ pub async fn get_file(file_url: &str, local_file_path: &str) -> Result<(), AppEr
|
|||
Ok(content) => {
|
||||
if content.is_empty() {
|
||||
if attempt < MAX_FETCH_RETRIES {
|
||||
eprintln!(
|
||||
"Warning: empty response from {file_url} (attempt {}/{MAX_FETCH_RETRIES}), retrying...",
|
||||
log::warn!(
|
||||
"empty response from {file_url} (attempt {}/{MAX_FETCH_RETRIES}), retrying...",
|
||||
attempt + 1,
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(
|
||||
500 * (attempt + 1) as u64,
|
||||
))
|
||||
.await;
|
||||
tokio::time::sleep(retry_delay(attempt)).await;
|
||||
attempt += 1;
|
||||
continue;
|
||||
}
|
||||
|
|
@ -84,14 +83,11 @@ pub async fn get_file(file_url: &str, local_file_path: &str) -> Result<(), AppEr
|
|||
}
|
||||
Err(error) => {
|
||||
if attempt < MAX_FETCH_RETRIES {
|
||||
eprintln!(
|
||||
"Warning: failed to fetch {file_url} (attempt {}/{MAX_FETCH_RETRIES}): {error}",
|
||||
log::warn!(
|
||||
"failed to fetch {file_url} (attempt {}/{MAX_FETCH_RETRIES}): {error}",
|
||||
attempt + 1,
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(
|
||||
500 * (attempt + 1) as u64,
|
||||
))
|
||||
.await;
|
||||
tokio::time::sleep(retry_delay(attempt)).await;
|
||||
attempt += 1;
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,16 +25,25 @@ const TRASH_MIN_RQ: u8 = 80;
|
|||
// 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,
|
||||
/// then brand, then model name.
|
||||
///
|
||||
/// Avoids cloning into a `Car` just for sorting — uses the same ordering as
|
||||
/// `compare_cars` but operates directly on `BestCarRecord`.
|
||||
pub fn compare_records(a: &BestCarRecord, b: &BestCarRecord) -> std::cmp::Ordering {
|
||||
a.rq.cmp(&b.rq)
|
||||
.then(a.normalized_config.cmp(&b.normalized_config))
|
||||
.then(a.brand.cmp(&b.brand))
|
||||
.then(a.only_name.cmp(&b.only_name))
|
||||
compare_by_rq_config_brand_name(
|
||||
(&a.rq, &a.normalized_config, &a.brand, &a.only_name),
|
||||
(&b.rq, &b.normalized_config, &b.brand, &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
|
||||
/// 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.
|
||||
///
|
||||
/// **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,
|
||||
|
|
@ -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
|
||||
/// 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
|
||||
/// from the qualifying config. This allows wishlisting a second copy for a different
|
||||
|
|
@ -454,9 +470,7 @@ pub fn write_wishlist_files(
|
|||
})
|
||||
.collect();
|
||||
if dropped_count > 0 {
|
||||
eprintln!(
|
||||
"Warning: {dropped_count} wishlist car(s) not found in cars database and omitted from remove.csv"
|
||||
);
|
||||
log::warn!("{dropped_count} wishlist car(s) not found in cars database and omitted from remove.csv");
|
||||
}
|
||||
|
||||
write_sorted_csv(
|
||||
|
|
@ -465,10 +479,10 @@ pub fn write_wishlist_files(
|
|||
"RQ,Brand,Model,Year,FinalBuild,Count",
|
||||
&to_remove,
|
||||
|a, b| {
|
||||
a.rq.cmp(&b.rq)
|
||||
.then(a.normalized_config.cmp(&b.normalized_config))
|
||||
.then(a.brand.cmp(&b.brand))
|
||||
.then(a.only_name.cmp(&b.only_name))
|
||||
compare_by_rq_config_brand_name(
|
||||
(&a.rq, &a.normalized_config, &a.brand, &a.only_name),
|
||||
(&b.rq, &b.normalized_config, &b.brand, &b.only_name),
|
||||
)
|
||||
},
|
||||
|entry, file| {
|
||||
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.
|
||||
// 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
|
||||
.iter()
|
||||
.filter_map(|(guid, configs)| counts.car_map.get(guid).map(|car| (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.
|
||||
// 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 {
|
||||
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;
|
||||
};
|
||||
|
||||
// Collect target configs, excluding any already owned on another copy of this car.
|
||||
// Keep only the best remaining target (highest track count).
|
||||
if let Some(keys) = counts.guid_to_rid.get(guid) {
|
||||
let best_target = keys
|
||||
.iter()
|
||||
.filter_map(|key| {
|
||||
let (record, track_count) = counts.by_rid.get(key)?;
|
||||
if owned_final_configs
|
||||
.contains(&(car.rid.clone(), record.normalized_config.clone()))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some((record.normalized_config.clone(), *track_count))
|
||||
})
|
||||
.max_by_key(|(_, count)| *count);
|
||||
let best_target =
|
||||
keys.iter()
|
||||
.filter_map(|key| {
|
||||
let (record, track_count) = counts.by_rid.get(key)?;
|
||||
if owned_final_configs.get(car.rid.as_str()).is_some_and(|finals| {
|
||||
finals.contains(record.normalized_config.as_str())
|
||||
}) {
|
||||
return None;
|
||||
}
|
||||
Some((record.normalized_config.clone(), *track_count))
|
||||
})
|
||||
.max_by_key(|(_, target_count)| *target_count);
|
||||
|
||||
if let Some((target, track_count)) = best_target {
|
||||
entries.push(ImproveEntry {
|
||||
|
|
@ -1018,7 +1037,6 @@ mod tests {
|
|||
assert!(counts.by_rid.is_empty());
|
||||
assert!(counts.guid_to_rid.is_empty());
|
||||
assert!(counts.car_map.is_empty());
|
||||
assert!(counts.car_list.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -89,9 +89,10 @@ pub fn parse_car_times(
|
|||
// Read file content
|
||||
let file_content = match fs::read_to_string(&local_file_path) {
|
||||
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) => {
|
||||
// File not found is expected for some cars — treat as non-fatal
|
||||
eprintln!("Warning: could not read {local_file_path}: {e}");
|
||||
log::warn!("could not read {local_file_path}: {e}");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
|
@ -106,7 +107,7 @@ pub fn parse_car_times(
|
|||
|
||||
// Process each configuration variant (111, 233, 323, etc.)
|
||||
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")
|
||||
// at read time. Downstream code only ever sees bare digit strings.
|
||||
// Generic approach: find the last '_' and take what follows it,
|
||||
|
|
@ -121,13 +122,20 @@ pub fn parse_car_times(
|
|||
.map(|(_, suffix)| suffix.to_string())
|
||||
.unwrap_or_else(|| raw_config.clone());
|
||||
// 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 {
|
||||
// Parse track ID to separate base track name from surface condition.
|
||||
// Use rsplit_once to split on the LAST "_a" so track names that
|
||||
// contain "_a" (e.g. "airplaneHangars") are handled correctly.
|
||||
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 no_down_votes = match down_votes {
|
||||
|
|
@ -302,12 +310,12 @@ pub async fn parse_all_times(
|
|||
match result {
|
||||
// Outer JoinError from tokio::spawn
|
||||
Err(e) => {
|
||||
eprintln!("Warning: parsing task failed: {e}");
|
||||
log::warn!("parsing task failed: {e}");
|
||||
parse_errors += 1;
|
||||
}
|
||||
// parse_car_times or semaphore returned an error
|
||||
Ok(Err(e)) => {
|
||||
eprintln!("Warning: {e}");
|
||||
log::warn!("{e}");
|
||||
parse_errors += 1;
|
||||
}
|
||||
// parse_car_times succeeded
|
||||
|
|
@ -315,7 +323,7 @@ pub async fn parse_all_times(
|
|||
}
|
||||
}
|
||||
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.
|
||||
|
|
@ -364,14 +372,14 @@ pub async fn fetch_times(cars_db_path: &str, times_dir: &str) -> Result<(), AppE
|
|||
match result {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => {
|
||||
eprintln!("{e}");
|
||||
log::warn!("{e}");
|
||||
if first_error.is_none() {
|
||||
first_error = Some(e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = AppError::Network(format!("Task join error: {e}"));
|
||||
eprintln!("{msg}");
|
||||
log::warn!("{msg}");
|
||||
if first_error.is_none() {
|
||||
first_error = Some(msg);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -150,6 +150,9 @@ enum Commands {
|
|||
/// ```
|
||||
#[tokio::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 {
|
||||
eprintln!("Error: {e}");
|
||||
std::process::exit(1);
|
||||
|
|
@ -217,7 +220,6 @@ async fn run() -> Result<(), AppError> {
|
|||
&cli.my_cars,
|
||||
&cli.cars_db,
|
||||
&cli.times_dir,
|
||||
&cli.output_dir,
|
||||
&track,
|
||||
&condition,
|
||||
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';
|
||||
}
|
||||
|
||||
let sum: i32 = digits.iter().map(|&d| d as i32).sum();
|
||||
let sum: u8 = digits.iter().sum();
|
||||
if sum >= 8 {
|
||||
// Already a final build (sum == 8) or maxed (sum > 8) — return as-is.
|
||||
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).
|
||||
///
|
||||
/// Configs with sum < 8 are non-final and still have upgrade steps remaining.
|
||||
/// Uses [`normalize_to_final_build`] internally: if normalization returns the
|
||||
/// original string unchanged, the config is already at or beyond a final build.
|
||||
/// Malformed configs (not exactly 3 ASCII digits) are treated as non-final so
|
||||
/// 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 {
|
||||
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}");
|
||||
owned_configs.entry(guid.to_string()).or_default().push(config);
|
||||
} 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