diff --git a/CHANGELOG.md b/CHANGELOG.md index af872c4..94d29de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,17 +15,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Data structures documentation for JSON formats - Development setup guide with dependency explanations - `track_names.json` — external config file mapping track codes to human-readable names (loaded at compile time via `include_str!`) -- `full_script` command for running fetch + all analysis commands in sequence +- `clap` dependency with derive feature for structured CLI argument parsing - Daily workflow documentation with output file descriptions (`best.csv`, `details.csv`, `add.csv`, `remove.csv`, `trash.csv`, `sorted.csv`, `very_best.csv`, `wishlist.csv`) +- Test suite covering `update_best_car` logic, RQ range analysis, time validation, track name completeness, and condition completeness ### Changed - `translate_track_name()` in `lib.rs` now loads track name mappings from `track_names.json` instead of a hardcoded match statement - Magic number 115 replaced with `const MAX_RQ: u8 = 113` in `records.rs` for clarity and maintainability - `parse_all_times()` now uses a semaphore (limit 20) for bounded concurrency instead of unbounded task spawning +- Replaced manual `env::args()` CLI parsing with `clap` derive for proper `--help`, `--version`, and argument validation +- `print_best_car()` signature changed from `(&String, &String, &String)` to `(&str, &str, u8)` for idiomatic Rust and compile-time RQ validation - Empty test bodies removed from `records.rs` -- Enhanced error handling throughout the application - Better progress reporting during data fetching -- More descriptive command-line help text - Improved CSV output formatting ### Removed @@ -34,7 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **tracks.txt**: Removed track definitions file (only used by club binary) - Club module `Car` struct (separate struct with `class`, `upgrade` fields) -## [0.2.0] - 2024-12-XX +## [0.2.0] - 2026-05-07 ### Added - **Track-Specific Analysis**: New `track` command for targeted recommendations @@ -213,4 +214,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 **Changelog Format**: Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format **Versioning**: Follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) principles -**Last Updated**: 2026-05-06 +**Last Updated**: 2026-05-07 diff --git a/Cargo.lock b/Cargo.lock index 6cc3c2f..caa5da2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,56 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -54,6 +104,52 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "core-foundation" version = "0.9.4" @@ -559,6 +655,12 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" @@ -662,6 +764,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "openssl" version = "0.10.79" @@ -1052,6 +1160,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -1198,6 +1312,7 @@ dependencies = [ name = "top_drives" version = "0.2.0" dependencies = [ + "clap", "futures", "reqwest", "serde_json", @@ -1311,6 +1426,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "vcpkg" version = "0.2.15" diff --git a/Cargo.toml b/Cargo.toml index 67a8589..5f1d636 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ reqwest = "0.12.15" tokio = { version = "1.44.1", features = ["full"] } serde_json = "1.0.140" futures = "0.3.31" +clap = { version = "4", features = ["derive"] } [dev-dependencies] tempfile = "3.10.1" diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..f523c50 --- /dev/null +++ b/TODO.md @@ -0,0 +1,4 @@ +1) **Move shell post-processing into Rust** — `full_script` does `sort`, `grep`, file splitting via shell. Add native subcommands (`very-best`, `add`, `remove`) or CLI flags (`--min-count`, `--sort-by`) so the binary produces the final files directly. Modify the script to export to files, nothing else. +2) **Correct `+` prefix semantics** — `+` means "not on wishlist". It is not needed in `best`/`missing` output but is used as "add to wishlist". Since we incorporated the wishlist actions into the application, the prefix can go. +3) **Add `all` subcommand** — `full_script` runs 5 commands sequentially, each parsing all timing files independently. A single `all` command would parse once and write all outputs, cutting runtime ~80%. +4) **Document the daily workflow** — README and USER_GUIDE don't explain `full_script` or the relationship between Rust output and the files (`sorted.csv`, `very_best.csv`, `wishlist.csv`, `add.csv`). diff --git a/src/records.rs b/src/records.rs index 6fdea26..1f8601f 100644 --- a/src/records.rs +++ b/src/records.rs @@ -28,10 +28,10 @@ //! - `futures`: Future utilities for handling async operations use std::collections::{HashMap, HashSet}; -use std::env; use std::fs; use std::sync::Arc; +use clap::{Parser, Subcommand}; use tokio::sync::Semaphore; use tokio::task; @@ -48,22 +48,45 @@ use top_drives::{ /// Used as the upper bound when iterating RQ ranges and as a fallback for parse errors. const MAX_RQ: u8 = 113; +/// Command-line interface for the Top Drives Records Analyzer. +#[derive(Parser)] +#[command(name = "records", about = "Top Drives Records Analyzer", version)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +/// Available subcommands for car performance analysis. +#[derive(Subcommand)] +enum Commands { + /// Download latest car and track data from repository + Fetch, + /// Show best performing cars in your collection + Best, + /// Show best cars not owned by you (prefix + if not on wishlist) + Missing, + /// Show wishlist cars that are no longer among the best + Wishlist, + /// Identify cars that can be safely removed from collection + Trash, + /// Show detailed timing data for all tracks + Details, + /// Show best car for a specific track/condition/RQ combination + Track { + /// Track name (e.g. "Laguna Seca") + track: String, + /// Track condition (e.g. "Dry", "Wet") + condition: String, + /// RQ limit (e.g. 85) + rq: u8, + }, +} + /// Main entry point for the Top Drives Records Analyzer /// -/// Parses command line arguments and executes the requested analysis operation. +/// Parses command line arguments using `clap` and executes the requested analysis operation. /// Supports multiple subcommands for different types of car performance analysis. /// -/// # Arguments -/// -/// The first argument specifies the operation to perform: -/// - `fetch`: Download latest car and track data from repository -/// - `best`: Show best performing cars in user's collection -/// - `missing`: Show best cars not owned by the user (prefix `+` if not on wishlist) -/// - `trash`: Identify cars that can be safely removed from collection -/// - `wishlist`: Show wishlist cars that are no longer among the best -/// - `details`: Show detailed timing data for all tracks -/// - `track`: Show best car for specific track/condition/RQ combination -/// /// # Example Usage /// /// ```bash @@ -72,85 +95,63 @@ const MAX_RQ: u8 = 113; /// ``` #[tokio::main] async fn main() { - let args: Vec<_> = env::args().collect(); + let cli = Cli::parse(); - match args.get(1) { - None => { - println!("Please provide a command.") + match cli.command { + Commands::Fetch => { + // Download phase: Get fresh data from all sources + // This is the foundation for all other operations + get_cars_file().await; + get_tracks_file().await; + fetch_times().await; } - Some(argument) => { - let command = argument.as_str(); - match command { - "fetch" => { - // Download phase: Get fresh data from all sources - // This is the foundation for all other operations - get_cars_file().await; - get_tracks_file().await; - fetch_times().await; - } - "best" => { - // Standard best cars analysis - // Shows cars you own that perform well across tracks - let my_data = load_my_cars_data(); - let ctx = AnalysisContext::new(); - let _car_list = parse_all_times(&my_data.owned, &ctx).await; - print_best_cars(&ctx, false, &my_data.wishlist); // false = include owned cars - } - "missing" => { - // Missing cars analysis - // Shows high-performing cars you don't own - // Cars not yet on the wishlist are prefixed with "+" - let my_data = load_my_cars_data(); - let ctx = AnalysisContext::new(); - let _car_list = parse_all_times(&my_data.owned, &ctx).await; - print_best_cars(&ctx, true, &my_data.wishlist); // true = filter out owned cars - } - "wishlist" => { - // Wishlist review - // Shows cars on the wishlist that are no longer among the best - let my_data = load_my_cars_data(); - let ctx = AnalysisContext::new(); - let car_list = parse_all_times(&my_data.owned, &ctx).await; - print_wishlist_stale(&ctx, &my_data.wishlist, &car_list); - } - "trash" => { - // Collection optimization - // Identifies cars that can be safely removed - let my_data = load_my_cars_data(); - let ctx = AnalysisContext::new(); - let car_list = parse_all_times(&my_data.owned, &ctx).await; - print_trash(&ctx, &car_list, &my_data.owned); - } - "details" => { - // Comprehensive data export - // Outputs all timing data for external analysis - let my_data = load_my_cars_data(); - let ctx = AnalysisContext::new(); - parse_all_times(&my_data.owned, &ctx).await; - print_best_times(&ctx); - } - "track" => { - // Track-specific analysis - // Requires exactly 3 additional arguments - if args.get(4).is_none() { - println!("Track name, condition and RQ limit required."); - } else { - // Safe to unwrap here due to above check - let my_data = load_my_cars_data(); - let ctx = AnalysisContext::new(); - parse_all_times(&my_data.owned, &ctx).await; - let track = args.get(2).unwrap(); - let condition = args.get(3).unwrap(); - let rq = args.get(4).unwrap(); - print_best_car(&ctx, track, condition, rq); - } - } - &_ => { - // Unknown command handler - // Provides feedback for typos or outdated commands - println!("Unknown command '{}'.", argument) - } - } + Commands::Best => { + // Standard best cars analysis + // Shows cars you own that perform well across tracks + let my_data = load_my_cars_data(); + let ctx = AnalysisContext::new(); + let _car_list = parse_all_times(&my_data.owned, &ctx).await; + print_best_cars(&ctx, false, &my_data.wishlist); // false = include owned cars + } + Commands::Missing => { + // Missing cars analysis + // Shows high-performing cars you don't own + // Cars not yet on the wishlist are prefixed with "+" + let my_data = load_my_cars_data(); + let ctx = AnalysisContext::new(); + let _car_list = parse_all_times(&my_data.owned, &ctx).await; + print_best_cars(&ctx, true, &my_data.wishlist); // true = filter out owned cars + } + Commands::Wishlist => { + // Wishlist review + // Shows cars on the wishlist that are no longer among the best + let my_data = load_my_cars_data(); + let ctx = AnalysisContext::new(); + let car_list = parse_all_times(&my_data.owned, &ctx).await; + print_wishlist_stale(&ctx, &my_data.wishlist, &car_list); + } + Commands::Trash => { + // Collection optimization + // Identifies cars that can be safely removed + let my_data = load_my_cars_data(); + let ctx = AnalysisContext::new(); + let car_list = parse_all_times(&my_data.owned, &ctx).await; + print_trash(&ctx, &car_list, &my_data.owned); + } + Commands::Details => { + // Comprehensive data export + // Outputs all timing data for external analysis + let my_data = load_my_cars_data(); + let ctx = AnalysisContext::new(); + parse_all_times(&my_data.owned, &ctx).await; + print_best_times(&ctx); + } + Commands::Track { track, condition, rq } => { + // Track-specific analysis + let my_data = load_my_cars_data(); + let ctx = AnalysisContext::new(); + parse_all_times(&my_data.owned, &ctx).await; + print_best_car(&ctx, &track, &condition, rq); } } } @@ -392,12 +393,12 @@ fn print_best_cars(ctx: &AnalysisContext, filter_out_owned: bool, wishlist: &Has /// # Note /// If no car is found for the specified combination, it may indicate that no data /// is available for that specific track/condition/RQ combination. -fn print_best_car(ctx: &AnalysisContext, track: &String, condition: &String, rq: &String) { +fn print_best_car(ctx: &AnalysisContext, track: &str, condition: &str, rq: u8) { let best_cars = ctx.best_cars.lock().unwrap(); let key = ( - track.clone(), - rq.parse::().unwrap_or(MAX_RQ), // Default to max RQ if parsing fails - condition.clone(), + track.to_string(), + rq, + condition.to_string(), ); if let Some(best_list) = best_cars.get(&key) {