Compare commits

...

10 commits

Author SHA1 Message Date
cc73fe62fc Add progress counter for car time downloads
Show a persistent single-line progress counter ("Downloading track times
for car 1234/7589") while fetch_times downloads timing data. A ProgressBar
from indicatif 0.17 is sized to the car list, cloned into each spawned
download task, and incremented after fetch_car completes so it tracks
completions rather than starts; Arc internals make this safe under the
concurrent semaphore. The bar is cleared via finish_and_clear() before
the error/warning logging loop so retry messages don't garble the line,
and indicatif renders nothing when stderr is not a TTY.

Also apply pending rustfmt reformatting to src/capture/windows.rs
(formatting only, no logic changes).
2026-08-22 10:53:17 +02:00
f829ad52a4 Shut down game, adb and BlueStacks after capture 2026-08-22 10:35:36 +02:00
3de8bf1f80 Silence CONNECT tunnel logging in capture output 2026-08-22 10:34:06 +02:00
ead31529a4 Add desktop launcher script for one-click garage capture 2026-08-22 10:26:38 +02:00
b85672559b Add Windows-only garage capture via MITM proxy and ADB automation 2026-08-22 10:23:56 +02:00
7ac889cbff Handle no-record cars and per-copy trash listing
Empty API responses (200 + empty body) mean "known car, no times
submitted yet" on the records site — typically newly released prize
cars. Previously each such car burned 3 retries with backoff and then
aborted the entire fetch; 27 cars in the current DB hit this.

- Add AppError::EmptyResponse { url } as a distinct, deterministic
  failure class; get_file fails immediately instead of retrying
- fetch_car downgrades EmptyResponse to a skip with an info log, so
  fetch completes; the next fetch picks the cars up automatically
  once records exist
- parse_car_times treats empty (or whitespace-only) timing files
  like missing files, eliminating "EOF while parsing a value at line
  1 column 0" parse errors from stale empty files

trash.csv Config column now lists only the copies to delete: for a
car whose one tune is a final build not best on any track (e.g.
owned 332 next to a best-on-213-tracks 323), only the dead tune is
shown instead of joining all owned configs. Categories 1 and 3 still
list all configs since the whole car goes.

Tests: empty response makes exactly 1 request and yields the
EmptyResponse variant; empty file parses Ok; partial-copy trash
shows only the deletable config.
2026-08-22 07:24:22 +02:00
293a8d7707 Add protect/storage lists, dynamic trash thresholds, tie resolution
Protect & storage lists:
- protect.csv: unlocked garage cars (state != 0, locked == false) worth
  locking more copies of. Needed copies = best configs surviving the
  RQ-band thresholds (prize cars and RQ >= 80 always count); already
  locked copies (copy_count) are subtracted, surplus drops off the list.
- storage.csv: Keep/Trash recommendation for storage cars (state == 0)
  using the same copy-count logic; surplus unlocked copies are fusion
  material.
- user_data.rs now parses card state (garage vs storage), garage slots
  (user.garageSlots/garageSlotsUsed), and unlocked card maps.

Dynamic trash thresholds:
- TrashThresholds derive RQ 20-29/30-39 as half/a quarter of a base
  value (--trash-base-count, default 4); RQ 40+ never covered.
- plan_trash_rules escalates the base automatically while projected free
  garage slots (capacity - used + unlocked non-best copies + new trash
  candidates, copies counted per card) stay below --garage-min-free
  (default 20), stopping when no further cars qualify. Effective
  thresholds and projection are printed.
- Improve-based low-value cars (best target count at or below the band
  threshold) land in trash.csv; collect_improve_entries is shared with
  improve.csv and computed once per run.

Same-car tie resolution:
- resolve_tied_configs collapses tie-equivalent configs of the same car
  per track (compute_car_counts, write_details_csv), preferring the
  player's owned final-build config. Fixes phantom rows like the
  Mercedes 280 SL 332 entry tied at 61.31 by its owned 323, inflated
  per-config track counts, and pointless second-copy recommendations.

Wishlist:
- Prize cars are excluded from the ideal wishlist and surface in
  remove.csv via the ideal-set difference.

CLI: new protect command; --trash-base-count/--garage-min-free on
trash/protect/all; details command reuses counts for tie resolution.
README updated for all new files, flags, and criteria.
2026-08-21 18:45:37 +02:00
ec8be9d138 Enforce feature isolation and expand copy_count test coverage
- Gate env_logger init behind #[cfg(feature = "bin-logging")] in
  records.rs main() so the binary respects the feature flag
- Set default-features = false on the dev-dependency self-reference
  in Cargo.toml; previously default features were silently re-enabled
  during tests, making feature isolation accidental rather than real
- Bind guid.to_string() once per card in user_data.rs deck loop and
  reuse it via clone, replacing three allocations per locked card
- Add per-field help text to the Track subcommand args clarifying
  human-readable names ("Laguna Seca", "Dry") vs raw codes
- Document write_details_csv DashMap snapshot memory tradeoff
  (full clone, roughly doubled peak memory during the write)
- Extend my_cars fixture test: duplicate guids count as 2 copies
  with per-copy configs, and incomplete-tune cards still count in
  copy_count
2026-08-15 21:30:38 +02:00
1a844e7aa8 Address fourth review round: observability, docs accuracy, dep hygiene, tests
- Warn when no timing data was found at all (empty --times-dir previously
  produced empty CSVs without any hint) — best_cars empty + non-empty car
  list now logs a warning in parse_all_times
- Remove the inaccurate "known gap" doc from write_trash_csv: category 1
  (not best on any track) already covers non-final cars, so the
  documented gap never existed
- Gate env_logger behind the bin-logging feature (default-on) and mark
  the records binary as requiring it, so downstream lib consumers do not
  pull env_logger transitively; lib builds clean with --no-default-features
- Switch fetch_car progress output from println! to log::info! so
  RUST_LOG controls it
- atomic_write takes &Path instead of &PathBuf
- Add direct unit tests for is_final_build (sum-8 finals, maxed builds,
  sub-8 non-finals, malformed inputs) and a parsing test covering
  missing/null/non-numeric "t" fields (warn + skip, valid entries still
  parsed)
2026-08-15 20:14:53 +02:00
58b8d23e68 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
2026-08-15 19:29:51 +02:00
28 changed files with 4295 additions and 306 deletions

3
.gitignore vendored
View file

@ -5,5 +5,8 @@ cars_repo.json
tracks_repo.json
times/
*.csv
ca/
captures/
capture.toml
AGENTS.md

View file

@ -32,6 +32,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Removed
- `plans/code-review-v2.md` (completed action items)
### Fixed
- `trash.csv` Config column now lists only the copies to delete: for cars where just one tune is useless (e.g. owned 332 + best-everywhere 323) only the dead tune is shown instead of all owned configs
- Cars the records site knows but has no times for (typically new prize cars, returned as `200` with an empty body) no longer abort `fetch` after 3 retries — they are skipped with an info log
- Empty timing files are now treated like missing files instead of producing `EOF while parsing a value` parse errors
## [0.3.0] - 2026-05-07
### Added

1204
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -7,9 +7,16 @@ edition = "2021"
name = "top_drives"
path = "src/lib.rs"
# Bin-only (logger init in records.rs); required-features keeps the lib crate's
# downstream consumers free of env_logger unless they opt in.
[[bin]]
name = "records"
path = "src/records.rs"
required-features = ["bin-logging"]
[[bin]]
name = "capture"
path = "src/capture/main.rs"
[dependencies]
reqwest = "0.12.15"
@ -20,13 +27,26 @@ futures = "0.3.31"
clap = { version = "4", features = ["derive"] }
thiserror = "2"
dashmap = "6"
log = "0.4"
env_logger = { version = "0.11", optional = true }
indicatif = "0.17"
[target.'cfg(windows)'.dependencies]
hudsucker = "0.25"
http = "1"
http-body-util = "0.1"
md-5 = "0.10"
time = { version = "0.3", features = ["formatting"] }
base64 = "0.22"
toml = "0.8"
[features]
default = []
default = ["bin-logging", "test-utils"]
bin-logging = ["dep:env_logger"]
test-utils = []
[dev-dependencies]
top_drives = { path = ".", features = ["test-utils"] }
top_drives = { path = ".", features = ["test-utils"], default-features = false }
tempfile = "3.10.1"
proptest = "1"
mockito = "1"

View file

@ -112,18 +112,48 @@ cargo run --bin records -- best --min-count 5
```bash
cargo run --bin records -- trash
```
Generates `trash.csv` — owned cars that are not best on any track, have RQ < 80, and are not prize cars. Sorted by RQ.
Generates `trash.csv` — owned cars that are not best on any track, have RQ < 80, and are not prize cars, plus low-value upgrade fodder: unfinished cars whose best config reaches at most a per-RQ-band track count (RQ 10-19: ≤4, RQ 20-29: ≤2, RQ 30-39: ≤1 by default). Sorted by RQ.
**Output File:**
```
trash.csv — Owned cars safe to remove
trash.csv — Owned cars safe to remove or use as fusion material
```
The band thresholds derive from a single base value (`--trash-base-count`, default 4); each higher band gets half of the previous one. The tool prints the effective thresholds and a garage-space projection (`user.garageSlots`/`garageSlotsUsed` from the export). When the projected free slots after clean-up fall below `--garage-min-free` (default 20), the base count is raised automatically — and printed — until the target is reached or no more cars qualify:
```bash
cargo run --bin records -- trash --trash-base-count 5 --garage-min-free 30
```
Pass `--trash-base-count 0` to disable the improve-based criteria entirely.
#### Protect & Storage Analysis
```bash
cargo run --bin records -- protect
```
Generates `protect.csv` — cars in the **garage** that are currently **not locked** and are worth locking more copies of — and `storage.csv`, a keep/trash recommendation for cars in the **storage** ("Lager", `state == 0` in the export). Both apply the same dynamic criteria (including garage-space escalation and its printed plan) as the trash command.
**Output Files:**
```
protect.csv — Unlocked garage cars worth locking
storage.csv — Keep/Trash recommendation for storage cars
```
`protect.csv` columns: `RQ,Brand,Model,Year,FinalBuild,Count,ToLock`. A car drops off the list once its locked copies cover every best config surviving the RQ-band thresholds; `ToLock` is the number of additional copies worth locking. Cars not best on any track never appear.
`storage.csv` columns: `RQ,Brand,Model,Year,FinalBuild,Count,Recommendation`. Cars not best on any track, best only in configs at or below their RQ-band threshold, or with enough locked copies already (surplus unlocked copies are fusion material), are `Trash`; prize cars and RQ ≥ 80 are always `Keep`.
The trash criteria flags apply here as well:
```bash
cargo run --bin records -- protect --trash-base-count 5 --garage-min-free 30
```
#### Wishlist Management
```bash
cargo run --bin records -- wishlist
```
Generates `wishlist.csv` (ideal wishlist: very_best unowned cars, capped at 300), `add.csv` (cars that should be on your wishlist but aren't), and `remove.csv` (cars on your wishlist that shouldn't be). All sorted by RQ.
Generates `wishlist.csv` (ideal wishlist: very_best unowned cars, capped at 300), `add.csv` (cars that should be on your wishlist but aren't), and `remove.csv` (cars on your wishlist that shouldn't be). All sorted by RQ. Prize cars are excluded from the ideal wishlist; prize cars still on your actual wishlist appear in `remove.csv`.
**Output Files:**
```
@ -146,7 +176,7 @@ cargo run --bin records -- wishlist --max-wishlist 500
```bash
cargo run --bin records -- all
```
Generates all 7 CSV files in a single parse pass. Equivalent to running `best`, `trash`, `wishlist`, and `improve` together, but more efficient.
Generates all 10 CSV files in a single parse pass. Equivalent to running `best`, `trash`, `wishlist`, `improve`, and `protect` together, but more efficient.
**Output Files:**
```
@ -157,11 +187,13 @@ wishlist.csv — Ideal wishlist (very_best unowned, capped)
add.csv — Cars to add to wishlist
remove.csv — Cars to remove from wishlist
improve.csv — Owned cars not yet at final build config
protect.csv — Unlocked garage cars worth locking
storage.csv — Keep/Trash recommendation for storage cars
```
Supports both `--min-count N` (default 10) and `--max-wishlist M` (default 300):
Supports `--min-count N` (default 10), `--max-wishlist M` (default 300), and the trash criteria flags:
```bash
cargo run --bin records -- all --min-count 5 --max-wishlist 500
cargo run --bin records -- all --min-count 5 --max-wishlist 500 --trash-base-count 4 --garage-min-free 20
```
#### Detailed Performance Data
@ -202,9 +234,10 @@ The analyzer supports multiple track conditions:
|---------|-------------|-----------|
| `fetch` | Download latest car and track data | None |
| `best` | Generate `best.csv` and `very_best.csv` | `[--min-count N]` |
| `trash` | Generate `trash.csv` (owned cars safe to remove) | None |
| `trash` | Generate `trash.csv` (owned cars safe to remove) | `[--trash-base-count N] [--garage-min-free N]` |
| `protect` | Generate `protect.csv` (unlocked garage cars) and `storage.csv` (storage keep/trash) | `[--trash-base-count N] [--garage-min-free N]` |
| `wishlist` | Generate `wishlist.csv`, `add.csv`, `remove.csv` | `[--min-count N] [--max-wishlist M]` |
| `all` | Generate all 7 CSV files in a single pass | `[--min-count N] [--max-wishlist M]` |
| `all` | Generate all 10 CSV files in a single pass | `[--min-count N] [--max-wishlist M] [--trash-base-count N] [--garage-min-free N]` |
| `details` | Print detailed timing data for all tracks (stdout) | None |
| `track` | Print best car for specific track (stdout) | `<track_name> <condition> <rq>` |
| `improve` | Generate `improve.csv` (owned cars not at final build) | None |
@ -215,6 +248,8 @@ The analyzer supports multiple track conditions:
|------|---------|-------------|
| `--min-count N` | `10` | Minimum number of tracks a car must be best on to qualify as "very best" |
| `--max-wishlist M` | `300` | Maximum number of cars on the ideal wishlist |
| `--trash-base-count N` | `4` | Maximum improve track count for RQ 10-19 to count as trash; RQ 20-29/30-39 get half/a quarter. `0` disables the criteria |
| `--garage-min-free N` | `20` | Minimum projected free garage slots; the trash base count is raised automatically when the projection falls short |
## 📅 Daily Workflow

31
capture.toml.example Normal file
View file

@ -0,0 +1,31 @@
# Example configuration for `cargo run --bin capture`.
# Copy to capture.toml and adjust to your machine; capture.toml is gitignored.
#
# The capture binary is Windows-only (BlueStacks + adb interception); on Unix
# hosts it builds as a stub that exits with an error message.
[device]
# BlueStacks ADB endpoint (BlueStacks Settings -> Advanced -> Android Debug Bridge)
serial = "127.0.0.1:5555"
# How long to wait for the emulator to come online before giving up
wait_secs = 180
# Close BlueStacks automatically after the capture finished
shutdown_bluestacks = true
[paths]
# Path to adb.exe (empty = auto-detect from LOCALAPPDATA, then PATH)
adb = 'C:\Users\<you>\AppData\Local\Android\Sdk\platform-tools\adb.exe'
# Path to the BlueStacks player, started automatically if the device is offline
bluestacks = 'C:\Program Files\BlueStacks_nxt\HD-Player.exe'
# Where the captured garage document is written
output = 'my_cars.json'
[proxy]
# Local port for the MITM proxy (must be free; close HTTP Toolkit first)
port = 8080
[capture]
# How long to wait for the game to boot and issue the sync request
timeout_secs = 300
# Per-request timeout when re-issuing the stored sync request (`capture replay`)
replay_timeout_secs = 30

23
run_capture.cmd Normal file
View file

@ -0,0 +1,23 @@
@echo off
setlocal
cd /d "%~dp0"
if not exist capture.toml (
echo No capture.toml found.
echo Copy capture.toml.example to capture.toml and adjust the paths first.
pause
exit /b 1
)
if not exist target\release\capture.exe (
echo Building capture binary ...
cargo build --release --bin capture
if errorlevel 1 (
pause
exit /b 1
)
)
target\release\capture.exe run
echo.
pause

13
src/capture/main.rs Normal file
View file

@ -0,0 +1,13 @@
#[cfg(windows)]
mod windows;
#[cfg(windows)]
fn main() {
windows::run();
}
#[cfg(not(windows))]
fn main() {
eprintln!("error: the capture binary is Windows-only (BlueStacks + ADB MITM interception)");
std::process::exit(2);
}

902
src/capture/windows.rs Normal file
View file

@ -0,0 +1,902 @@
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use base64::Engine;
use http::request::Request;
use http_body_util::BodyExt;
use hudsucker::certificate_authority::RcgenAuthority;
use hudsucker::rcgen::{
BasicConstraints, CertificateParams, DnType, IsCa, Issuer, KeyPair, KeyUsagePurpose,
PKCS_ECDSA_P256_SHA256,
};
use hudsucker::{Body, HttpContext, HttpHandler, Proxy, RequestOrResponse};
use md5::{Digest, Md5};
use serde::{Deserialize, Serialize};
const GAME_PACKAGE: &str = "com.hutchgames.cccg";
const CA_COMMON_NAME: &str = "Top Drives Capture CA";
const GARAGE_MARKER: &[u8] = b"playerDeck";
const CONFIG_FILE: &str = "capture.toml";
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
#[derive(Debug, Serialize, Deserialize)]
#[serde(default)]
struct CaptureConfig {
device: DeviceConfig,
paths: PathsConfig,
proxy: ProxyConfig,
capture: CaptureSettings,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(default)]
struct DeviceConfig {
serial: String,
wait_secs: u64,
shutdown_bluestacks: bool,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(default)]
struct PathsConfig {
adb: String,
bluestacks: String,
output: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(default)]
struct ProxyConfig {
port: u16,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(default)]
struct CaptureSettings {
timeout_secs: u64,
replay_timeout_secs: u64,
}
impl Default for CaptureConfig {
fn default() -> Self {
Self {
device: DeviceConfig::default(),
paths: PathsConfig::default(),
proxy: ProxyConfig::default(),
capture: CaptureSettings::default(),
}
}
}
impl Default for DeviceConfig {
fn default() -> Self {
Self { serial: "127.0.0.1:5555".to_string(), wait_secs: 180, shutdown_bluestacks: true }
}
}
impl Default for PathsConfig {
fn default() -> Self {
Self {
adb: detect_adb(),
bluestacks: detect_bluestacks(),
output: "my_cars.json".to_string(),
}
}
}
impl Default for ProxyConfig {
fn default() -> Self {
Self { port: 8080 }
}
}
impl Default for CaptureSettings {
fn default() -> Self {
Self { timeout_secs: 300, replay_timeout_secs: 30 }
}
}
fn detect_adb() -> String {
if let Ok(local_app_data) = std::env::var("LOCALAPPDATA") {
let candidate = format!("{local_app_data}\\Android\\Sdk\\platform-tools\\adb.exe");
if Path::new(&candidate).exists() {
return candidate;
}
}
"adb".to_string()
}
fn detect_bluestacks() -> String {
const CANDIDATES: [&str; 3] = [
r"C:\Program Files\BlueStacks_nxt\HD-Player.exe",
r"C:\Program Files\BlueStacks\HD-Player.exe",
r"C:\Program Files (x86)\BlueStacks\HD-Player.exe",
];
CANDIDATES
.iter()
.find(|candidate| Path::new(candidate).exists())
.map(|candidate| candidate.to_string())
.unwrap_or_default()
}
fn load_config(project_root: &Path) -> Result<CaptureConfig> {
let config_path = project_root.join(CONFIG_FILE);
if config_path.exists() {
let text = std::fs::read_to_string(&config_path)?;
let config: CaptureConfig = toml::from_str(&text)?;
return Ok(config);
}
let config = CaptureConfig::default();
let pretty = toml::to_string_pretty(&config)?.replace("\\\\", "/");
std::fs::write(&config_path, &pretty)?;
println!("created default configuration at {}", config_path.display());
println!("review it and adjust paths if needed, then run this command again");
std::process::exit(0);
}
fn output_path(config: &CaptureConfig, project_root: &Path) -> PathBuf {
let raw = Path::new(&config.paths.output);
if raw.is_absolute() {
raw.to_path_buf()
} else {
project_root.join(raw)
}
}
#[derive(Serialize, Deserialize)]
struct CapturedRequest {
method: String,
url: String,
headers: Vec<(String, String)>,
#[serde(skip_serializing_if = "Option::is_none")]
body: Option<String>,
}
#[derive(Clone)]
struct CaptureHandler {
state: Arc<CaptureState>,
}
struct CaptureState {
found: AtomicBool,
done: tokio::sync::Notify,
project_root: PathBuf,
output: PathBuf,
pending: std::sync::Mutex<Option<CapturedRequest>>,
}
impl HttpHandler for CaptureHandler {
async fn handle_request(
&mut self,
_ctx: &HttpContext,
mut request: Request<Body>,
) -> RequestOrResponse {
let host = request_host(&request).to_lowercase();
if request.method() == http::Method::CONNECT {
return request.into();
}
let path_and_query = request
.uri()
.path_and_query()
.map(|value| value.as_str().to_string())
.unwrap_or_else(|| "/".to_string());
if !is_noise_host(&host) {
println!(" {} {}{}", request.method(), host, path_and_query);
}
request.headers_mut().remove(http::header::ACCEPT_ENCODING);
if !self.state.found.load(Ordering::SeqCst)
&& host.contains("topdrives")
&& path_and_query.contains("/sync")
{
let body_bytes =
BodyExt::collect(std::mem::replace(request.body_mut(), Body::from(Vec::new())))
.await
.map(|collected| collected.to_bytes().to_vec())
.unwrap_or_default();
let template = CapturedRequest {
method: request.method().to_string(),
url: format!("https://{host}{path_and_query}"),
headers: request
.headers()
.iter()
.map(|(name, value)| {
(
name.as_str().to_string(),
String::from_utf8_lossy(value.as_bytes()).to_string(),
)
})
.collect(),
body: if body_bytes.is_empty() {
None
} else {
Some(base64::engine::general_purpose::STANDARD.encode(&body_bytes))
},
};
*self.state.pending.lock().unwrap() = Some(template);
*request.body_mut() = Body::from(body_bytes);
}
request.into()
}
async fn handle_response(
&mut self,
_ctx: &HttpContext,
mut response: http::Response<Body>,
) -> http::Response<Body> {
if self.state.found.load(Ordering::SeqCst) {
return response;
}
let looks_textual = response
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(|value| value.contains("json") || value.contains("text"))
.unwrap_or(false);
if !looks_textual {
return response;
}
let bytes =
match BodyExt::collect(std::mem::replace(response.body_mut(), Body::from(Vec::new())))
.await
{
Ok(collected) => collected.to_bytes().to_vec(),
Err(_) => return response,
};
if contains_marker(&bytes, GARAGE_MARKER) {
if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&bytes) {
if json.get("user").is_some() {
let template = self.state.pending.lock().unwrap().take();
if let Err(error) = persist_capture(
&self.state.project_root,
&self.state.output,
&json,
template.as_ref(),
) {
eprintln!(" failed to save capture: {error}");
}
self.state.found.store(true, Ordering::SeqCst);
self.state.done.notify_waiters();
}
}
}
*response.body_mut() = Body::from(bytes);
response
}
}
fn request_host(request: &Request<Body>) -> String {
if let Some(host) = request.uri().host() {
return host.to_string();
}
request
.headers()
.get(http::header::HOST)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.split(':').next())
.unwrap_or_default()
.to_string()
}
async fn fetch_response(
client: &reqwest::Client,
method: &http::Method,
url: &str,
headers: &http::HeaderMap,
body: &[u8],
timeout: Duration,
) -> Result<Vec<u8>> {
let mut builder = client
.request(reqwest::Method::from_bytes(method.as_str().as_bytes())?, url)
.timeout(timeout);
for (name, value) in headers.iter() {
let lower = name.as_str().to_lowercase();
if matches!(
lower.as_str(),
"host"
| "content-length"
| "accept-encoding"
| "connection"
| "proxy-connection"
| "keep-alive"
) {
continue;
}
builder = builder.header(name.as_str(), value.as_bytes());
}
if !body.is_empty() {
builder = builder.body(body.to_vec());
}
let response = builder.send().await?;
let status = response.status();
let bytes = response.bytes().await?;
if !status.is_success() {
return Err(format!("HTTP {status}").into());
}
Ok(bytes.to_vec())
}
fn persist_capture(
project_root: &Path,
output: &Path,
json: &serde_json::Value,
template: Option<&CapturedRequest>,
) -> Result<()> {
let timestamp =
time::OffsetDateTime::now_utc().format(&time::format_description::parse_borrowed::<2>(
"[year]-[month]-[day]T[hour]-[minute]-[second]",
)?)?;
let run_dir = project_root.join("captures").join(&timestamp);
std::fs::create_dir_all(&run_dir)?;
let pretty = serde_json::to_string_pretty(json)?;
std::fs::write(run_dir.join("my_cars.json"), &pretty)?;
if output.exists() {
let backup = run_dir.join("my_cars.previous.json");
std::fs::copy(output, backup)?;
}
std::fs::write(output, &pretty)?;
if let Some(template) = template {
std::fs::write(
project_root.join("captures").join("request.json"),
serde_json::to_string_pretty(template)?,
)?;
}
println!(" garage data saved to {}", output.display());
print_garage_summary(json);
Ok(())
}
fn print_garage_summary(json: &serde_json::Value) {
let social_name = json
.pointer("/user/socialName")
.and_then(|value| value.as_str())
.unwrap_or("unknown player");
let deck = json
.get("playerDeck")
.and_then(|value| value.as_array())
.map(|cards| cards.len())
.unwrap_or(0);
let locked = json
.get("playerDeck")
.and_then(|value| value.as_array())
.map(|cards| {
cards
.iter()
.filter(|card| card.get("locked").and_then(|l| l.as_bool()).unwrap_or(false))
.count()
})
.unwrap_or(0);
println!(" player: {social_name}, garage: {deck} cars ({locked} locked)");
}
fn is_noise_host(host: &str) -> bool {
const NOISE: [&str; 15] = [
"google",
"gstatic",
"crashlytics",
"firebase",
"facebook",
"fbcdn",
"unity",
"applovin",
"admob",
"doubleclick",
"vungle",
"ironsource",
"chartboost",
"sentry",
"bugsnag",
];
NOISE.iter().any(|fragment| host.contains(fragment))
}
fn contains_marker(haystack: &[u8], marker: &[u8]) -> bool {
haystack.windows(marker.len()).any(|window| window == marker)
}
fn run_adb(config: &CaptureConfig, args: &[&str]) -> Result<String> {
let output = Command::new(&config.paths.adb)
.args(args)
.stdin(Stdio::null())
.output()
.map_err(|error| format!("failed to run adb ({}): {error}", config.paths.adb))?;
if !output.status.success() {
return Err(format!(
"adb {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr).trim()
)
.into());
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn adb(config: &CaptureConfig, args: &[&str]) -> Result<String> {
let serial = config.device.serial.as_str();
run_adb(config, &[&["-s", serial][..], args].concat())
}
fn adb_shell(config: &CaptureConfig, command: &str) -> Result<String> {
adb(config, &["shell", command])
}
fn adb_shell_root(config: &CaptureConfig, command: &str) -> Result<String> {
let quoted = format!("su -c '{command}'");
adb(config, &["shell", quoted.as_str()])
}
fn device_listed(config: &CaptureConfig) -> bool {
match run_adb(config, &["devices"]) {
Ok(devices) => devices
.lines()
.any(|line| line.starts_with(&config.device.serial) && line.contains("device")),
Err(_) => false,
}
}
fn ensure_device_connected(config: &CaptureConfig) -> Result<()> {
let deadline = Instant::now() + Duration::from_secs(config.device.wait_secs);
let mut bluestacks_started = false;
loop {
let _ = run_adb(config, &["connect", &config.device.serial]);
if device_listed(config) {
return Ok(());
}
if !bluestacks_started {
if config.paths.bluestacks.is_empty() || !Path::new(&config.paths.bluestacks).exists() {
return Err(format!(
"device {} is offline and no usable BlueStacks path is configured ({} in {CONFIG_FILE})",
config.device.serial, config.paths.bluestacks
)
.into());
}
println!(
"device {} offline, starting BlueStacks ({}) ...",
config.device.serial, config.paths.bluestacks
);
Command::new(&config.paths.bluestacks).spawn()?;
bluestacks_started = true;
}
if Instant::now() >= deadline {
return Err(format!(
"device {} did not come online within {}s",
config.device.serial, config.device.wait_secs
)
.into());
}
std::thread::sleep(Duration::from_secs(3));
}
}
fn android_sdk_level(config: &CaptureConfig) -> Result<u8> {
adb_shell(config, "getprop ro.build.version.sdk")?
.trim()
.parse()
.map_err(|_| "could not read Android SDK level".into())
}
fn load_or_create_ca(project_root: &Path) -> Result<(String, String)> {
let ca_dir = project_root.join("ca");
let cert_path = ca_dir.join("topdrives-ca.pem");
let key_path = ca_dir.join("topdrives-ca.key.pem");
if cert_path.exists() && key_path.exists() {
let cert = std::fs::read_to_string(&cert_path)?;
let key = std::fs::read_to_string(&key_path)?;
return Ok((cert, key));
}
std::fs::create_dir_all(&ca_dir)?;
let key_pair = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256)?;
let mut params = CertificateParams::new(Vec::new())?;
params.distinguished_name.push(DnType::CommonName, CA_COMMON_NAME);
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
params.key_usages = vec![
KeyUsagePurpose::KeyCertSign,
KeyUsagePurpose::CrlSign,
KeyUsagePurpose::DigitalSignature,
];
let now = time::OffsetDateTime::now_utc();
params.not_before = now - time::Duration::days(1);
params.not_after = now + time::Duration::days(3650);
let certificate = params.self_signed(&key_pair)?;
let cert_pem = certificate.pem();
let key_pem = key_pair.serialize_pem();
std::fs::write(&cert_path, &cert_pem)?;
std::fs::write(&key_path, &key_pem)?;
println!("created new CA certificate at {}", cert_path.display());
Ok((cert_pem, key_pem))
}
fn android_ca_filename(common_name: &str) -> String {
let mut canonical = Vec::new();
canonical.extend_from_slice(&[0x06, 0x03, 0x55, 0x04, 0x03]);
canonical.push(0x0C);
canonical.push(common_name.len() as u8);
canonical.extend_from_slice(common_name.as_bytes());
let digest = Md5::digest(&canonical);
let hash = u32::from_le_bytes([digest[0], digest[1], digest[2], digest[3]]);
format!("{hash:08x}.0")
}
fn inject_ca(config: &CaptureConfig, cert_pem: &str, project_root: &Path) -> Result<()> {
let filename = android_ca_filename(CA_COMMON_NAME);
let store_path = format!("/system/etc/security/cacerts/{filename}");
let staging_path = "/data/local/tmp/topdrives-ca.pem";
let local_path = project_root.join("ca").join("topdrives-ca.pem");
std::fs::write(&local_path, cert_pem)?;
let presence =
adb_shell(config, &format!("test -f {store_path} && echo present || echo absent"))?;
if presence.trim() == "present" {
println!("CA already present in the system trust store");
return Ok(());
}
adb(config, &["push", local_path.to_string_lossy().as_ref(), staging_path])?;
adb_shell_root(
config,
"mkdir -p /data/local/tmp/cacerts-backup && cp /system/etc/security/cacerts/* /data/local/tmp/cacerts-backup/",
)?;
let mounts = adb_shell(config, "cat /proc/mounts")?;
if !mounts.contains("/system/etc/security/cacerts") {
adb_shell_root(config, "mount -t tmpfs tmpfs /system/etc/security/cacerts")?;
}
adb_shell_root(
config,
&format!(
"cp /data/local/tmp/cacerts-backup/* /system/etc/security/cacerts/ && cp {staging_path} {store_path} && chmod 644 {store_path}"
),
)?;
let count_text = adb_shell(config, "ls /system/etc/security/cacerts | wc -l")?;
let count: u32 = count_text
.trim()
.parse()
.map_err(|_| "could not count certificates in the store".to_string())?;
if count < 100 {
return Err(format!("system store looks emptied ({count} certificates), aborting").into());
}
println!("injected CA into the system trust store as {filename} ({count} certificates total)");
Ok(())
}
fn set_device_proxy(config: &CaptureConfig, port: u16) -> Result<()> {
adb_shell(config, &format!("settings put global http_proxy 127.0.0.1:{port}")).map(|_| ())
}
fn clear_device_proxy(config: &CaptureConfig) {
let _ = adb_shell(config, "settings put global http_proxy :0");
let _ = adb_shell(config, "settings delete global http_proxy");
}
fn setup_adb_reverse(config: &CaptureConfig, port: u16) -> Result<()> {
let socket = format!("tcp:{port}");
match adb(config, &["reverse", socket.as_str(), socket.as_str()]) {
Ok(_) => Ok(()),
Err(_) => {
let route = adb_shell(config, "ip route")?;
let gateway = route.lines().find_map(|line| {
line.strip_prefix("default via ").and_then(|rest| rest.split(' ').next())
});
match gateway {
Some(gateway) => {
println!(
"adb reverse failed, falling back to gateway address {gateway}:{port}"
);
adb_shell(config, &format!("settings put global http_proxy {gateway}:{port}"))
.map(|_| ())
}
None => Err("could not set up adb reverse and no default gateway found".into()),
}
}
}
}
fn launch_game(config: &CaptureConfig) -> Result<()> {
adb_shell(config, &format!("am force-stop {GAME_PACKAGE}"))?;
std::thread::sleep(Duration::from_secs(2));
adb(
config,
&["shell", "monkey", "-p", GAME_PACKAGE, "-c", "android.intent.category.LAUNCHER", "1"],
)?;
Ok(())
}
fn build_proxy(
listener: tokio::net::TcpListener,
cert_pem: &str,
key_pem: &str,
handler: CaptureHandler,
) -> Result<ProxyFuture> {
let key_pair = KeyPair::from_pem(key_pem)?;
let issuer = Issuer::from_ca_cert_pem(cert_pem, key_pair)?;
let provider = hudsucker::rustls::crypto::aws_lc_rs::default_provider();
let authority = RcgenAuthority::new(issuer, 1_000, provider);
let proxy = Proxy::builder()
.with_listener(listener)
.with_ca(authority)
.with_rustls_connector(hudsucker::rustls::crypto::aws_lc_rs::default_provider())
.with_http_handler(handler)
.build()?;
Ok(Box::pin(proxy.start()))
}
type ProxyFuture = std::pin::Pin<
Box<dyn std::future::Future<Output = std::result::Result<(), hudsucker::Error>> + Send>,
>;
async fn command_run(config: &CaptureConfig) -> Result<()> {
let project_root = std::env::current_dir()?;
let (cert_pem, key_pem) = load_or_create_ca(&project_root)?;
ensure_device_connected(config)?;
let sdk_level = android_sdk_level(config)?;
if sdk_level > 28 {
println!("warning: Android {sdk_level} detected, the tmpfs CA injection is only tested up to Android 9");
}
inject_ca(config, &cert_pem, &project_root)?;
let listener = tokio::net::TcpListener::bind(std::net::SocketAddr::from((
[127, 0, 0, 1],
config.proxy.port,
)))
.await
.map_err(|error| {
format!(
"cannot listen on 127.0.0.1:{} (HTTP Toolkit still running?): {error}",
config.proxy.port
)
})?;
let state = Arc::new(CaptureState {
found: AtomicBool::new(false),
done: tokio::sync::Notify::new(),
project_root: project_root.clone(),
output: output_path(config, &project_root),
pending: std::sync::Mutex::new(None),
});
let proxy =
build_proxy(listener, &cert_pem, &key_pem, CaptureHandler { state: state.clone() })?;
tokio::spawn(proxy);
set_device_proxy(config, config.proxy.port)?;
setup_adb_reverse(config, config.proxy.port)?;
println!("proxy active on 127.0.0.1:{}, launching {GAME_PACKAGE} ...", config.proxy.port);
launch_game(config)?;
println!(
"waiting for the garage request (timeout {}s, Ctrl+C to abort)",
config.capture.timeout_secs
);
let outcome: Result<()> = tokio::select! {
_ = state.done.notified() => Ok(()),
_ = tokio::time::sleep(Duration::from_secs(config.capture.timeout_secs)) => Err("timed out waiting for the garage request".into()),
_ = tokio::signal::ctrl_c() => Err("aborted".into()),
};
clear_device_proxy(config);
let remove_socket = format!("--remove tcp:{}", config.proxy.port);
let _ = adb(config, &["reverse", remove_socket.as_str()]);
println!("device proxy settings restored");
println!("stopping {GAME_PACKAGE}");
let _ = adb_shell(config, &format!("am force-stop {GAME_PACKAGE}"));
let _ = run_adb(config, &["kill-server"]);
println!("adb stopped");
if config.device.shutdown_bluestacks {
close_bluestacks();
}
match outcome {
Ok(()) => {
println!("capture finished, run `cargo run --bin records -- best` to analyze");
Ok(())
}
Err(message) => Err(message),
}
}
async fn command_setup(config: &CaptureConfig) -> Result<()> {
let project_root = std::env::current_dir()?;
let (cert_pem, _) = load_or_create_ca(&project_root)?;
ensure_device_connected(config)?;
let sdk_level = android_sdk_level(config)?;
println!("device {} connected, Android SDK {}", config.device.serial, sdk_level);
inject_ca(config, &cert_pem, &project_root)?;
Ok(())
}
async fn command_replay(config: &CaptureConfig) -> Result<()> {
let project_root = std::env::current_dir()?;
let request_path = project_root.join("captures").join("request.json");
if !request_path.exists() {
return Err("no captured request found, run `capture run` first".into());
}
let template: CapturedRequest = serde_json::from_str(&std::fs::read_to_string(&request_path)?)?;
let mut headers = http::HeaderMap::new();
for (name, value) in &template.headers {
let lower = name.to_lowercase();
if matches!(
lower.as_str(),
"host"
| "content-length"
| "accept-encoding"
| "connection"
| "proxy-connection"
| "keep-alive"
) {
continue;
}
headers.insert(
http::HeaderName::from_bytes(name.as_bytes())?,
http::HeaderValue::from_bytes(value.as_bytes())?,
);
}
let body = template
.body
.as_deref()
.map(|encoded| base64::engine::general_purpose::STANDARD.decode(encoded))
.transpose()?
.unwrap_or_default();
let method: http::Method = template.method.parse()?;
let timeout = Duration::from_secs(config.capture.replay_timeout_secs);
let http = reqwest::Client::builder().timeout(timeout).build()?;
println!("replaying {} {}", method, template.url);
let bytes = fetch_response(&http, &method, &template.url, &headers, &body, timeout).await?;
if !contains_marker(&bytes, GARAGE_MARKER) {
return Err(
"response no longer contains the garage document, the session token probably expired; run `capture run` again".into(),
);
}
let json: serde_json::Value = serde_json::from_slice(&bytes)?;
persist_capture(&project_root, &output_path(config, &project_root), &json, Some(&template))?;
println!("replay finished successfully");
Ok(())
}
async fn command_clean(config: &CaptureConfig) -> Result<()> {
ensure_device_connected(config)?;
clear_device_proxy(config);
let remove_socket = format!("--remove tcp:{}", config.proxy.port);
let _ = adb(config, &["reverse", remove_socket.as_str()]);
let filename = android_ca_filename(CA_COMMON_NAME);
let _ = adb_shell_root(config, &format!("rm -f /system/etc/security/cacerts/{filename}"));
let _ = adb_shell_root(config, "umount /system/etc/security/cacerts");
println!("proxy settings cleared and CA removed from the device");
Ok(())
}
fn close_bluestacks() {
let silent = |args: &[&str]| {
let _ = Command::new("taskkill")
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
};
silent(&["/IM", "HD-Player.exe"]);
std::thread::sleep(Duration::from_secs(5));
silent(&["/F", "/IM", "HD-Player.exe"]);
println!("BlueStacks closed");
}
fn print_usage() {
println!(
"usage: capture <command>
commands:
setup generate the CA and inject it into BlueStacks
run full flow: start BlueStacks if needed, proxy, game launch, garage capture
replay re-issue the stored garage request without the emulator
clean remove proxy settings and the injected CA
configuration is read from {CONFIG_FILE} in the working directory"
);
}
#[tokio::main]
pub(crate) async fn run() {
let command = std::env::args().nth(1).unwrap_or_default();
if command.is_empty() {
print_usage();
return;
}
let project_root = std::env::current_dir().expect("cannot determine working directory");
let config = match load_config(&project_root) {
Ok(config) => config,
Err(error) => {
eprintln!("error reading {CONFIG_FILE}: {error}");
std::process::exit(1);
}
};
let result = match command.as_str() {
"setup" => command_setup(&config).await,
"run" => command_run(&config).await,
"replay" => command_replay(&config).await,
"clean" => command_clean(&config).await,
_ => {
print_usage();
return;
}
};
if let Err(error) = result {
eprintln!("error: {error}");
std::process::exit(1);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn marker_detection_finds_nested_json_key() {
let body = br#"{"serverId":"x","playerDeck":[{"cardId":"a"}]}"#;
assert!(contains_marker(body, GARAGE_MARKER));
assert!(!contains_marker(b"{}", GARAGE_MARKER));
}
#[test]
fn android_ca_filename_is_openssl_style_hash() {
let filename = android_ca_filename(CA_COMMON_NAME);
assert_eq!(filename.len(), 10);
assert!(filename.ends_with(".0"));
assert!(filename[..8].chars().all(|character| character.is_ascii_hexdigit()));
}
#[test]
fn noise_hosts_are_filtered() {
assert!(is_noise_host("ads.google.com"));
assert!(is_noise_host("config.firebase.io"));
assert!(!is_noise_host("api.hutchgames.com"));
assert!(!is_noise_host("topdrives-api.example.com"));
}
#[test]
fn default_config_round_trips_through_toml() {
let config = CaptureConfig::default();
let text = toml::to_string_pretty(&config).unwrap();
let parsed: CaptureConfig = toml::from_str(&text).unwrap();
assert_eq!(parsed.device.serial, config.device.serial);
assert_eq!(parsed.paths.output, config.paths.output);
assert_eq!(parsed.proxy.port, config.proxy.port);
}
}

View file

@ -1,11 +1,14 @@
//! All command implementation.
//!
//! Generates all output files in a single pass: best.csv, very_best.csv,
//! trash.csv, details.csv, wishlist.csv, add.csv, remove.csv, and improve.csv.
//! trash.csv, details.csv, wishlist.csv, add.csv, remove.csv, improve.csv,
//! protect.csv, and storage.csv.
use top_drives::AppError;
use crate::commands::{load_my_cars_or_warn, run_analysis};
use crate::commands::{
load_my_cars_or_warn, run_analysis, write_trash_with_rules, GarageInfo, TrashParams,
};
use crate::output;
/// Executes the all command: generates all output files in a single analysis pass.
@ -16,24 +19,27 @@ pub async fn run_all(
output_dir: &str,
min_count: usize,
max_wishlist: usize,
trash_params: &TrashParams,
) -> 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 unlocked = my_data.unlocked.clone();
let garage_unlocked = my_data.garage_unlocked.clone();
let storage = my_data.storage.clone();
let garage = GarageInfo::from_my_cars(&my_data);
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_details_csv(output_dir, &ctx)?;
output::write_wishlist_files(
output_dir,
&counts,
&my_data.owned,
&my_data.wishlist,
min_count,
max_wishlist,
)?;
let thresholds =
write_trash_with_rules(output_dir, &counts, &owned, &unlocked, trash_params, &garage)?;
output::write_details_csv(output_dir, &ctx, &counts)?;
output::write_wishlist_files(output_dir, &counts, &owned, &wishlist, min_count, max_wishlist)?;
output::write_improve_csv(output_dir, &counts)?;
output::write_protect_csv(output_dir, &counts, &garage_unlocked, thresholds.as_ref())?;
output::write_storage_csv(output_dir, &counts, &storage, thresholds.as_ref())?;
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, protect.csv, storage.csv"
);
Ok(())
}

View file

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

View file

@ -16,8 +16,8 @@ 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?;
write_details_csv(output_dir, &ctx)?;
let (ctx, counts) = run_analysis(my_data, cars_db_path, times_dir).await?;
write_details_csv(output_dir, &ctx, &counts)?;
println!("Written: details.csv");
Ok(())
}

View file

@ -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.");

View file

@ -8,16 +8,60 @@ pub mod best;
pub mod details;
pub mod fetch;
pub mod improve;
pub mod protect;
pub mod track;
pub mod trash;
pub mod wishlist;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use top_drives::{
compute_car_counts, load_my_cars_data, AnalysisContext, AppError, CarCounts, MyCarsData,
};
use crate::output::{
collect_improve_entries, plan_trash_rules, write_trash_csv, GarageProjection, TrashThresholds,
};
// ---------------------------------------------------------------------------
// Trash criteria parameters
// ---------------------------------------------------------------------------
/// Default base threshold for the improve-based trash criteria (RQ 10-19).
const DEFAULT_TRASH_BASE_COUNT: usize = 4;
/// Default minimum projected free garage slots for trash threshold escalation.
const DEFAULT_GARAGE_MIN_FREE: usize = 20;
/// CLI parameters for the improve-based trash criteria.
#[derive(clap::Args)]
pub struct TrashParams {
/// Maximum improve track count for RQ 10-19 to count as trash; RQ 20-29/30-39
/// get half/a quarter of it. 0 disables these criteria (default: 4)
#[arg(long, default_value_t = DEFAULT_TRASH_BASE_COUNT)]
pub trash_base_count: usize,
/// Minimum projected free garage slots; the base count is raised
/// automatically when the projection falls short (default: 20)
#[arg(long, default_value_t = DEFAULT_GARAGE_MIN_FREE)]
pub garage_min_free: usize,
}
/// Garage slot capacity and occupancy from the user export.
pub struct GarageInfo {
/// Total garage slot capacity (0 = unknown).
pub slots: usize,
/// Currently occupied garage slots.
pub slots_used: usize,
}
impl GarageInfo {
/// Extracts garage slot data from the parsed user export.
pub fn from_my_cars(my_data: &MyCarsData) -> Self {
GarageInfo { slots: my_data.garage_slots, slots_used: my_data.garage_slots_used }
}
}
use crate::parsing::parse_all_times;
// ---------------------------------------------------------------------------
@ -33,8 +77,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 +89,119 @@ 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, 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))
}
// ---------------------------------------------------------------------------
// Trash rule planning output
// ---------------------------------------------------------------------------
/// Plans the effective trash thresholds, escalating the base value against the
/// garage-space projection, and prints the plan.
///
/// Returns `None` when the improve-based criteria are disabled
/// (`trash_base_count == 0`).
pub fn plan_thresholds(
counts: &CarCounts,
unlocked: &HashMap<String, usize>,
params: &TrashParams,
garage: &GarageInfo,
) -> Option<TrashThresholds> {
if params.trash_base_count == 0 {
return None;
}
let improve = collect_improve_entries(counts);
let (thresholds, projection) = plan_trash_rules(
counts,
&improve,
unlocked,
params.trash_base_count,
garage.slots,
garage.slots_used,
params.garage_min_free,
);
print_trash_plan(&thresholds, &projection, params.garage_min_free);
Some(thresholds)
}
/// Plans the effective trash thresholds (printing the garage-space projection)
/// and writes trash.csv, returning the thresholds applied for other outputs
/// (protect.csv, storage.csv).
///
/// The caller is responsible for printing the "Written: ..." line so compound
/// commands like `all` can fold it into their summary.
pub fn write_trash_with_rules(
output_dir: &str,
counts: &CarCounts,
owned: &HashSet<String>,
unlocked: &HashMap<String, usize>,
params: &TrashParams,
garage: &GarageInfo,
) -> Result<Option<TrashThresholds>, AppError> {
let thresholds = plan_thresholds(counts, unlocked, params, garage);
let improve = collect_improve_entries(counts);
write_trash_csv(output_dir, counts, owned, thresholds.as_ref(), &improve)?;
Ok(thresholds)
}
/// Prints the effective trash thresholds and the garage-space projection.
pub fn print_trash_plan(
thresholds: &TrashThresholds,
projection: &GarageProjection,
min_free: usize,
) {
let base_note = if projection.base_count != projection.configured_base_count {
format!(
" (base raised from {} to {})",
projection.configured_base_count, projection.base_count
)
} else {
String::new()
};
println!(
"Trash thresholds: RQ 10-19: count <= {}, RQ 20-29: count <= {}, RQ 30-39: count <= {}{}",
thresholds.rq10_19, thresholds.rq20_29, thresholds.rq30_39, base_note
);
match projection.projected_free {
Some(free) => {
println!(
"Garage: {}/{} slots used ({} free now), {} unlocked to delete, {} unlocked to lock, {} new trash candidates -> {} free projected (target: {})",
projection.slots_used,
projection.garage_slots,
projection.free_now().unwrap_or(0),
projection.unlocked_freed,
projection.unlocked_protected,
projection.trash_candidates,
free,
min_free
);
if free < min_free {
log::warn!(
"Projected free garage slots ({free}) stay below the target ({min_free}) — no further cars qualify for the trash criteria"
);
}
}
None => {
println!("Garage: no slot data in export — thresholds applied without space projection")
}
}
}

40
src/commands/protect.rs Normal file
View file

@ -0,0 +1,40 @@
//! Protect command implementation.
//!
//! Generates protect.csv (unlocked garage cars worth locking more copies of)
//! and storage.csv (a keep/trash recommendation for cars in the storage),
//! both applying the same dynamic trash criteria as the trash command —
//! including the garage-space threshold escalation.
use top_drives::AppError;
use crate::commands::{
load_my_cars_or_warn, plan_thresholds, run_analysis, GarageInfo, TrashParams,
};
use crate::output::{write_protect_csv, write_storage_csv};
/// Executes the protect command: generates protect.csv and storage.csv.
///
/// protect.csv covers unlocked cars in the **garage** (`state != 0`,
/// `locked == false`); storage.csv covers cars in the **storage** (`state == 0`).
pub async fn run_protect(
my_cars_path: &str,
cars_db_path: &str,
times_dir: &str,
output_dir: &str,
params: &TrashParams,
) -> Result<(), AppError> {
let my_data = load_my_cars_or_warn(my_cars_path)?;
let unlocked = my_data.unlocked.clone();
let garage_unlocked = my_data.garage_unlocked.clone();
let storage = my_data.storage.clone();
let garage = GarageInfo::from_my_cars(&my_data);
let (_ctx, counts) = run_analysis(my_data, cars_db_path, times_dir).await?;
let thresholds = plan_thresholds(&counts, &unlocked, params, &garage);
let protected = write_protect_csv(output_dir, &counts, &garage_unlocked, thresholds.as_ref())?;
let (keep, trash) = write_storage_csv(output_dir, &counts, &storage, thresholds.as_ref())?;
println!(
"Written: protect.csv ({protected} garage cars to lock), storage.csv ({keep} keep, {trash} trash)"
);
Ok(())
}

View file

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

View file

@ -1,13 +1,16 @@
//! Trash command implementation.
//!
//! Generates trash.csv — owned cars that are not best on any track in their
//! current configuration (RQ < 80, not prize). Includes cars whose actual
//! final/maxed build is not best, even if other configs of the same car are.
//! current configuration (RQ < 80, not prize), plus low-value upgrade fodder
//! selected by RQ-band track-count thresholds. The base threshold is escalated
//! automatically when the projected free garage slots would fall short of a
//! configurable reserve.
use top_drives::AppError;
use crate::commands::{load_my_cars_or_warn, run_analysis};
use crate::output::write_trash_csv;
use crate::commands::{
load_my_cars_or_warn, run_analysis, write_trash_with_rules, GarageInfo, TrashParams,
};
/// Executes the trash command: generates trash.csv.
pub async fn run_trash(
@ -15,10 +18,14 @@ pub async fn run_trash(
cars_db_path: &str,
times_dir: &str,
output_dir: &str,
params: &TrashParams,
) -> 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 unlocked = my_data.unlocked.clone();
let garage = GarageInfo::from_my_cars(&my_data);
let (_ctx, counts) = run_analysis(my_data, cars_db_path, times_dir).await?;
write_trash_with_rules(output_dir, &counts, &owned, &unlocked, params, &garage)?;
println!("Written: trash.csv");
Ok(())
}

View file

@ -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(())
}

View file

@ -8,7 +8,7 @@ use std::sync::Arc;
use dashmap::DashMap;
use crate::types::{BestCarRecord, Car, TripletKey};
use crate::types::{is_final_build, BestCarRecord, Car, TripletKey};
/// Holds the mutable analysis state for a records analysis run.
///
@ -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>>,
@ -62,6 +62,55 @@ pub struct CarCounts {
pub copy_count: HashMap<String, usize>,
}
/// Resolves same-car tie duplicates within one track's best-car list.
///
/// All records in a track list are within the time tolerance of each other,
/// so multiple entries of the **same car** (different tune configs) are
/// tie-equivalent: either tune achieves the same time, and counting both
/// would double-claim the track per config and surface pointless
/// recommendations (e.g. wishlisting a second copy tuned 332 when the owned
/// 323 ties it). Exactly one config per car is kept:
///
/// - the player's owned final-build config, if one of the tied configs is
/// owned (so downstream ownership checks match what the player has), else
/// - the first record in list order (deterministic).
///
/// Records of other cars are passed through unchanged, preserving order.
pub fn resolve_tied_configs<'a>(
records: &'a [BestCarRecord],
owned_final: &HashMap<&str, HashSet<&str>>,
) -> Vec<&'a BestCarRecord> {
let is_owned_final = |record: &BestCarRecord| -> bool {
owned_final
.get(record.guid.as_str())
.is_some_and(|configs| configs.contains(record.normalized_config.as_str()))
};
let mut chosen: HashMap<&str, usize> = HashMap::with_capacity(records.len());
for (index, record) in records.iter().enumerate() {
match chosen.get(record.car_rid.as_str()) {
Some(&current) => {
// Replace only when the current pick is not owned-final and
// this record is — first-seen order wins otherwise.
let current_is_owned = is_owned_final(&records[current]);
if !current_is_owned && is_owned_final(record) {
chosen.insert(record.car_rid.as_str(), index);
}
}
None => {
chosen.insert(record.car_rid.as_str(), index);
}
}
}
records
.iter()
.enumerate()
.filter(|(index, record)| chosen.get(record.car_rid.as_str()) == Some(index))
.map(|(_, record)| record)
.collect()
}
/// Computes per-car win counts from the analysis context.
///
/// Returns a `CarCounts` struct that can be shared across all output functions.
@ -69,18 +118,30 @@ pub struct CarCounts {
/// `HashMap` inserts.
///
/// Groups by `(car_rid, normalized_config)` so that the same car with different
/// final builds is counted as separate entries.
/// final builds is counted as separate entries. Same-car ties on a track are
/// attributed to a single config (see [`resolve_tied_configs`]).
pub fn compute_car_counts(
ctx: &AnalysisContext,
car_list: Vec<Car>,
owned_configs: HashMap<String, Vec<String>>,
copy_count: HashMap<String, usize>,
) -> CarCounts {
// guid → owned final-build configs, borrowed views for tie attribution.
let owned_final: HashMap<&str, HashSet<&str>> = owned_configs
.iter()
.map(|(guid, configs)| {
(
guid.as_str(),
configs.iter().filter(|c| is_final_build(c)).map(|c| c.as_str()).collect(),
)
})
.collect();
let mut by_rid: HashMap<(String, String), (BestCarRecord, usize)> = HashMap::new();
let mut guid_to_rid: HashMap<String, HashSet<(String, String)>> = HashMap::new();
for entry in ctx.best_cars.iter() {
for best_car in entry.value() {
for best_car in resolve_tied_configs(entry.value(), &owned_final) {
let key = (best_car.car_rid.clone(), best_car.normalized_config.clone());
let rid_entry = by_rid.entry(key.clone()).or_insert_with(|| (best_car.clone(), 0usize));
rid_entry.1 += 1;
@ -94,10 +155,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)]
@ -109,6 +170,128 @@ mod tests {
HashMap::new()
}
fn tie_record(rid: &str, config: &str, guid: &str, time: f64) -> BestCarRecord {
BestCarRecord {
car_rid: rid.to_string(),
brand: "TestBrand".to_string(),
only_name: "TestModel".to_string(),
year: 2020,
rq: 85,
config: config.to_string(),
normalized_config: config.to_string(),
time,
owned: false,
prize: false,
guid: guid.to_string(),
}
}
fn owned_final_map<'a>(
guid: &'a str,
configs: &[&'a str],
) -> HashMap<&'a str, HashSet<&'a str>> {
HashMap::from([(guid, configs.iter().copied().collect::<HashSet<&'a str>>())])
}
#[test]
fn test_resolve_tied_configs_keeps_single_config_per_car() {
// Mercedes 280 SL case: 323 and 332 tie; unowned → first kept.
let records = vec![
tie_record("Car_A", "323", "guid_a", 61.31),
tie_record("Car_B", "332", "guid_b", 61.31),
tie_record("Car_A", "332", "guid_a", 61.31),
];
let resolved = resolve_tied_configs(&records, &HashMap::new());
assert_eq!(resolved.len(), 2, "one config per car plus the other car");
assert!(resolved.iter().any(|r| r.car_rid == "Car_A" && r.normalized_config == "323"));
assert!(resolved.iter().any(|r| r.car_rid == "Car_B"));
assert!(!resolved.iter().any(|r| r.car_rid == "Car_A" && r.normalized_config == "332"));
}
#[test]
fn test_resolve_tied_configs_prefers_owned_final_config() {
// Player owns 332 as a final build → the tie is attributed to 332
// even though 323 comes first.
let records = vec![
tie_record("Car_A", "323", "guid_a", 61.31),
tie_record("Car_A", "332", "guid_a", 61.31),
];
let owned_final = owned_final_map("guid_a", &["332"]);
let resolved = resolve_tied_configs(&records, &owned_final);
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].normalized_config, "332");
}
#[test]
fn test_resolve_tied_configs_ignores_owned_non_final_config() {
// An unfinished owned build does not absorb the tie.
let records = vec![
tie_record("Car_A", "323", "guid_a", 61.31),
tie_record("Car_A", "332", "guid_a", 61.31),
];
// "332" owned but NOT final is not in owned_final — first config kept.
let owned_final: HashMap<&str, HashSet<&str>> = HashMap::new();
let resolved = resolve_tied_configs(&records, &owned_final);
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].normalized_config, "323");
}
#[test]
fn test_resolve_tied_configs_keeps_first_owned_when_several_owned() {
// Both configs owned as final builds → the first owned one wins.
let records = vec![
tie_record("Car_A", "323", "guid_a", 61.31),
tie_record("Car_A", "332", "guid_a", 61.31),
];
let owned_final = owned_final_map("guid_a", &["323", "332"]);
let resolved = resolve_tied_configs(&records, &owned_final);
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].normalized_config, "323");
}
#[test]
fn test_compute_car_counts_attributes_tie_to_owned_config() {
// End-to-end: the tie track counts for the owned config only.
let ctx = AnalysisContext::new();
ctx.best_cars.insert(
("Ocean Highway".to_string(), 21, "Wet".to_string()),
vec![
tie_record("Car_A", "323", "guid_a", 61.31),
tie_record("Car_A", "332", "guid_a", 61.31),
],
);
let car_list = vec![Car {
rid: "Car_A".to_string(),
brand: "TestBrand".to_string(),
only_name: "TestModel".to_string(),
year: 2020,
rq: 21,
prize: false,
guid: "guid_a".to_string(),
}];
let mut owned_configs = HashMap::new();
owned_configs.insert("guid_a".to_string(), vec!["323".to_string()]);
let counts = compute_car_counts(&ctx, car_list, owned_configs, HashMap::new());
let count_323 =
counts.by_rid.get(&("Car_A".to_string(), "323".to_string())).map(|(_, c)| *c);
let count_332 =
counts.by_rid.get(&("Car_A".to_string(), "332".to_string())).map(|(_, c)| *c);
assert_eq!(count_323, Some(1), "tie attributed to the owned config");
assert_eq!(count_332, None, "tie-equivalent config is not counted");
}
#[test]
fn test_compute_car_counts_basic() {
let ctx = AnalysisContext::new();
@ -158,7 +341,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]

View file

@ -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.
@ -16,10 +21,13 @@ use crate::types::AppError;
/// Writes to a temporary file first, then renames on success, so a failed
/// download never deletes the existing file.
///
/// Retries up to `MAX_FETCH_RETRIES` times on transient failures (server errors,
/// connection errors, or empty responses). Permanent HTTP errors (4xx) fail immediately.
/// Retries up to `MAX_FETCH_RETRIES` times on transient failures (server errors
/// or connection errors). Permanent conditions fail immediately: client HTTP
/// errors (4xx) and empty bodies ([`AppError::EmptyResponse`] — the records API
/// reports "no data submitted yet" as a 200 with an empty body, which no amount
/// of retrying can change).
pub async fn get_file(file_url: &str, local_file_path: &str) -> Result<(), AppError> {
/// Maximum number of retries when the server returns an empty response.
/// Maximum number of retries on transient failures (5xx, connection errors).
const MAX_FETCH_RETRIES: usize = 3;
// Parse the URL once so malformed URLs are caught before any network I/O.
@ -39,15 +47,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;
}
@ -56,21 +61,9 @@ pub async fn get_file(file_url: &str, local_file_path: &str) -> Result<(), AppEr
match response.text().await {
Ok(content) => {
if content.is_empty() {
if attempt < MAX_FETCH_RETRIES {
eprintln!(
"Warning: 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;
attempt += 1;
continue;
}
return Err(AppError::Network(format!(
"Empty response from {file_url} after {MAX_FETCH_RETRIES} retries"
)));
// "Known resource, no data yet" — deterministic, so
// fail immediately instead of burning retries.
return Err(AppError::EmptyResponse { url: file_url.to_string() });
}
fs::write(&tmp_path, &content).map_err(AppError::Io)?;
return fs::rename(&tmp_path, local_file_path).map_err(AppError::Io);
@ -84,14 +77,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;
}

View file

@ -41,7 +41,7 @@ pub mod test_utils;
// Re-export all public types and functions for backward compatibility.
// External code can use `top_drives::Car` etc. without knowing the internal module layout.
pub use context::{compute_car_counts, AnalysisContext, CarCounts};
pub use context::{compute_car_counts, resolve_tied_configs, AnalysisContext, CarCounts};
pub use csv_util::{compare_cars, escape_csv_field};
pub use http::get_file;
pub use translate::{translate_track_name, translate_track_type};

File diff suppressed because it is too large Load diff

View file

@ -11,6 +11,7 @@ use std::sync::Arc;
use dashmap::DashMap;
use futures::future::join_all;
use indicatif::{ProgressBar, ProgressStyle};
use serde_json::Value;
use tokio::sync::Semaphore;
use tokio::task;
@ -89,13 +90,20 @@ 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(());
}
};
// An empty file means "no timing data" (the API reports this as an empty
// body) — same as a missing file, not a parse error.
if file_content.trim().is_empty() {
return Ok(());
}
// Parse JSON data
let json_data: Value = match serde_json::from_str(&file_content) {
Ok(v) => v,
@ -106,7 +114,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 +129,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 +317,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 +330,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.
@ -328,6 +343,15 @@ pub async fn parse_all_times(
});
}
// Missing timing files are silent (expected for most cars), so a wrong or
// empty --times-dir would otherwise produce empty CSVs without any hint.
if ctx.best_cars.is_empty() && !car_list.is_empty() {
log::warn!(
"no timing data found in {times_dir} for {} car(s) — all output CSVs will be empty",
car_list.len()
);
}
Ok(car_list)
}
@ -340,38 +364,56 @@ const CAR_TIMING_API_BASE: &str = "https://api.topdrivesrecords.com/car/";
/// Fetches timing data files for all cars using controlled concurrency.
///
/// Displays a single-line progress counter (completed/total cars) that stays
/// on the bottom line while downloads run. The counter tracks completions,
/// not cars seen, since downloads run concurrently under the semaphore.
///
/// Returns the first error encountered, or `Ok(())` if all downloads succeeded.
pub async fn fetch_times(cars_db_path: &str, times_dir: &str) -> Result<(), AppError> {
let car_list = get_car_list(cars_db_path)?;
let progress = ProgressBar::new(car_list.len() as u64);
progress.set_style(
ProgressStyle::with_template("{msg} {pos}/{len}")
.expect("static progress template is valid"),
);
progress.set_message("Downloading track times for car");
let semaphore = Arc::new(Semaphore::new(CONCURRENCY_LIMIT));
let mut handles = vec![];
for car in get_car_list(cars_db_path)? {
for car in car_list {
let permit = semaphore
.clone()
.acquire_owned()
.await
.map_err(|e| AppError::Network(format!("Semaphore closed: {e}")))?;
let times_dir = times_dir.to_string();
let progress = progress.clone();
handles.push(tokio::spawn(async move {
let _permit = permit; // held until end of block
fetch_car(&car.rid, &times_dir).await
let result = fetch_car(&car.rid, &times_dir).await;
progress.inc(1);
result
}));
}
let results = join_all(handles).await;
// Clear the counter before error logging so warnings don't garble the line.
progress.finish_and_clear();
let mut first_error: Option<AppError> = None;
for result in results {
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);
}
@ -382,12 +424,23 @@ pub async fn fetch_times(cars_db_path: &str, times_dir: &str) -> Result<(), AppE
}
/// Downloads timing data for a single car from the remote API.
///
/// An empty response means the records site knows the car but no times have
/// been submitted yet (typical for freshly released prize cars) — treated as
/// a skip, not a failure, so the overall fetch is not aborted.
async fn fetch_car(rid: &str, times_dir: &str) -> Result<(), AppError> {
let file_name = rid.to_owned() + ".json";
let url = format!("{CAR_TIMING_API_BASE}{rid}");
let local_file_path = format!("{times_dir}/{file_name}");
println!("Fetching {url}.");
top_drives::get_file(&url, &local_file_path).await
log::info!("Fetching {url}.");
match top_drives::get_file(&url, &local_file_path).await {
Ok(()) => Ok(()),
Err(AppError::EmptyResponse { .. }) => {
log::info!("no records on the site yet for {rid} — skipped");
Ok(())
}
Err(e) => Err(e),
}
}
// ---------------------------------------------------------------------------
@ -494,6 +547,44 @@ mod tests {
assert_eq!(records.len(), 1, "Same car_rid should not appear twice in tie list");
}
#[test]
fn test_update_best_car_same_car_different_config_slower_not_added() {
// A slower config of the same car is simply discarded (existing rule).
let ctx = AnalysisContext::new();
let mut record_323 = make_record("Car_A", 85, 100.0, "guid_a");
record_323.normalized_config = "323".to_string();
let mut record_332 = make_record("Car_A", 85, 100.4, "guid_a");
record_332.normalized_config = "332".to_string();
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record_323);
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record_332);
let records = ctx.best_cars.get(&("laguna".to_string(), 85, "Dry".to_string())).unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].normalized_config, "323");
}
#[test]
fn test_update_best_car_same_car_different_config_faster_replaces() {
// A strictly faster config of the same car replaces the entry.
let ctx = AnalysisContext::new();
let mut record_323 = make_record("Car_A", 85, 100.0, "guid_a");
record_323.normalized_config = "323".to_string();
let mut record_332 = make_record("Car_A", 85, 99.0, "guid_a");
record_332.normalized_config = "332".to_string();
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record_323);
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record_332);
let records = ctx.best_cars.get(&("laguna".to_string(), 85, "Dry".to_string())).unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].normalized_config, "332", "Faster config replaces the slower one");
}
// =======================================================================
// parse_car_times tests
// =======================================================================
@ -536,6 +627,55 @@ mod tests {
assert!(ctx.best_cars.contains_key(&("Monaco Streets".to_string(), 85, "Wet".to_string())));
}
#[test]
fn test_parse_car_times_skips_missing_or_non_numeric_time() {
let ctx = AnalysisContext::new();
let car = Car {
rid: "TestCar_2020".to_string(),
brand: "TestBrand".to_string(),
only_name: "TestModel".to_string(),
year: 2020,
rq: 85,
prize: false,
guid: "guid_test".to_string(),
};
let dir = tempfile::tempdir().unwrap();
let times_dir = dir.path().to_str().unwrap();
let file_path = dir.path().join("TestCar_2020.json");
// "t" missing entirely, null, and a string — all must be skipped
// without panicking or inserting a record.
let json_content = r#"{
"data": {
"111": {
"times": {
"laguna_a00": { "u": "Player1" },
"mnCity_a01": { "t": null, "u": "Player2" },
"testBowl_a02": { "t": "112.45", "u": "Player3" },
"butte_a01": { "t": 50.0, "u": "Player4" }
}
}
}
}"#;
fs::write(&file_path, json_content).unwrap();
let my_cars: HashSet<String> = HashSet::new();
parse_car_times(car, &my_cars, &ctx.best_cars, times_dir).unwrap();
// Only the valid butte entry survives.
assert!(!ctx.best_cars.contains_key(&("Laguna Seca".to_string(), 85, "Dry".to_string())));
assert!(!ctx.best_cars.contains_key(&(
"Monaco Streets".to_string(),
85,
"Wet".to_string()
)));
assert!(
ctx.best_cars.contains_key(&("Butte".to_string(), 85, "Wet".to_string())),
"valid time entry should still be parsed"
);
}
#[test]
fn test_parse_car_times_filters_downvoted() {
let ctx = AnalysisContext::new();
@ -630,6 +770,30 @@ mod tests {
assert_eq!(ctx.best_cars.len(), 0, "No entries for missing file");
}
#[test]
fn test_parse_car_times_empty_file_treated_as_missing() {
let ctx = AnalysisContext::new();
let car = Car {
rid: "EmptyCar_2020".to_string(),
brand: "TestBrand".to_string(),
only_name: "TestModel".to_string(),
year: 2020,
rq: 85,
prize: false,
guid: "guid_test".to_string(),
};
let dir = tempfile::tempdir().unwrap();
let times_dir = dir.path().to_str().unwrap();
fs::write(dir.path().join("EmptyCar_2020.json"), "").unwrap();
let my_cars: HashSet<String> = HashSet::new();
// Empty file must be Ok(()) like a missing file, not a parse error.
parse_car_times(car, &my_cars, &ctx.best_cars, times_dir).unwrap();
assert_eq!(ctx.best_cars.len(), 0, "No entries for empty file");
}
// =======================================================================
// Track name / type tests (from lib)
// =======================================================================

View file

@ -42,6 +42,8 @@ use clap::{Parser, Subcommand};
use top_drives::AppError;
use crate::commands::TrashParams;
// ---------------------------------------------------------------------------
// Constants (CLI defaults)
// ---------------------------------------------------------------------------
@ -104,8 +106,16 @@ enum Commands {
#[arg(long, default_value_t = 10)]
min_count: usize,
},
/// Generate trash.csv (owned cars not best on any track)
Trash,
/// Generate trash.csv (owned cars not best on any track, plus low-value upgrade fodder)
Trash {
#[command(flatten)]
trash: TrashParams,
},
/// Generate protect.csv and storage.csv (unlocked cars: keep vs. trash recommendation)
Protect {
#[command(flatten)]
trash: TrashParams,
},
/// Generate wishlist.csv, add.csv, and remove.csv
Wishlist {
/// Minimum number of tracks a car must be best on to qualify (default: 10)
@ -123,11 +133,20 @@ enum Commands {
/// Maximum number of cars on the wishlist (default: 300)
#[arg(long, default_value_t = 300)]
max_wishlist: usize,
#[command(flatten)]
trash: TrashParams,
},
/// Generate details.csv with all timing data
Details,
/// Show best car for a specific track/condition/RQ combination
Track { track: String, condition: String, rq: u8 },
Track {
/// Human-readable track name (e.g. "Laguna Seca", not the raw code "laguna")
track: String,
/// Human-readable surface condition (e.g. "Dry", "Wet"; not the raw code "00")
condition: String,
/// RQ level (car's RQ up to 113)
rq: u8,
},
/// Generate improve.csv (owned cars not in final config, sorted by best tracks)
Improve,
}
@ -150,6 +169,10 @@ 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.
#[cfg(feature = "bin-logging")]
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);
@ -177,8 +200,24 @@ async fn run() -> Result<(), AppError> {
)
.await?;
}
Commands::Trash => {
commands::trash::run_trash(&cli.my_cars, &cli.cars_db, &cli.times_dir, &cli.output_dir)
Commands::Trash { trash } => {
commands::trash::run_trash(
&cli.my_cars,
&cli.cars_db,
&cli.times_dir,
&cli.output_dir,
&trash,
)
.await?;
}
Commands::Protect { trash } => {
commands::protect::run_protect(
&cli.my_cars,
&cli.cars_db,
&cli.times_dir,
&cli.output_dir,
&trash,
)
.await?;
}
Commands::Wishlist { min_count, max_wishlist } => {
@ -192,7 +231,7 @@ async fn run() -> Result<(), AppError> {
)
.await?;
}
Commands::All { min_count, max_wishlist } => {
Commands::All { min_count, max_wishlist, trash } => {
commands::all::run_all(
&cli.my_cars,
&cli.cars_db,
@ -200,6 +239,7 @@ async fn run() -> Result<(), AppError> {
&cli.output_dir,
min_count,
max_wishlist,
&trash,
)
.await?;
}
@ -217,7 +257,6 @@ async fn run() -> Result<(), AppError> {
&cli.my_cars,
&cli.cars_db,
&cli.times_dir,
&cli.output_dir,
&track,
&condition,
rq,

View file

@ -29,6 +29,16 @@ pub enum AppError {
reason: String,
},
/// The server responded with 200 but an empty body.
///
/// The records API uses this to signal "known resource, no data submitted yet"
/// (typically newly released prize cars). Deterministic — retrying cannot help.
#[error("Empty response from {url} (no data available on the server)")]
EmptyResponse {
/// The URL that returned the empty body
url: String,
},
/// A required data file is missing
#[error("Missing data file: {0}")]
MissingFile(String),
@ -132,7 +142,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 +162,55 @@ 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
}
#[cfg(test)]
mod tests {
use super::is_final_build;
#[test]
fn final_builds_have_digit_sum_eight() {
assert!(is_final_build("323"));
assert!(is_final_build("332"));
assert!(is_final_build("233"));
assert!(is_final_build("521"));
assert!(is_final_build("800"));
}
#[test]
fn maxed_builds_beyond_eight_are_final() {
assert!(is_final_build("879"));
assert!(is_final_build("999"));
assert!(is_final_build("990"));
}
#[test]
fn unfinished_builds_below_eight_are_not_final() {
assert!(!is_final_build("111"));
assert!(!is_final_build("221"));
assert!(!is_final_build("610"));
assert!(!is_final_build("070"));
}
#[test]
fn malformed_configs_are_not_final() {
assert!(!is_final_build(""));
assert!(!is_final_build("12"));
assert!(!is_final_build("1234"));
assert!(!is_final_build("abc"));
assert!(!is_final_build("3a3"));
assert!(!is_final_build(" 33"));
assert!(!is_final_build("33 "));
}
}

View file

@ -30,6 +30,19 @@ pub struct MyCarsData {
/// Multiple copies of the same car model share the same guid.
/// `owned` HashSet deduplicates, so this counts duplicates.
pub copy_count: HashMap<String, usize>,
/// GUIDs of deck cards that are NOT locked (guid → number of unlocked copies).
/// Union of garage and storage cards; used for the garage-space projection.
pub unlocked: HashMap<String, usize>,
/// Unlocked cards in the garage (`state != 0`, `locked == false`; guid →
/// copy count). Candidates for protect.csv.
pub garage_unlocked: HashMap<String, usize>,
/// Cards in the storage (`state == 0`, any lock state; guid → copy count).
/// Candidates for storage.csv recommendations.
pub storage: HashMap<String, usize>,
/// Total garage slot capacity (from `user.garageSlots`), 0 if absent.
pub garage_slots: usize,
/// Occupied garage slots (from `user.garageSlotsUsed`), 0 if absent.
pub garage_slots_used: usize,
}
/// Loads and parses `my_cars.json` once, extracting both owned and wishlist data.
@ -62,13 +75,22 @@ pub fn load_my_cars_data(path: &str) -> Result<MyCarsData, AppError> {
let mut owned: HashSet<String> = HashSet::new();
let mut owned_configs: HashMap<String, Vec<String>> = HashMap::new();
let mut copy_count: HashMap<String, usize> = HashMap::new();
let mut unlocked: HashMap<String, usize> = HashMap::new();
let mut garage_unlocked: HashMap<String, usize> = HashMap::new();
let mut storage: HashMap<String, usize> = HashMap::new();
if let Some(deck) = json.get("playerDeck").and_then(|d| d.as_array()) {
for card in deck {
if card.get("locked").and_then(|v| v.as_bool()).unwrap_or(false) {
if let Some(guid) = card.get("cardId").and_then(|v| v.as_str()) {
owned.insert(guid.to_string());
*copy_count.entry(guid.to_string()).or_insert(0) += 1;
let Some(guid) = card.get("cardId").and_then(|v| v.as_str()) else {
continue;
};
let guid = guid.to_string();
let locked = card.get("locked").and_then(|v| v.as_bool()).unwrap_or(false);
// state 0 = storage ("Lager"), anything else = garage.
let in_storage = card.get("state").and_then(|v| v.as_i64()) == Some(0);
if locked {
owned.insert(guid.clone());
*copy_count.entry(guid.clone()).or_insert(0) += 1;
// Extract actual tune config: engineMajor + weightMajor + chassisMajor.
// Skip cards with incomplete tune data to avoid false "000" entries
// that would incorrectly appear as needing upgrade.
@ -77,15 +99,31 @@ pub fn load_my_cars_data(path: &str) -> Result<MyCarsData, AppError> {
let chassis = card.get("chassisMajor").and_then(|v| v.as_u64());
if let (Some(e), Some(w), Some(c)) = (engine, weight, chassis) {
let config = format!("{e}{w}{c}");
owned_configs.entry(guid.to_string()).or_default().push(config);
owned_configs.entry(guid.clone()).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");
}
} else {
*unlocked.entry(guid.clone()).or_insert(0) += 1;
if !in_storage {
*garage_unlocked.entry(guid.clone()).or_insert(0) += 1;
}
}
if in_storage {
*storage.entry(guid).or_insert(0) += 1;
}
}
}
let garage_slots =
json.get("user").and_then(|u| u.get("garageSlots")).and_then(|v| v.as_u64()).unwrap_or(0)
as usize;
let garage_slots_used = json
.get("user")
.and_then(|u| u.get("garageSlotsUsed"))
.and_then(|v| v.as_u64())
.unwrap_or(0) as usize;
// Extract wishlist GUIDs from wishlists[0].cards
let wishlist: HashSet<String> = json
.get("wishlists")
@ -95,7 +133,17 @@ pub fn load_my_cars_data(path: &str) -> Result<MyCarsData, AppError> {
.map(|cards| cards.iter().filter_map(|c| c.as_str().map(|s| s.to_string())).collect())
.unwrap_or_default();
Ok(MyCarsData { owned, wishlist, owned_configs, copy_count })
Ok(MyCarsData {
owned,
wishlist,
owned_configs,
copy_count,
unlocked,
garage_unlocked,
storage,
garage_slots,
garage_slots_used,
})
}
#[cfg(test)]
@ -111,4 +159,54 @@ mod tests {
other => panic!("Expected MissingFile error, got: {other}"),
}
}
#[test]
fn test_load_my_cars_data_parses_deck_and_garage() {
let json = r#"{
"user": { "garageSlots": 100, "garageSlotsUsed": 90 },
"playerDeck": [
{ "cardId": "g1", "state": 1, "locked": true, "engineMajor": 3, "weightMajor": 2, "chassisMajor": 3 },
{ "cardId": "g2", "state": 0, "locked": false },
{ "cardId": "g2", "state": 0, "locked": false },
{ "cardId": "g3", "state": 1, "locked": false },
{ "cardId": "g4", "state": 0, "locked": true }
],
"wishlists": [ { "cards": ["g9"] } ]
}"#;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("my_cars.json");
std::fs::write(&path, json).unwrap();
let data = load_my_cars_data(path.to_str().unwrap()).unwrap();
assert!(data.owned.contains("g1"));
assert!(data.owned.contains("g4"), "locked storage cards still count as owned");
assert_eq!(
data.owned_configs.get("g1").map(|c| c.as_slice()),
Some(&["323".to_string()][..])
);
assert!(!data.owned.contains("g2"), "unlocked cards must not count as owned");
assert_eq!(data.unlocked.get("g2"), Some(&2), "two unlocked copies");
assert_eq!(data.unlocked.get("g3"), Some(&1));
assert_eq!(data.garage_unlocked.get("g3"), Some(&1), "unlocked garage card");
assert!(!data.garage_unlocked.contains_key("g2"), "storage card is not garage");
assert_eq!(data.storage.get("g2"), Some(&2), "two storage copies");
assert_eq!(data.storage.get("g4"), Some(&1), "locked storage card");
assert!(data.wishlist.contains("g9"));
assert_eq!(data.garage_slots, 100);
assert_eq!(data.garage_slots_used, 90);
}
#[test]
fn test_load_my_cars_data_without_garage_info() {
let json = r#"{ "playerDeck": [] }"#;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("my_cars.json");
std::fs::write(&path, json).unwrap();
let data = load_my_cars_data(path.to_str().unwrap()).unwrap();
assert_eq!(data.garage_slots, 0);
assert_eq!(data.garage_slots_used, 0);
assert!(data.unlocked.is_empty());
}
}

View file

@ -31,22 +31,23 @@ async fn test_get_file_success() {
}
#[tokio::test]
async fn test_get_file_empty_response_retries_and_fails() {
async fn test_get_file_empty_response_fails_immediately_without_retry() {
let mut server = Server::new_async().await;
// Return 200 with empty body — triggers retry, then fails
let m = server
.mock("GET", "/data.json")
.with_status(200)
.with_body("")
.expect(4) // 1 initial + 3 retries
.create();
// 200 with empty body means "no data available yet" — permanent, so exactly
// one request must be made (no retries) and a distinguishable error returned.
let m = server.mock("GET", "/data.json").with_status(200).with_body("").expect(1).create();
let dir = tempfile::tempdir().unwrap();
let out_path = dir.path().join("output.txt");
let url = format!("{}/data.json", server.url());
let result = get_file(&url, out_path.to_str().unwrap()).await;
assert!(result.is_err(), "Empty response should fail after retries");
let err = result.expect_err("Empty response should fail immediately");
assert!(
matches!(err, top_drives::AppError::EmptyResponse { .. }),
"Empty response should yield AppError::EmptyResponse, got: {err}"
);
assert!(!out_path.exists(), "No file should be written for an empty response");
m.assert();
}

View file

@ -240,6 +240,7 @@ fn test_load_my_cars_data_with_fixture() {
let json = r#"{
"playerDeck": [
{ "cardId": "guid_a", "locked": true, "engineMajor": 3, "weightMajor": 2, "chassisMajor": 3 },
{ "cardId": "guid_a", "locked": true, "engineMajor": 2, "weightMajor": 3, "chassisMajor": 3 },
{ "cardId": "guid_b", "locked": false, "engineMajor": 2, "weightMajor": 3, "chassisMajor": 3 },
{ "cardId": "guid_c", "locked": true, "engineMajor": 1, "weightMajor": 1, "chassisMajor": 1 }
],
@ -251,15 +252,19 @@ fn test_load_my_cars_data_with_fixture() {
let data = load_my_cars_data(path.to_str().unwrap()).unwrap();
// Only locked cards are owned
// Only locked cards are owned (set deduplicates duplicate guids)
assert_eq!(data.owned.len(), 2);
assert!(data.owned.contains("guid_a"));
assert!(data.owned.contains("guid_c"));
assert!(!data.owned.contains("guid_b"));
// owned_configs only for locked cards with complete tune data
assert_eq!(data.owned_configs.len(), 2);
assert_eq!(data.owned_configs.get("guid_a"), Some(&vec!["323".to_string()]));
// copy_count tracks duplicates: guid_a has two copies, guid_c one
assert_eq!(data.copy_count.get("guid_a"), Some(&2));
assert_eq!(data.copy_count.get("guid_c"), Some(&1));
assert!(!data.copy_count.contains_key("guid_b"));
// owned_configs collects one entry per copy with complete tune data
assert_eq!(data.owned_configs.get("guid_a"), Some(&vec!["323".to_string(), "233".to_string()]));
assert_eq!(data.owned_configs.get("guid_c"), Some(&vec!["111".to_string()]));
// Unlocked card should not be in owned_configs
assert!(!data.owned_configs.contains_key("guid_b"));
@ -289,6 +294,11 @@ fn test_load_my_cars_data_incomplete_tune_data() {
// All locked cards are owned
assert_eq!(data.owned.len(), 3);
// copy_count counts every locked card, even without complete tune data
assert_eq!(data.copy_count.get("guid_full"), Some(&1));
assert_eq!(data.copy_count.get("guid_partial"), Some(&1));
assert_eq!(data.copy_count.get("guid_empty"), Some(&1));
// Only the card with complete tune data has a config entry
assert_eq!(data.owned_configs.len(), 1);
assert_eq!(data.owned_configs.get("guid_full"), Some(&vec!["233".to_string()]));