Refactor: extract lib.rs into focused sub-modules, add --output-dir CLI option

- Split monolithic src/lib.rs (~640 lines) into 7 sub-modules:
  types.rs, context.rs, user_data.rs, translate.rs, http.rs,
  csv_util.rs, test_utils.rs
- Add configurable --output-dir CLI option for CSV output files
- Implement atomic file writes (write-to-temp + rename) for all CSVs
- Change compute_car_counts() to return CarCounts directly (not Result)
- Add test-utils feature gate for shared test helpers
- Add HTTP mock tests with mockito (6 tests)
- Add proptest tests for compute_car_counts (3 property tests)
- Update integration tests to use shared test utilities
- Update documentation (API.md, ARCHITECTURE.md, DATA_STRUCTURES.md)
This commit is contained in:
Jan-Peter von Hunnius 2026-05-08 20:21:46 +02:00
parent d2c1418e5e
commit 67a79f190a
27 changed files with 1648 additions and 789 deletions

99
src/user_data.rs Normal file
View file

@ -0,0 +1,99 @@
//! User data loading from `my_cars.json`.
//!
//! Handles parsing the game export file to extract owned car GUIDs
//! and wishlist entries.
use std::collections::HashSet;
use std::fs::File;
use std::io::BufReader;
use serde_json::Value;
use crate::types::AppError;
/// Parsed user data from `my_cars.json`
///
/// Holds both the owned car GUIDs and wishlist GUIDs extracted from a single
/// file read, avoiding duplicate I/O.
#[derive(Debug, Default)]
pub struct MyCarsData {
/// GUIDs of cars the user owns (from `playerDeck[].cardId` where `locked == true`)
pub owned: HashSet<String>,
/// GUIDs of wishlisted cars (from `wishlists[0].cards`)
pub wishlist: HashSet<String>,
}
/// Loads and parses `my_cars.json` once, extracting both owned and wishlist data.
///
/// Opens the game export file a single time and extracts:
/// - **Owned cars** from `playerDeck[].cardId` (filtered by `locked == true`)
/// - **Wishlist** from `wishlists[0].cards`
///
/// # File Source
///
/// * **File**: `my_cars.json` (exported from Top Drives game)
///
/// # Error Handling
///
/// Returns an `AppError` if the file cannot be opened or parsed.
/// Use `MyCarsData::default()` for graceful degradation when the file is optional.
pub fn load_my_cars_data(path: &str) -> Result<MyCarsData, AppError> {
let file = File::open(path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
AppError::MissingFile(format!("{path}: {e}"))
} else {
AppError::Io(e)
}
})?;
let reader = BufReader::new(file);
let json: Value = serde_json::from_reader(reader)
.map_err(|e| AppError::Parse { file: path.to_string(), reason: e.to_string() })?;
// Extract owned car GUIDs from playerDeck
let owned: HashSet<String> = json
.get("playerDeck")
.and_then(|d| d.as_array())
.map(|deck| {
deck.iter()
.filter_map(|card| {
if card.get("locked").and_then(|v| v.as_bool()).unwrap_or(false) {
card.get("cardId").and_then(|v| v.as_str()).map(|s| s.to_string())
} else {
None
}
})
.collect()
})
.unwrap_or_default();
// Extract wishlist GUIDs from wishlists[0].cards
let wishlist: HashSet<String> = json
.get("wishlists")
.and_then(|w| w.get(0))
.and_then(|w| w.get("cards"))
.and_then(|c| c.as_array())
.map(|cards| {
cards
.iter()
.filter_map(|c| c.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
Ok(MyCarsData { owned, wishlist })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_load_my_cars_data_missing_file() {
let result = load_my_cars_data("nonexistent_file.json");
assert!(result.is_err());
match result.unwrap_err() {
AppError::MissingFile(msg) => assert!(msg.contains("nonexistent_file.json")),
other => panic!("Expected MissingFile error, got: {other}"),
}
}
}