- Split monolithic src/lib.rs (~640 lines) into 7 sub-modules: types.rs, context.rs, user_data.rs, translate.rs, http.rs, csv_util.rs, test_utils.rs - Add configurable --output-dir CLI option for CSV output files - Implement atomic file writes (write-to-temp + rename) for all CSVs - Change compute_car_counts() to return CarCounts directly (not Result) - Add test-utils feature gate for shared test helpers - Add HTTP mock tests with mockito (6 tests) - Add proptest tests for compute_car_counts (3 property tests) - Update integration tests to use shared test utilities - Update documentation (API.md, ARCHITECTURE.md, DATA_STRUCTURES.md)
350 lines
12 KiB
Markdown
350 lines
12 KiB
Markdown
# API Documentation
|
|
|
|
## Overview
|
|
|
|
This document provides comprehensive API documentation for the Top Drives Records Analyzer, including all public functions, data structures, and their usage patterns.
|
|
|
|
## Module Structure
|
|
|
|
```
|
|
src/
|
|
├── lib.rs # Thin re-export module: declares sub-modules, re-exports public types
|
|
├── types.rs # Core types: AppError, Car, BestCarRecord, TripletKey
|
|
├── context.rs # Analysis state: AnalysisContext, CarCounts, compute_car_counts
|
|
├── user_data.rs # User data: MyCarsData, load_my_cars_data
|
|
├── translate.rs # Translation: translate_track_name, translate_track_type
|
|
├── http.rs # HTTP: get_file (async download with retry)
|
|
├── csv_util.rs # CSV utilities: compare_cars, escape_csv_field
|
|
├── test_utils.rs # Test helpers: make_record, make_car
|
|
├── records.rs # Binary entry point: CLI definition and dispatch
|
|
├── commands/
|
|
│ ├── mod.rs # Command dispatch, run_analysis(), load_my_cars_or_warn()
|
|
│ ├── fetch.rs # Fetch command: download car/track/timing data
|
|
│ ├── best.rs # Best command: best.csv + very_best.csv
|
|
│ ├── trash.rs # Trash command: trash.csv
|
|
│ ├── wishlist.rs # Wishlist command: wishlist.csv, add.csv, remove.csv
|
|
│ ├── details.rs # Details command: details.csv
|
|
│ ├── track.rs # Track command: stdout query
|
|
│ └── all.rs # All command: generates all output files
|
|
├── parsing/
|
|
│ ├── mod.rs # Core parsing: parse_car_times, update_best_car, parse_all_times
|
|
│ └── car_list.rs # Car database loading: get_car_list
|
|
└── output/
|
|
└── mod.rs # CSV output: write_sorted_csv, write_best_csv, wishlist logic, etc.
|
|
```
|
|
|
|
---
|
|
|
|
## Shared Library (`src/lib.rs`)
|
|
|
|
Thin re-export module that declares 6 focused sub-modules and re-exports all public types for backward compatibility. External code can use `top_drives::Car` without knowing the internal module layout.
|
|
|
|
### Public Types
|
|
|
|
#### `Car`
|
|
**Core car information structure**
|
|
|
|
```rust
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct Car {
|
|
pub rid: String, // Repository identifier
|
|
pub brand: String, // Manufacturer/brand name
|
|
#[serde(rename = "onlyName")]
|
|
pub only_name: String, // Model name (without brand)
|
|
pub year: u16, // Model year
|
|
pub rq: u8, // Rarity level (10-113)
|
|
#[serde(default)]
|
|
pub prize: bool, // Whether car is prize-only
|
|
pub guid: String, // Game-specific card identifier
|
|
}
|
|
```
|
|
|
|
**Purpose**: Represents a car entity from the records database.
|
|
|
|
**Deserialization**: Uses `serde::Deserialize` with `#[serde(rename = "onlyName")]` for the `only_name` field and `#[serde(default)]` for `prize`.
|
|
|
|
#### `BestCarRecord`
|
|
**Best performing car record for a specific track/RQ/condition**
|
|
|
|
```rust
|
|
#[derive(Debug, Clone)]
|
|
pub struct BestCarRecord {
|
|
pub car_rid: String, // Car identifier
|
|
pub brand: String, // Manufacturer
|
|
pub only_name: String, // Model name
|
|
pub year: u16, // Year
|
|
pub rq: u8, // RQ level
|
|
pub config: String, // Tune config (111, 233, etc.)
|
|
pub time: f64, // Track time in seconds
|
|
pub owned: bool, // User ownership status
|
|
pub prize: bool, // Prize car status
|
|
pub guid: String, // Game identifier
|
|
}
|
|
```
|
|
|
|
#### `TripletKey`
|
|
**Composite key for unique track conditions**
|
|
|
|
```rust
|
|
pub type TripletKey = (String, u8, String);
|
|
// Track ID RQ Level Surface Condition
|
|
```
|
|
|
|
#### `AnalysisContext`
|
|
**Mutable analysis state using concurrent DashMap**
|
|
|
|
```rust
|
|
pub struct AnalysisContext {
|
|
pub best_cars: Arc<DashMap<TripletKey, Vec<BestCarRecord>>>,
|
|
}
|
|
```
|
|
|
|
**Methods**:
|
|
- `new()` — Create a new, empty analysis context
|
|
|
|
**Thread Safety**: Uses `DashMap` for sharded internal locking, allowing concurrent access from multiple parsing tasks without a global lock.
|
|
|
|
#### `CarCounts`
|
|
**Aggregated per-car win counts**
|
|
|
|
```rust
|
|
pub struct CarCounts {
|
|
pub by_rid: HashMap<String, (BestCarRecord, usize)>,
|
|
pub guid_to_rid: HashMap<String, String>,
|
|
pub car_map: HashMap<String, Car>,
|
|
pub car_list: Vec<Car>,
|
|
}
|
|
```
|
|
|
|
#### `MyCarsData`
|
|
**Parsed user data from `my_cars.json`**
|
|
|
|
```rust
|
|
#[derive(Debug, Default)]
|
|
pub struct MyCarsData {
|
|
pub owned: HashSet<String>, // Owned car GUIDs
|
|
pub wishlist: HashSet<String>, // Wishlist car GUIDs
|
|
}
|
|
```
|
|
|
|
#### `AppError`
|
|
**Unified error type**
|
|
|
|
```rust
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum AppError {
|
|
#[error("I/O error: {0}")]
|
|
Io(#[from] std::io::Error),
|
|
#[error("Network error: {0}")]
|
|
Network(String),
|
|
#[error("Parse error in {file}: {reason}")]
|
|
Parse { file: String, reason: String },
|
|
#[error("Missing data file: {0}")]
|
|
MissingFile(String),
|
|
#[error("Internal error: {0}")]
|
|
Internal(String),
|
|
}
|
|
```
|
|
|
|
### Public Functions
|
|
|
|
#### `translate_track_name(track_code: &str) -> String`
|
|
**Translates internal track identifiers to human-readable names**
|
|
|
|
- **Input**: Track code (e.g., `"laguna"`)
|
|
- **Output**: Track name (e.g., `"Laguna Seca"`)
|
|
- **Fallback**: Returns original code if no mapping found
|
|
- **Data source**: `track_names.json` (compiled into binary via `include_str!`)
|
|
|
|
#### `translate_track_type(track_code: &str) -> &'static str`
|
|
**Translates condition codes to surface names**
|
|
|
|
- **Input**: Condition code (e.g., `"00"`)
|
|
- **Output**: Surface name (e.g., `"Dry"`)
|
|
- **Fallback**: Returns the raw code string for unrecognized codes
|
|
|
|
#### `get_file(file_url: &str, local_file_path: &str) -> Result<(), AppError>`
|
|
**Downloads a file from a URL and saves it locally**
|
|
|
|
- Writes to a `.tmp` file first, then renames on success (atomic write)
|
|
- Retries up to 3 times on server errors (5xx) and empty responses
|
|
- Returns `AppError::Network` on HTTP failures
|
|
- Returns `AppError::Io` on file write failures
|
|
|
|
#### `load_my_cars_data(path: &str) -> Result<MyCarsData, AppError>`
|
|
**Loads and parses user car collection data**
|
|
|
|
- **Input**: Path to `my_cars.json`
|
|
- **Returns**: `MyCarsData` with owned and wishlist GUIDs
|
|
- **Errors**: `AppError::MissingFile` if file not found, `AppError::Parse` if JSON invalid
|
|
|
|
#### `compare_cars(a: &Car, b: &Car) -> std::cmp::Ordering`
|
|
**Sorting comparator for cars**
|
|
|
|
- Sorts by: RQ → Brand → Model name
|
|
|
|
#### `compute_car_counts(ctx: &AnalysisContext, car_list: Vec<Car>) -> CarCounts`
|
|
**Aggregates per-car win counts from analysis context**
|
|
|
|
- Iterates all entries in the `DashMap`
|
|
- Returns `CarCounts` with by_rid, guid_to_rid, car_map, and car_list
|
|
|
|
#### `escape_csv_field(field: &str) -> String`
|
|
**Escapes a field for safe CSV inclusion**
|
|
|
|
- Wraps in quotes if field contains comma, quote, newline, or carriage return
|
|
- Doubles internal quotes per CSV spec
|
|
|
|
---
|
|
|
|
## Records Binary (`src/records.rs`)
|
|
|
|
CLI entry point. Defines command-line arguments and dispatches to command handlers.
|
|
|
|
### CLI Global Options
|
|
|
|
| Option | Default | Description |
|
|
|--------|---------|-------------|
|
|
| `--my-cars` | `my_cars.json` | Path to user's car collection export |
|
|
| `--cars-db` | `cars_repo.json` | Path to car database file |
|
|
| `--times-dir` | `times` | Directory containing per-car timing JSON files |
|
|
| `--tracks-db` | `tracks_repo.json` | Path to tracks database file |
|
|
|
|
### CLI Commands
|
|
|
|
| Command | Description | Flags |
|
|
|---------|-------------|-------|
|
|
| `fetch` | Download latest car, track, and timing data | None |
|
|
| `best` | Generate `best.csv` and `very_best.csv` | `--min-count N` (default: 10) |
|
|
| `trash` | Generate `trash.csv` | None |
|
|
| `wishlist` | Generate `wishlist.csv`, `add.csv`, `remove.csv` | `--min-count N`, `--max-wishlist M` |
|
|
| `all` | Generate all output files in a single pass | `--min-count N`, `--max-wishlist M` |
|
|
| `details` | Generate `details.csv` with all timing data | None |
|
|
| `track` | Show best car for specific track/condition/RQ | `<track> <condition> <rq>` |
|
|
|
|
---
|
|
|
|
## Parsing Module (`src/parsing/`)
|
|
|
|
### Constants (`src/parsing/mod.rs`)
|
|
|
|
| Constant | Value | Description |
|
|
|----------|-------|-------------|
|
|
| `MAX_RQ` | 113 | Maximum RQ level for range analysis |
|
|
| `MIN_VALID_TIME` | 1.2 | Minimum valid track time (seconds) |
|
|
| `TIME_TOLERANCE` | 0.005 | Tolerance for tie detection (seconds) |
|
|
| `TEST_BOWL_FACTOR` | 2000.0 | Multiplier for Test Bowl time inversion |
|
|
| `CONCURRENCY_LIMIT` | 20 | Maximum concurrent tasks |
|
|
| `CAR_TIMING_API_BASE` | API URL | Base URL for per-car timing data |
|
|
|
|
### Key Functions
|
|
|
|
#### `parse_car_times(car, my_cars, best_cars, times_dir) -> Result<(), AppError>`
|
|
**Processes timing data for a single car** (`src/parsing/mod.rs`)
|
|
|
|
- **Synchronous** — designed to be called from `tokio::task::spawn_blocking`
|
|
- Reads car JSON file from the times directory
|
|
- Processes each configuration and track
|
|
- Applies quality filters (downvotes, minimum time)
|
|
- Handles Test Bowl time inversion
|
|
- Uses `rsplit_once("_a")` for correct track name splitting
|
|
|
|
#### `update_best_car(best_cars, track_id, car_rq, surface, new_record)`
|
|
**Updates best car records with a new performance entry** (`src/parsing/mod.rs`)
|
|
|
|
- Iterates RQ range from car's RQ to `MAX_RQ`
|
|
- Replaces existing record if new time is faster
|
|
- Adds to tie list if within `TIME_TOLERANCE`
|
|
- Prevents duplicate `car_rid` entries in tie lists
|
|
- Optimized: avoids cloning on the last iteration via `is_last` guard
|
|
|
|
#### `parse_all_times(my_cars, ctx, cars_db_path, times_dir) -> Result<Vec<Car>, AppError>`
|
|
**Orchestrates concurrent parsing of all car timing files** (`src/parsing/mod.rs`)
|
|
|
|
- Loads car list from database
|
|
- Uses `Semaphore(CONCURRENCY_LIMIT)` for bounded concurrency
|
|
- Acquires semaphore in async context, then runs sync parsing via `spawn_blocking`
|
|
- Returns the full car list for downstream use
|
|
|
|
#### `fetch_times(cars_db_path, times_dir) -> Result<(), AppError>`
|
|
**Fetches timing data files for all cars** (`src/parsing/mod.rs`)
|
|
|
|
- Uses bounded concurrency via semaphore
|
|
- Returns first error encountered, or `Ok(())` if all succeed
|
|
|
|
#### `get_car_list(cars_db_path: &str) -> Result<Vec<Car>, AppError>`
|
|
**Loads the car database** (`src/parsing/car_list.rs`)
|
|
|
|
- Uses serde deserialization for type-safe parsing
|
|
- Returns `AppError::MissingFile` or `AppError::Parse` on failure
|
|
|
|
---
|
|
|
|
## Commands Module (`src/commands/`)
|
|
|
|
### `run_analysis(owned, cars_db_path, times_dir) -> Result<(AnalysisContext, CarCounts), AppError>`
|
|
**Common analysis setup** (`src/commands/mod.rs`)
|
|
|
|
Loads car database, parses all timing files concurrently, and computes car counts.
|
|
|
|
### `load_my_cars_or_warn(path: &str) -> Result<MyCarsData, AppError>`
|
|
**Loads user data with graceful degradation** (`src/commands/mod.rs`)
|
|
|
|
- Returns empty `MyCarsData::default()` with a warning if file is missing
|
|
- Propagates parse errors and other failures
|
|
|
|
---
|
|
|
|
## Output Module (`src/output/`)
|
|
|
|
### CSV Output Functions
|
|
|
|
#### `write_sorted_csv(path, header, entries, cmp, format_row) -> Result<(), AppError>`
|
|
**Generic sorted CSV writer**
|
|
|
|
- Sorts by index (avoids cloning entries)
|
|
- Writes header row
|
|
- Formats each row via closure
|
|
|
|
#### `write_best_csv(counts)`, `write_very_best_csv(counts, min_count)`
|
|
**Generate best car CSV files**
|
|
|
|
- Format: `RQ,Brand,Model,Year,Count`
|
|
- Fields are CSV-escaped
|
|
|
|
#### `write_trash_csv(counts, owned)`
|
|
**Generate trash car CSV file**
|
|
|
|
- Filters: not best on any track, RQ < 80, not prize car
|
|
- Format: `RQ,Brand,Model,Year`
|
|
|
|
#### `write_wishlist_files(counts, owned, wishlist, min_count, max_wishlist)`
|
|
**Generate wishlist, add, and remove CSV files**
|
|
|
|
- Computes ideal wishlist once
|
|
- Derives all three files from it
|
|
- Format: `RQ,Brand,Model,Year,Count`
|
|
|
|
#### `write_details_csv(ctx)`
|
|
**Generate comprehensive timing data CSV**
|
|
|
|
- Format: `RQ,TrackID,Condition,CarRQ,Brand,Model,Year,Config,Time`
|
|
- Sorted by RQ then track ID
|
|
|
|
#### `print_best_car(ctx, track, condition, rq) -> Result<(), AppError>`
|
|
**Print best car for a specific track/condition/RQ to stdout**
|
|
|
|
#### `compute_ideal_wishlist(counts, owned, min_count, max_wishlist) -> Vec<(BestCarRecord, usize)>`
|
|
**Computes the ideal wishlist**
|
|
|
|
- Filters to cars with count >= `min_count`
|
|
- Excludes owned cars
|
|
- Sorts by RQ
|
|
- Caps at `max_wishlist`
|
|
|
|
### Sorting Helpers
|
|
|
|
#### `compare_records(a: &BestCarRecord, b: &BestCarRecord) -> std::cmp::Ordering`
|
|
**Comparison for BestCarRecord sorting**
|
|
|
|
- Sorts by: RQ → Brand → Model name
|