Replace manual CLI parsing with clap derive

- Add clap dependency with derive feature for structured CLI argument parsing
- Replace env::args() manual matching with clap subcommands (fetch, best, missing, wishlist, trash, details, track)
- Change print_best_car() signature from (&String, &String, &String) to (&str, &str, u8) for idiomatic Rust
- Add TODO.md tracking known tech debt and planned features
- Update CHANGELOG.md with clap migration details and set v0.2.0 release date
This commit is contained in:
Jan-Peter von Hunnius 2026-05-07 12:27:11 +02:00
parent 89fa69e3c8
commit 08c96d9981
5 changed files with 227 additions and 99 deletions

View file

@ -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

121
Cargo.lock generated
View file

@ -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"

View file

@ -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"

4
TODO.md Normal file
View file

@ -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`).

View file

@ -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::<u8>().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) {