Implement Phase 2: API client, sync pipeline, and CSV card importers

Port the full data ingestion layer from Python to Rust:
- Typed API client with 10 endpoints, serde response types, and error handling
- Team/player/transaction sync with proper upsert semantics (preserves hand field)
- Batter and pitcher CSV importers with 40+ column mappings each
- Parse helpers for float/int/endurance with 21 unit tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-02-27 22:56:50 -06:00
parent 3c0c206aba
commit 3c70ecc71a
6 changed files with 1580 additions and 0 deletions

View File

@ -0,0 +1,291 @@
{
"meta": {
"version": "1.0.0",
"created": "2026-02-27",
"lastUpdated": "2026-02-27",
"planType": "migration",
"phase": "Phase 2: API Client + Sync + CSV Import",
"description": "Port the data ingestion pipeline — HTTP API client, sync functions, and CSV card importer — from Python to Rust. This phase populates the database that Phase 1 created.",
"totalEstimatedHours": 16,
"totalTasks": 11,
"completedTasks": 0
},
"categories": {
"critical": "Must complete before sync or import can work",
"high": "Required for data pipeline to function end-to-end",
"medium": "Import functions needed to populate card data",
"low": "Orchestration and polish"
},
"tasks": [
{
"id": "CRIT-001",
"name": "Define API response types (serde structs)",
"description": "Create typed response structs for all API endpoints. The league API returns JSON with nested objects and field names that differ from DB column names (e.g. 'sname' → 'short_name', 'wara' → 'swar'). Use serde rename attributes to handle the mismatches at deserialization time.",
"category": "critical",
"priority": 1,
"completed": false,
"tested": false,
"dependencies": [],
"files": [
{
"path": "rust/src/api/client.rs",
"lines": [1],
"issue": "No response types defined"
}
],
"suggestedFix": "Create a new file `rust/src/api/types.rs` and add to `api/mod.rs`. Define structs:\n\n1. `TeamsResponse { count: u32, teams: Vec<TeamData> }`\n2. `TeamData` — flat fields plus nested `manager1: Option<Manager>`, `manager2: Option<Manager>`, `division: Option<Division>`. Use `#[serde(rename = \"sname\")]` for `short_name`, `#[serde(rename = \"lname\")]` for `long_name`, `#[serde(rename = \"gmid\")]` for `gm_discord_id`, `#[serde(rename = \"gmid2\")]` for `gm2_discord_id`. Manager struct: `{ name: Option<String> }`. Division struct: `{ id: Option<i64>, division_name: Option<String>, league_abbrev: Option<String> }`.\n3. `PlayersResponse { count: u32, players: Vec<PlayerData> }`\n4. `PlayerData` — use `#[serde(rename = \"wara\")]` for `swar`, `#[serde(rename = \"sbaplayer\")]` for `sbaplayer_id`, `#[serde(rename = \"image\")]` for `card_image`, `#[serde(rename = \"image2\")]` for `card_image_alt`. Nested `team: Option<TeamRef>` where `TeamRef { id: i64 }`.\n5. `TransactionsResponse { count: u32, transactions: Vec<TransactionData> }`\n6. `TransactionData` — `#[serde(rename = \"moveid\")]` for `move_id`. Nested `player: Option<PlayerRef>`, `oldteam: Option<TeamRef>`, `newteam: Option<TeamRef>`.\n7. `CurrentResponse { season: i64, week: i64 }` (for get_current endpoint).\n\nAll structs derive `Debug, Deserialize`. Use `Option<>` liberally — API responses have many nullable fields. Use `#[serde(default)]` on optional fields to handle missing keys gracefully.",
"estimatedHours": 1.5,
"notes": "Key gotchas: gmid/gmid2 come as integers from the API but are stored as String in DB — deserialize as Option<i64> then .map(|id| id.to_string()). The 'team' field on PlayerData is an object {id: N} not a bare integer."
},
{
"id": "CRIT-002",
"name": "Define API error type",
"description": "Create a proper error enum for API operations using thiserror. The Python client has special handling for Cloudflare 403 errors and distinguishes HTTP errors from network errors.",
"category": "critical",
"priority": 2,
"completed": false,
"tested": false,
"dependencies": [],
"files": [
{
"path": "rust/src/api/client.rs",
"lines": [1],
"issue": "Uses anyhow::Result with no typed errors"
}
],
"suggestedFix": "Add to `api/client.rs` or a new `api/error.rs`:\n\n```rust\n#[derive(Debug, thiserror::Error)]\npub enum ApiError {\n #[error(\"HTTP {status}: {message}\")]\n Http { status: u16, message: String },\n\n #[error(\"Cloudflare blocked the request (403). The API may be down or rate-limiting.\")]\n CloudflareBlocked,\n\n #[error(\"Request failed: {0}\")]\n Request(#[from] reqwest::Error),\n\n #[error(\"JSON parse error: {0}\")]\n Parse(#[from] serde_json::Error),\n}\n```\n\nIn the request method: if status == 403 and body contains 'cloudflare' (case-insensitive), return CloudflareBlocked. Otherwise return Http variant.",
"estimatedHours": 0.5,
"notes": "Keep it simple. The Python error has status_code as an optional field — in Rust we can use separate variants instead."
},
{
"id": "CRIT-003",
"name": "Implement core _request method and auth",
"description": "Add the internal request method to LeagueApiClient that handles authentication (Bearer token), base URL prefixing (/api/v3), response status checking, Cloudflare detection, and JSON deserialization. All public endpoint methods will delegate to this.",
"category": "critical",
"priority": 3,
"completed": false,
"tested": false,
"dependencies": ["CRIT-001", "CRIT-002"],
"files": [
{
"path": "rust/src/api/client.rs",
"lines": [11, 23],
"issue": "Only has new() constructor, no request logic"
}
],
"suggestedFix": "Add a private async method:\n\n```rust\nasync fn get<T: DeserializeOwned>(&self, path: &str, params: &[(& str, String)]) -> Result<T, ApiError>\n```\n\n1. Build URL: `format!(\"{}/api/v3{}\", self.base_url, path)`\n2. Create request with `self.client.get(url).query(params)`\n3. If `self.api_key` is not empty, add `.bearer_auth(&self.api_key)`\n4. Send and get response\n5. If `!response.status().is_success()`: read body text, check for cloudflare 403, else return Http error\n6. Deserialize JSON body to T\n\nNote: reqwest's .query() handles repeated params correctly when given a slice of tuples — e.g., `&[(\"team_id\", \"1\"), (\"team_id\", \"2\")]` produces `?team_id=1&team_id=2`.",
"estimatedHours": 1,
"notes": "The Python client uses httpx.AsyncClient as a context manager. In Rust, reqwest::Client is Clone + Send and can be reused freely — no context manager needed. Consider making api_key an Option<String> to properly represent 'no auth' vs 'empty string'."
},
{
"id": "HIGH-001",
"name": "Implement all API endpoint methods",
"description": "Port all 10 public API methods from the Python client. Each method builds the correct path and query params, then delegates to the core _request/get method.",
"category": "high",
"priority": 4,
"completed": false,
"tested": false,
"dependencies": ["CRIT-003"],
"files": [
{
"path": "rust/src/api/client.rs",
"lines": [11, 23],
"issue": "No endpoint methods"
}
],
"suggestedFix": "Implement these methods on LeagueApiClient:\n\n1. `get_teams(season, team_abbrev, active_only, short_output)` → GET /teams\n2. `get_team(team_id)` → GET /teams/{id}\n3. `get_team_roster(team_id, which)` → GET /teams/{id}/roster/{which}\n4. `get_players(season, team_id, pos, name, short_output)` → GET /players\n5. `get_player(player_id, short_output)` → GET /players/{id}\n6. `search_players(query, season, limit)` → GET /players/search\n7. `get_transactions(season, week_start, week_end, team_abbrev, cancelled, frozen, short_output)` → GET /transactions\n8. `get_current()` → GET /current\n9. `get_schedule(season, week, team_id)` → GET /schedules\n10. `get_standings(season)` → GET /standings\n\nFor optional params, use `Option<T>` in the method signature and only add the param to the query slice when Some. For array params (team_id, team_abbrev, pos), accept `Option<&[T]>` and emit one tuple per element.\n\nReturn the typed response structs from CRIT-001.",
"estimatedHours": 2.5,
"notes": "get_players and get_transactions have the most params. Consider a builder pattern or param struct if the signatures get unwieldy, but plain method args are fine for now — matches the Python style."
},
{
"id": "HIGH-002",
"name": "Implement sync_teams",
"description": "Port the team sync function. Fetches teams from API and upserts into the database. Must handle the JSON field name mismatches (sname→short_name, lname→long_name, gmid→gm_discord_id, nested manager/division objects).",
"category": "high",
"priority": 5,
"completed": false,
"tested": false,
"dependencies": ["HIGH-001"],
"files": [
{
"path": "rust/src/api/mod.rs",
"lines": [1],
"issue": "No sync module"
}
],
"suggestedFix": "Create `rust/src/api/sync.rs` and add `pub mod sync;` to api/mod.rs.\n\nImplement `pub async fn sync_teams(pool: &SqlitePool, season: i64, client: &LeagueApiClient) -> Result<i64>`:\n\n1. Call `client.get_teams(Some(season), None, true, false)`\n2. Iterate response.teams\n3. For each TeamData: use `INSERT OR REPLACE INTO teams (id, abbrev, short_name, long_name, season, ...) VALUES (?, ?, ?, ...)`. Map: `data.short_name` (already renamed by serde), flatten `data.manager1.map(|m| m.name)`, `data.division.map(|d| d.id)`, `data.gm_discord_id.map(|id| id.to_string())`, set `synced_at = Utc::now().naive_utc()`.\n4. Call `update_sync_status(pool, \"teams\", count, None)` from db::queries.\n5. Return count.\n\nUse a transaction (pool.begin()) to batch all inserts — faster and atomic.",
"estimatedHours": 1.5,
"notes": "The Python code does individual get-then-update/insert. The Rust version should use INSERT OR REPLACE for simplicity (matching Phase 1 upsert pattern). The season field is set on insert only in Python — in Rust, just always set it since we're using REPLACE."
},
{
"id": "HIGH-003",
"name": "Implement sync_players",
"description": "Port the player sync function. Similar to team sync but with different field mappings (wara→swar, sbaplayer→sbaplayer_id, nested team.id).",
"category": "high",
"priority": 6,
"completed": false,
"tested": false,
"dependencies": ["HIGH-001"],
"files": [
{
"path": "rust/src/api/sync.rs",
"lines": [],
"issue": "File doesn't exist yet"
}
],
"suggestedFix": "Implement `pub async fn sync_players(pool: &SqlitePool, season: i64, team_id: Option<i64>, client: &LeagueApiClient) -> Result<i64>`:\n\n1. Call `client.get_players(Some(season), team_id.map(|id| vec![id]).as_deref(), None, None, false)`\n2. Iterate response.players\n3. INSERT OR REPLACE with field mapping: `data.swar` (renamed by serde from wara), `data.team.map(|t| t.id)` for team_id, `data.sbaplayer_id` (renamed from sbaplayer), pos_1 through pos_8, etc.\n4. Important: do NOT overwrite `hand` field — it comes from CSV import only. Use `INSERT OR REPLACE` but if you need to preserve hand, use `INSERT ... ON CONFLICT(id) DO UPDATE SET name=excluded.name, ... ` and omit `hand` from the SET clause.\n5. Call `update_sync_status(pool, \"players\", count, None)`.\n6. Return count.\n\nUse transaction for batching.",
"estimatedHours": 1.5,
"notes": "Critical: player.hand must NOT be overwritten by API sync (it's CSV-only). Use ON CONFLICT DO UPDATE with explicit column list instead of INSERT OR REPLACE, which would null out hand. This is different from the team sync pattern."
},
{
"id": "HIGH-004",
"name": "Implement sync_transactions",
"description": "Port the transaction sync function. Transactions use a composite key (move_id, player_id) for dedup, not a simple PK. Must skip rows with missing moveid or player.id.",
"category": "high",
"priority": 7,
"completed": false,
"tested": false,
"dependencies": ["HIGH-001"],
"files": [
{
"path": "rust/src/api/sync.rs",
"lines": [],
"issue": "File doesn't exist yet"
}
],
"suggestedFix": "Implement `pub async fn sync_transactions(pool: &SqlitePool, season: i64, week_start: i64, week_end: Option<i64>, team_abbrev: Option<&str>, client: &LeagueApiClient) -> Result<i64>`:\n\n1. Call `client.get_transactions(season, week_start, week_end, team_abbrev, false, false, false)`\n2. Iterate response.transactions\n3. Skip if moveid is None or player is None/player.id is None\n4. Use `INSERT INTO transactions (...) VALUES (...) ON CONFLICT(move_id, player_id) DO UPDATE SET cancelled=excluded.cancelled, frozen=excluded.frozen, synced_at=excluded.synced_at` — only update the mutable fields on conflict.\n5. Map: `data.move_id` (renamed from moveid), `data.player.unwrap().id`, `data.oldteam.map(|t| t.id)`, `data.newteam.map(|t| t.id)`, set season and week.\n6. Call `update_sync_status(pool, \"transactions\", count, None)`.\n7. Return count.\n\nUse transaction for batching.",
"estimatedHours": 1,
"notes": "The transactions table has UNIQUE(move_id, player_id). ON CONFLICT on that unique constraint is the right approach. Python does a SELECT-then-INSERT/UPDATE which is racy."
},
{
"id": "HIGH-005",
"name": "Implement sync_all orchestrator",
"description": "Port sync_all which creates one API client and runs all three sync functions sequentially, returning a summary of counts.",
"category": "high",
"priority": 8,
"completed": false,
"tested": false,
"dependencies": ["HIGH-002", "HIGH-003", "HIGH-004"],
"files": [
{
"path": "rust/src/api/sync.rs",
"lines": [],
"issue": "File doesn't exist yet"
}
],
"suggestedFix": "Implement `pub async fn sync_all(pool: &SqlitePool, settings: &Settings) -> Result<SyncResult>`:\n\n1. Create `LeagueApiClient::new(&settings.api.base_url, &settings.api.api_key, settings.api.timeout)?`\n2. Call sync_teams, sync_players, sync_transactions sequentially\n3. For transactions, use week_start=1, week_end=None (sync all weeks)\n4. Return a SyncResult struct: `{ teams: i64, players: i64, transactions: i64 }`\n\nDefine `SyncResult` in sync.rs or types.rs.",
"estimatedHours": 0.5,
"notes": "Keep it simple. The Python version passes the client to each sync function — do the same. Consider logging each step's count."
},
{
"id": "MED-001",
"name": "Implement CSV helper functions (parse_float, parse_int, parse_endurance)",
"description": "Port the three CSV parsing helpers. parse_int must handle '5.0' → 5 (parse as float then truncate). parse_endurance uses regex to extract S(n), R(n), C(n) from endurance strings like 'S(5) R(4)' or 'R(1) C(6)*'.",
"category": "medium",
"priority": 9,
"completed": false,
"tested": false,
"dependencies": [],
"files": [],
"suggestedFix": "Create `rust/src/api/importer.rs` and add `pub mod importer;` to api/mod.rs.\n\nImplement:\n\n1. `fn parse_float(value: &str, default: f64) -> f64` — trim, return default if empty, parse f64 or return default.\n\n2. `fn parse_int(value: &str, default: i32) -> i32` — trim, return default if empty, parse as f64 first then truncate to i32. This handles '5.0' → 5.\n\n3. `fn parse_endurance(value: &str) -> (Option<i32>, Option<i32>, Option<i32>)` — use lazy_static or once_cell for compiled regexes:\n - `S\\((\\d+)\\*?\\)` → start\n - `R\\((\\d+)\\)` → relief\n - `C\\((\\d+)\\)` → close\n Return (start, relief, close) where any component may be None.\n\nWrite unit tests for all three, especially edge cases: empty string, whitespace, '5.0', 'S(5*) R(4)', 'C(6)', unparseable values.",
"estimatedHours": 1,
"notes": "Use `std::sync::LazyLock` (stable since Rust 1.80) for compiled regexes instead of lazy_static or once_cell. Keep these as module-level functions, not methods."
},
{
"id": "MED-002",
"name": "Implement import_batter_cards",
"description": "Port the batter CSV importer. Reads BatterCalcs.csv, maps columns to BatterCard fields, upserts into database. Also updates player.hand from CSV. Returns (imported, skipped, errors) counts.",
"category": "medium",
"priority": 10,
"completed": false,
"tested": false,
"dependencies": ["MED-001"],
"files": [
{
"path": "rust/src/api/importer.rs",
"lines": [],
"issue": "File doesn't exist yet"
}
],
"suggestedFix": "Implement `pub async fn import_batter_cards(pool: &SqlitePool, csv_path: &Path, update_existing: bool) -> Result<ImportResult>`:\n\nDefine `ImportResult { imported: i64, skipped: i64, errors: Vec<String> }`.\n\n1. Open CSV with `csv::ReaderBuilder::new().has_headers(true).from_path(csv_path)`\n2. Get headers, iterate records via `reader.records()`\n3. For each record:\n a. Parse player_id from column 'player_id' — skip if 0\n b. Look up player in DB — if not found, push error string and continue\n c. Update player.hand if column 'hand' is L/R/S: `UPDATE players SET hand = ? WHERE id = ?`\n d. If !update_existing, check if batter_card exists — if so, increment skipped and continue\n e. INSERT OR REPLACE batter card with all field mappings (see column map)\n f. Increment imported\n4. Catch per-row errors (wrap in match) → push to errors vec\n\nColumn mappings (CSV header → parse function → DB field):\n- 'SO vlhp' → parse_float → so_vlhp\n- 'BB v lhp' → parse_float → bb_vlhp\n- 'HIT v lhp' → parse_float → hit_vlhp\n- (etc. — 19 stat columns per side, plus stealing, STL, SPD, B, H, FIELDING, cArm/CA, vL, vR, Total)\n- Source set to csv filename\n\nUse a helper fn or macro to avoid repeating `get_column(headers, record, 'name')` 40+ times. Consider a HashMap<String, usize> for header→index lookup.",
"estimatedHours": 2.5,
"notes": "The catcher_arm column has two possible CSV header names: 'cArm' OR 'CA'. Check both. SPD defaults to 10 if missing. Use a transaction to batch all inserts."
},
{
"id": "MED-003",
"name": "Implement import_pitcher_cards and import_all_cards",
"description": "Port the pitcher CSV importer (similar structure to batter but with endurance parsing, different column names, and pitcher-specific fields). Also port import_all_cards orchestrator that runs both imports and defaults CSV paths.",
"category": "medium",
"priority": 11,
"completed": false,
"tested": false,
"dependencies": ["MED-002"],
"files": [
{
"path": "rust/src/api/importer.rs",
"lines": [],
"issue": "File doesn't exist yet"
}
],
"suggestedFix": "Implement `pub async fn import_pitcher_cards(pool: &SqlitePool, csv_path: &Path, update_existing: bool) -> Result<ImportResult>`:\n\nSame structure as batter import with key differences:\n1. player.hand validated as L/R only (no S for pitchers)\n2. CSV 'vlhp' columns map to DB 'vlhb' fields (vs Left-Handed Batters)\n3. Endurance parsing: try columns 'ENDURANCE' or 'cleanEndur' first with parse_endurance(), then override with individual 'SP'/'RP'/'CP' columns if present (parse_int)\n4. Additional fields: hold_rating ('HO'), fielding_range ('Range'), fielding_error ('Error'), wild_pitch ('WP'), balk ('BK'), batting_rating ('BAT-B')\n5. Watch for 'BP1b v rhp' — lowercase 'b' in CSV header\n6. 'BPHR vL' and 'BPHR vR' (not 'BPHR v lhp'/'BPHR v rhp') for pitcher ballpark HR columns\n\nImplement `pub async fn import_all_cards(pool: &SqlitePool, batter_csv: Option<&Path>, pitcher_csv: Option<&Path>, update_existing: bool) -> Result<AllImportResult>`:\n1. Default paths: docs/sheets_export/BatterCalcs.csv and PitcherCalcs.csv\n2. Run batter import, then pitcher import\n3. Return combined results\n4. Note: Python version calls rebuild_score_cache() after import — defer this to Phase 3 (calc layer) and add a TODO comment.",
"estimatedHours": 2.5,
"notes": "The pitcher importer has more gotchas than the batter importer. The endurance column fallback logic (ENDURANCE → cleanEndur → individual SP/RP/CP) must be implemented carefully. Extract shared CSV reading logic (header lookup, record iteration, error collection) into helper functions to avoid duplication with the batter importer."
}
],
"quickWins": [
{
"taskId": "CRIT-002",
"estimatedMinutes": 20,
"impact": "Clean error types make all other API code easier to write"
},
{
"taskId": "MED-001",
"estimatedMinutes": 30,
"impact": "Pure functions with no dependencies — can be implemented and tested immediately"
}
],
"productionBlockers": [
{
"taskId": "CRIT-001",
"reason": "No response types = can't deserialize any API responses"
},
{
"taskId": "CRIT-003",
"reason": "No request method = can't call any endpoints"
}
],
"weeklyRoadmap": {
"session1": {
"theme": "API Client Foundation",
"tasks": ["CRIT-001", "CRIT-002", "CRIT-003", "HIGH-001"],
"estimatedHours": 5.5,
"notes": "Get the HTTP client fully working with typed responses. Test against the live API with cargo run."
},
"session2": {
"theme": "Sync Pipeline",
"tasks": ["HIGH-002", "HIGH-003", "HIGH-004", "HIGH-005"],
"estimatedHours": 4.5,
"notes": "All three sync functions plus the orchestrator. Test by syncing real data and querying with Phase 1 query functions."
},
"session3": {
"theme": "CSV Import Pipeline",
"tasks": ["MED-001", "MED-002", "MED-003"],
"estimatedHours": 6,
"notes": "Parse helpers, batter importer, pitcher importer. Test against existing BatterCalcs.csv and PitcherCalcs.csv files."
}
},
"architecturalDecisions": {
"serde_rename_for_field_mapping": "Use #[serde(rename = \"...\")] on API response structs to handle JSON↔Rust name mismatches at deserialization time, not at the sync layer. This keeps sync functions clean.",
"player_hand_preservation": "sync_players must use ON CONFLICT DO UPDATE with explicit column list (omitting hand) instead of INSERT OR REPLACE, which would null out hand. Hand is populated only by CSV import.",
"transaction_batching": "Wrap all sync and import operations in sqlx transactions for atomicity and performance. SQLite is much faster with batched inserts inside a transaction.",
"error_collection_not_abort": "CSV import collects per-row errors into a Vec<String> and continues processing remaining rows, matching the Python behavior. Only truly fatal errors (file not found, DB connection lost) abort the entire import.",
"csv_header_lookup": "Build a HashMap<String, usize> from CSV headers for O(1) column lookup by name. This handles the variant column names (cArm vs CA, ENDURANCE vs cleanEndur) cleanly.",
"no_score_cache_rebuild_yet": "import_all_cards will NOT call rebuild_score_cache() — that belongs to Phase 3 (calc layer). Add a TODO comment at the call site."
},
"testingStrategy": {
"api_client": "Write integration tests that call the live API (behind a #[cfg(feature = \"integration\")] gate or #[ignore] attribute). Test at minimum: get_teams, get_players, get_current. Verify serde deserialization works with real API responses.",
"sync_functions": "Test with an in-memory SQLite database. Call sync, then verify rows were inserted using Phase 1 query functions. Mock the API client if feasible, otherwise use #[ignore] for live tests.",
"csv_helpers": "Full unit test coverage for parse_float, parse_int, parse_endurance with edge cases.",
"csv_import": "Create small test CSV files (3-5 rows) with known values. Import into in-memory DB, then query and assert field values match. Test both insert and update paths."
}
}

View File

@ -1,7 +1,25 @@
use anyhow::Result;
use reqwest::Client;
use serde::de::DeserializeOwned;
use std::time::Duration;
use super::types::*;
#[derive(Debug, thiserror::Error)]
pub enum ApiError {
#[error("HTTP {status}: {message}")]
Http { status: u16, message: String },
#[error("Cloudflare blocked the request (403). The API may be down or rate-limiting.")]
CloudflareBlocked,
#[error("Request failed: {0}")]
Request(#[from] reqwest::Error),
#[error("JSON parse error: {0}")]
Parse(#[from] serde_json::Error),
}
pub struct LeagueApiClient {
client: Client,
base_url: String,
@ -20,4 +38,188 @@ impl LeagueApiClient {
api_key: api_key.to_string(),
})
}
async fn get<T: DeserializeOwned>(
&self,
path: &str,
params: &[(String, String)],
) -> Result<T, ApiError> {
let url = format!("{}/api/v3{}", self.base_url, path);
let mut req = self.client.get(&url).query(params);
if !self.api_key.is_empty() {
req = req.bearer_auth(&self.api_key);
}
let response = req.send().await?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
if status.as_u16() == 403 && body.to_lowercase().contains("cloudflare") {
return Err(ApiError::CloudflareBlocked);
}
return Err(ApiError::Http { status: status.as_u16(), message: body });
}
let body = response.text().await.map_err(ApiError::Request)?;
let json = serde_json::from_str::<T>(&body)?;
Ok(json)
}
// -------------------------------------------------------------------------
// Public endpoint methods
// -------------------------------------------------------------------------
pub async fn get_teams(
&self,
season: Option<i64>,
team_abbrev: Option<&[&str]>,
active_only: bool,
short_output: bool,
) -> Result<TeamsResponse, ApiError> {
let mut params: Vec<(String, String)> = Vec::new();
if let Some(s) = season {
params.push(("season".to_string(), s.to_string()));
}
if let Some(abbrevs) = team_abbrev {
for a in abbrevs {
params.push(("team_abbrev".to_string(), a.to_string()));
}
}
if active_only {
params.push(("active_only".to_string(), "true".to_string()));
}
if short_output {
params.push(("short_output".to_string(), "true".to_string()));
}
self.get("/teams", &params).await
}
pub async fn get_team(&self, team_id: i64) -> Result<TeamData, ApiError> {
self.get(&format!("/teams/{}", team_id), &[]).await
}
pub async fn get_team_roster(
&self,
team_id: i64,
which: &str,
) -> Result<serde_json::Value, ApiError> {
self.get(&format!("/teams/{}/roster/{}", team_id, which), &[]).await
}
pub async fn get_players(
&self,
season: Option<i64>,
team_id: Option<&[i64]>,
pos: Option<&[&str]>,
name: Option<&str>,
short_output: bool,
) -> Result<PlayersResponse, ApiError> {
let mut params: Vec<(String, String)> = Vec::new();
if let Some(s) = season {
params.push(("season".to_string(), s.to_string()));
}
if let Some(ids) = team_id {
for id in ids {
params.push(("team_id".to_string(), id.to_string()));
}
}
if let Some(positions) = pos {
for p in positions {
params.push(("pos".to_string(), p.to_string()));
}
}
if let Some(n) = name {
params.push(("name".to_string(), n.to_string()));
}
if short_output {
params.push(("short_output".to_string(), "true".to_string()));
}
self.get("/players", &params).await
}
pub async fn get_player(
&self,
player_id: i64,
short_output: bool,
) -> Result<PlayerData, ApiError> {
let mut params: Vec<(String, String)> = Vec::new();
if short_output {
params.push(("short_output".to_string(), "true".to_string()));
}
self.get(&format!("/players/{}", player_id), &params).await
}
pub async fn search_players(
&self,
query: &str,
season: Option<i64>,
limit: Option<i64>,
) -> Result<PlayersResponse, ApiError> {
let mut params: Vec<(String, String)> = Vec::new();
params.push(("q".to_string(), query.to_string()));
if let Some(s) = season {
params.push(("season".to_string(), s.to_string()));
}
if let Some(l) = limit {
params.push(("limit".to_string(), l.to_string()));
}
self.get("/players/search", &params).await
}
pub async fn get_transactions(
&self,
season: i64,
week_start: i64,
week_end: Option<i64>,
team_abbrev: Option<&[&str]>,
cancelled: bool,
frozen: bool,
short_output: bool,
) -> Result<TransactionsResponse, ApiError> {
let mut params: Vec<(String, String)> = Vec::new();
params.push(("season".to_string(), season.to_string()));
params.push(("week_start".to_string(), week_start.to_string()));
if let Some(abbrevs) = team_abbrev {
for a in abbrevs {
params.push(("team_abbrev".to_string(), a.to_string()));
}
}
if let Some(we) = week_end {
params.push(("week_end".to_string(), we.to_string()));
}
if cancelled {
params.push(("cancelled".to_string(), "true".to_string()));
}
if frozen {
params.push(("frozen".to_string(), "true".to_string()));
}
if short_output {
params.push(("short_output".to_string(), "true".to_string()));
}
self.get("/transactions", &params).await
}
pub async fn get_current(&self) -> Result<CurrentResponse, ApiError> {
self.get("/current", &[]).await
}
pub async fn get_schedule(
&self,
season: i64,
week: Option<i64>,
team_id: Option<i64>,
) -> Result<serde_json::Value, ApiError> {
let mut params: Vec<(String, String)> = Vec::new();
params.push(("season".to_string(), season.to_string()));
if let Some(w) = week {
params.push(("week".to_string(), w.to_string()));
}
if let Some(t) = team_id {
params.push(("team_id".to_string(), t.to_string()));
}
self.get("/schedules", &params).await
}
pub async fn get_standings(&self, season: i64) -> Result<serde_json::Value, ApiError> {
let params = vec![("season".to_string(), season.to_string())];
self.get("/standings", &params).await
}
}

666
rust/src/api/importer.rs Normal file
View File

@ -0,0 +1,666 @@
//! CSV card data importer for Strat-o-Matic card values.
//!
//! Ports the Python `api/importer.py` helpers and import functions.
use std::collections::HashMap;
use std::path::Path;
use std::sync::LazyLock;
use anyhow::Result;
use regex::Regex;
use sqlx::SqlitePool;
// ---------------------------------------------------------------------------
// Result types
// ---------------------------------------------------------------------------
/// Outcome of a single CSV import operation (batters or pitchers).
#[derive(Debug, Default)]
pub struct ImportResult {
pub imported: usize,
pub skipped: usize,
pub errors: Vec<String>,
}
/// Combined result from importing both batter and pitcher card CSVs.
#[derive(Debug, Default)]
pub struct AllImportResult {
pub batters: ImportResult,
pub pitchers: ImportResult,
}
// ---------------------------------------------------------------------------
// Compiled regexes (stable LazyLock since Rust 1.80)
// ---------------------------------------------------------------------------
static RE_START: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"S\((\d+)\*?\)").expect("valid regex"));
static RE_RELIEF: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"R\((\d+)\)").expect("valid regex"));
static RE_CLOSE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"C\((\d+)\)").expect("valid regex"));
// ---------------------------------------------------------------------------
// Parse helpers
// ---------------------------------------------------------------------------
/// Safely parse a float from a string, returning `default` on failure or empty input.
pub fn parse_float(value: &str, default: f64) -> f64 {
let trimmed = value.trim();
if trimmed.is_empty() {
return default;
}
trimmed.parse::<f64>().unwrap_or(default)
}
/// Safely parse an integer from a string, returning `default` on failure or empty input.
///
/// Parses as `f64` first so that values like `"5.0"` are handled correctly.
pub fn parse_int(value: &str, default: i32) -> i32 {
let trimmed = value.trim();
if trimmed.is_empty() {
return default;
}
match trimmed.parse::<f64>() {
Ok(f) => f as i32,
Err(_) => default,
}
}
/// Parse an endurance string like `"S(5) R(4)"` or `"R(1) C(6)"`.
///
/// Returns `(start, relief, close)` — any component may be `None` if absent.
/// Handles starred starters like `"S(5*)"`.
pub fn parse_endurance(value: &str) -> (Option<i32>, Option<i32>, Option<i32>) {
if value.trim().is_empty() {
return (None, None, None);
}
let start = RE_START
.captures(value)
.and_then(|c| c.get(1))
.and_then(|m| m.as_str().parse::<i32>().ok());
let relief = RE_RELIEF
.captures(value)
.and_then(|c| c.get(1))
.and_then(|m| m.as_str().parse::<i32>().ok());
let close = RE_CLOSE
.captures(value)
.and_then(|c| c.get(1))
.and_then(|m| m.as_str().parse::<i32>().ok());
(start, relief, close)
}
// ---------------------------------------------------------------------------
// Import functions
// ---------------------------------------------------------------------------
/// Import batter card data from a CSV file into the database.
///
/// For each row the function:
/// 1. Parses `player_id` — skips rows where it is 0 or missing.
/// 2. Verifies the player exists; records an error and continues if not.
/// 3. Updates `players.hand` when the CSV supplies "L", "R", or "S".
/// 4. Skips the row (incrementing `skipped`) when `update_existing` is false
/// and a `batter_cards` row already exists for this player.
/// 5. Inserts or replaces the batter card with all mapped fields.
///
/// All DB writes happen inside a single transaction.
pub async fn import_batter_cards(
pool: &SqlitePool,
csv_path: &Path,
update_existing: bool,
) -> Result<ImportResult> {
let mut result = ImportResult::default();
let source = csv_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
let mut rdr = csv::ReaderBuilder::new().has_headers(true).from_path(csv_path)?;
// Build O(1) header → column-index lookup before iterating records.
let headers = rdr.headers()?.clone();
let header_index: HashMap<String, usize> =
headers.iter().enumerate().map(|(i, h)| (h.to_string(), i)).collect();
let mut tx = pool.begin().await?;
for record_result in rdr.records() {
let record = match record_result {
Ok(r) => r,
Err(e) => {
result.errors.push(format!("CSV parse error: {e}"));
continue;
}
};
// Returns the field value for `name`, or "" if the column is absent.
let get = |name: &str| -> &str {
header_index.get(name).and_then(|&i| record.get(i)).unwrap_or("")
};
let player_id = parse_int(get("player_id"), 0);
if player_id == 0 {
continue;
}
let player_id = player_id as i64;
// Lightweight existence check — no need to SELECT the full row.
let player_exists: Option<i64> =
sqlx::query_scalar("SELECT id FROM players WHERE id = ?")
.bind(player_id)
.fetch_optional(&mut *tx)
.await?;
if player_exists.is_none() {
result.errors.push(format!(
"Player {} ({}) not found in database",
player_id,
get("Name")
));
continue;
}
// Update batting hand when the CSV provides a valid value.
let hand = get("hand").trim();
if matches!(hand, "L" | "R" | "S") {
sqlx::query("UPDATE players SET hand = ? WHERE id = ?")
.bind(hand)
.bind(player_id)
.execute(&mut *tx)
.await?;
}
// Skip if card already exists and caller asked not to overwrite.
if !update_existing {
let existing: Option<i64> =
sqlx::query_scalar("SELECT id FROM batter_cards WHERE player_id = ?")
.bind(player_id)
.fetch_optional(&mut *tx)
.await?;
if existing.is_some() {
result.skipped += 1;
continue;
}
}
// cArm and CA are alternate column names for catcher arm rating.
let catcher_arm_str = {
let v = get("cArm");
if v.trim().is_empty() { get("CA") } else { v }
};
let catcher_arm: Option<i64> = if catcher_arm_str.trim().is_empty() {
None
} else {
Some(parse_int(catcher_arm_str, 0) as i64)
};
// Helpers for optional DB fields.
let opt_str = |v: &str| -> Option<String> {
let t = v.trim();
if t.is_empty() { None } else { Some(t.to_string()) }
};
let opt_float = |v: &str| -> Option<f64> {
if v.trim().is_empty() { None } else { Some(parse_float(v, 0.0)) }
};
sqlx::query(
"INSERT OR REPLACE INTO batter_cards (
player_id,
so_vlhp, bb_vlhp, hit_vlhp, ob_vlhp, tb_vlhp, hr_vlhp, dp_vlhp,
bphr_vlhp, bp1b_vlhp,
so_vrhp, bb_vrhp, hit_vrhp, ob_vrhp, tb_vrhp, hr_vrhp, dp_vrhp,
bphr_vrhp, bp1b_vrhp,
stealing, steal_rating, speed,
bunt, hit_run, fielding,
catcher_arm,
rating_vl, rating_vr, rating_overall,
imported_at, source
) VALUES (
?,
?, ?, ?, ?, ?, ?, ?,
?, ?,
?, ?, ?, ?, ?, ?, ?,
?, ?,
?, ?, ?,
?, ?, ?,
?,
?, ?, ?,
CURRENT_TIMESTAMP, ?
)",
)
.bind(player_id)
// vs LHP
.bind(parse_float(get("SO vlhp"), 0.0))
.bind(parse_float(get("BB v lhp"), 0.0))
.bind(parse_float(get("HIT v lhp"), 0.0))
.bind(parse_float(get("OB v lhp"), 0.0))
.bind(parse_float(get("TB v lhp"), 0.0))
.bind(parse_float(get("HR v lhp"), 0.0))
.bind(parse_float(get("DP v lhp"), 0.0))
.bind(parse_float(get("BPHR v lhp"), 0.0))
.bind(parse_float(get("BP1B v lhp"), 0.0))
// vs RHP
.bind(parse_float(get("SO v rhp"), 0.0))
.bind(parse_float(get("BB v rhp"), 0.0))
.bind(parse_float(get("HIT v rhp"), 0.0))
.bind(parse_float(get("OB v rhp"), 0.0))
.bind(parse_float(get("TB v rhp"), 0.0))
.bind(parse_float(get("HR v rhp"), 0.0))
.bind(parse_float(get("DP v rhp"), 0.0))
.bind(parse_float(get("BPHR v rhp"), 0.0))
.bind(parse_float(get("BP1B v rhp"), 0.0))
// Running game
.bind(opt_str(get("STEALING")))
.bind(opt_str(get("STL")))
.bind(parse_int(get("SPD"), 10) as i64)
// Batting extras
.bind(opt_str(get("B")))
.bind(opt_str(get("H")))
// Fielding
.bind(opt_str(get("FIELDING")))
// Catcher
.bind(catcher_arm)
// Pre-calculated ratings from the spreadsheet
.bind(opt_float(get("vL")))
.bind(opt_float(get("vR")))
.bind(opt_float(get("Total")))
// Metadata
.bind(&source)
.execute(&mut *tx)
.await?;
result.imported += 1;
}
tx.commit().await?;
Ok(result)
}
/// Import pitcher card data from a CSV file into the database.
///
/// For each row the function:
/// 1. Parses `player_id` — skips rows where it is 0 or missing.
/// 2. Verifies the player exists; records an error and continues if not.
/// 3. Updates `players.hand` when the CSV supplies "L" or "R" (no switch for pitchers).
/// 4. Skips the row (incrementing `skipped`) when `update_existing` is false
/// and a `pitcher_cards` row already exists for this player.
/// 5. Parses endurance from the "ENDURANCE"/"cleanEndur" column first, then
/// overrides individual components if "SP"/"RP"/"CP" columns are present.
/// 6. Inserts or replaces the pitcher card with all mapped fields.
///
/// Note: The CSV uses "vlhp" in column names (vs Left-Handed Pitchers), but the
/// pitcher card DB fields use "vlhb" (vs Left-Handed Batters). This naming
/// inconsistency is an artifact of the source spreadsheet.
///
/// All DB writes happen inside a single transaction.
pub async fn import_pitcher_cards(
pool: &SqlitePool,
csv_path: &Path,
update_existing: bool,
) -> Result<ImportResult> {
let mut result = ImportResult::default();
let source = csv_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
let mut rdr = csv::ReaderBuilder::new().has_headers(true).from_path(csv_path)?;
let headers = rdr.headers()?.clone();
let header_index: HashMap<String, usize> =
headers.iter().enumerate().map(|(i, h)| (h.to_string(), i)).collect();
let mut tx = pool.begin().await?;
for record_result in rdr.records() {
let record = match record_result {
Ok(r) => r,
Err(e) => {
result.errors.push(format!("CSV parse error: {e}"));
continue;
}
};
let get = |name: &str| -> &str {
header_index.get(name).and_then(|&i| record.get(i)).unwrap_or("")
};
let player_id = parse_int(get("player_id"), 0);
if player_id == 0 {
continue;
}
let player_id = player_id as i64;
let player_exists: Option<i64> =
sqlx::query_scalar("SELECT id FROM players WHERE id = ?")
.bind(player_id)
.fetch_optional(&mut *tx)
.await?;
if player_exists.is_none() {
result.errors.push(format!(
"Player {} ({}) not found in database",
player_id,
get("Name")
));
continue;
}
// Pitchers only throw L or R — no switch pitchers.
let hand = get("hand").trim();
if matches!(hand, "L" | "R") {
sqlx::query("UPDATE players SET hand = ? WHERE id = ?")
.bind(hand)
.bind(player_id)
.execute(&mut *tx)
.await?;
}
if !update_existing {
let existing: Option<i64> =
sqlx::query_scalar("SELECT id FROM pitcher_cards WHERE player_id = ?")
.bind(player_id)
.fetch_optional(&mut *tx)
.await?;
if existing.is_some() {
result.skipped += 1;
continue;
}
}
// Parse endurance from the combined column first, then override with
// individual SP/RP/CP columns if they are non-empty.
let endur_raw = {
let v = get("ENDURANCE");
if v.trim().is_empty() { get("cleanEndur") } else { v }
};
let (mut endur_start, mut endur_relief, mut endur_close) = parse_endurance(endur_raw);
let sp_raw = get("SP").trim();
if !sp_raw.is_empty() {
endur_start = Some(parse_int(sp_raw, 0));
}
let rp_raw = get("RP").trim();
if !rp_raw.is_empty() {
endur_relief = Some(parse_int(rp_raw, 0));
}
let cp_raw = get("CP").trim();
if !cp_raw.is_empty() {
endur_close = Some(parse_int(cp_raw, 0));
}
let opt_str = |v: &str| -> Option<String> {
let t = v.trim();
if t.is_empty() { None } else { Some(t.to_string()) }
};
let opt_float = |v: &str| -> Option<f64> {
if v.trim().is_empty() { None } else { Some(parse_float(v, 0.0)) }
};
let opt_int = |v: &str| -> Option<i64> {
if v.trim().is_empty() { None } else { Some(parse_int(v, 0) as i64) }
};
sqlx::query(
"INSERT OR REPLACE INTO pitcher_cards (
player_id,
so_vlhb, bb_vlhb, hit_vlhb, ob_vlhb, tb_vlhb, hr_vlhb, dp_vlhb,
bphr_vlhb, bp1b_vlhb,
so_vrhb, bb_vrhb, hit_vrhb, ob_vrhb, tb_vrhb, hr_vrhb, dp_vrhb,
bphr_vrhb, bp1b_vrhb,
hold_rating,
endurance_start, endurance_relief, endurance_close,
fielding_range, fielding_error,
wild_pitch, balk,
batting_rating,
rating_vlhb, rating_vrhb, rating_overall,
imported_at, source
) VALUES (
?,
?, ?, ?, ?, ?, ?, ?,
?, ?,
?, ?, ?, ?, ?, ?, ?,
?, ?,
?,
?, ?, ?,
?, ?,
?, ?,
?,
?, ?, ?,
CURRENT_TIMESTAMP, ?
)",
)
.bind(player_id)
// vs Left-Handed Batters (CSV uses "vlhp" suffix, DB uses "vlhb")
.bind(parse_float(get("SO vlhp"), 0.0))
.bind(parse_float(get("BB vlhp"), 0.0))
.bind(parse_float(get("HIT v lhp"), 0.0))
.bind(parse_float(get("OB v lhp"), 0.0))
.bind(parse_float(get("TB v lhp"), 0.0))
.bind(parse_float(get("HR v lhp"), 0.0))
.bind(parse_float(get("DP vlhp"), 0.0)) // no space after DP
.bind(parse_float(get("BPHR vL"), 0.0)) // different format from batter
.bind(parse_float(get("BP1B v lhp"), 0.0))
// vs Right-Handed Batters
.bind(parse_float(get("SO v rhp"), 0.0))
.bind(parse_float(get("BB v rhp"), 0.0))
.bind(parse_float(get("HIT v rhp"), 0.0))
.bind(parse_float(get("OB v rhp"), 0.0))
.bind(parse_float(get("TB v rhp"), 0.0))
.bind(parse_float(get("HR v rhp"), 0.0))
.bind(parse_float(get("DP v rhp"), 0.0))
.bind(parse_float(get("BPHR vR"), 0.0)) // different format from batter
.bind(parse_float(get("BP1b v rhp"), 0.0)) // lowercase 'b'
// Pitcher attributes
.bind(parse_int(get("HO"), 0) as i64)
.bind(endur_start.map(|v| v as i64))
.bind(endur_relief.map(|v| v as i64))
.bind(endur_close.map(|v| v as i64))
.bind(opt_int(get("Range")))
.bind(opt_int(get("Error")))
.bind(parse_int(get("WP"), 0) as i64)
.bind(parse_int(get("BK"), 0) as i64)
.bind(opt_str(get("BAT-B")))
// Pre-calculated ratings from the spreadsheet
.bind(opt_float(get("vL")))
.bind(opt_float(get("vR")))
.bind(opt_float(get("Total")))
// Metadata
.bind(&source)
.execute(&mut *tx)
.await?;
result.imported += 1;
}
tx.commit().await?;
Ok(result)
}
/// Import both batter and pitcher card CSVs in sequence.
///
/// Default paths are `docs/sheets_export/BatterCalcs.csv` and
/// `docs/sheets_export/PitcherCalcs.csv`. Pass `None` to use the defaults.
pub async fn import_all_cards(
pool: &SqlitePool,
batter_csv: Option<&Path>,
pitcher_csv: Option<&Path>,
update_existing: bool,
) -> Result<AllImportResult> {
let default_batter = Path::new("docs/sheets_export/BatterCalcs.csv");
let default_pitcher = Path::new("docs/sheets_export/PitcherCalcs.csv");
let batter_path = batter_csv.unwrap_or(default_batter);
let pitcher_path = pitcher_csv.unwrap_or(default_pitcher);
// Import each CSV independently — if one is missing, still attempt the other.
let batters = match import_batter_cards(pool, batter_path, update_existing).await {
Ok(result) => result,
Err(e) => {
eprintln!("Batter CSV import failed ({}): {}", batter_path.display(), e);
ImportResult { imported: 0, skipped: 0, errors: vec![format!("Batter import failed: {e}")] }
}
};
let pitchers = match import_pitcher_cards(pool, pitcher_path, update_existing).await {
Ok(result) => result,
Err(e) => {
eprintln!("Pitcher CSV import failed ({}): {}", pitcher_path.display(), e);
ImportResult { imported: 0, skipped: 0, errors: vec![format!("Pitcher import failed: {e}")] }
}
};
// TODO: Phase 3 — call rebuild_score_cache() after import
Ok(AllImportResult { batters, pitchers })
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
// --- parse_float ---
/// Normal float string should parse correctly.
#[test]
fn parse_float_normal() {
assert_eq!(parse_float("3.14", 0.0), 3.14);
}
/// Integer-looking string should parse as float.
#[test]
fn parse_float_integer_string() {
assert_eq!(parse_float("5", 0.0), 5.0);
}
/// Empty string returns the default.
#[test]
fn parse_float_empty() {
assert_eq!(parse_float("", 99.0), 99.0);
}
/// Whitespace-only string returns the default.
#[test]
fn parse_float_whitespace() {
assert_eq!(parse_float(" ", -1.0), -1.0);
}
/// Non-numeric string returns the default.
#[test]
fn parse_float_unparseable() {
assert_eq!(parse_float("N/A", 0.0), 0.0);
}
// --- parse_int ---
/// Normal integer string should parse correctly.
#[test]
fn parse_int_normal() {
assert_eq!(parse_int("7", 0), 7);
}
/// Float-formatted integer like "5.0" should truncate to 5.
#[test]
fn parse_int_float_string() {
assert_eq!(parse_int("5.0", 0), 5);
}
/// Float with fractional part truncates toward zero.
#[test]
fn parse_int_float_fractional() {
assert_eq!(parse_int("3.9", 0), 3);
}
/// Empty string returns the default.
#[test]
fn parse_int_empty() {
assert_eq!(parse_int("", 42), 42);
}
/// Whitespace-only string returns the default.
#[test]
fn parse_int_whitespace() {
assert_eq!(parse_int(" ", -1), -1);
}
/// Non-numeric string returns the default.
#[test]
fn parse_int_unparseable() {
assert_eq!(parse_int("abc", 0), 0);
}
// --- parse_endurance ---
/// Start-only endurance string.
#[test]
fn parse_endurance_start_only() {
assert_eq!(parse_endurance("S(5)"), (Some(5), None, None));
}
/// Relief-only endurance string.
#[test]
fn parse_endurance_relief_only() {
assert_eq!(parse_endurance("R(4)"), (None, Some(4), None));
}
/// Close-only endurance string.
#[test]
fn parse_endurance_close_only() {
assert_eq!(parse_endurance("C(6)"), (None, None, Some(6)));
}
/// Start + Relief combination.
#[test]
fn parse_endurance_start_relief() {
assert_eq!(parse_endurance("S(5) R(4)"), (Some(5), Some(4), None));
}
/// Relief + Close combination.
#[test]
fn parse_endurance_relief_close() {
assert_eq!(parse_endurance("R(1) C(6)"), (None, Some(1), Some(6)));
}
/// All three components present.
#[test]
fn parse_endurance_all() {
assert_eq!(parse_endurance("S(5) R(3) C(6)"), (Some(5), Some(3), Some(6)));
}
/// Starred starter notation "S(5*)" should match start = 5.
#[test]
fn parse_endurance_starred_starter() {
assert_eq!(parse_endurance("S(5*)"), (Some(5), None, None));
}
/// Starred starter with relief component.
#[test]
fn parse_endurance_starred_with_relief() {
assert_eq!(parse_endurance("S(5*) R(3) C(6)"), (Some(5), Some(3), Some(6)));
}
/// Empty string returns all None.
#[test]
fn parse_endurance_empty() {
assert_eq!(parse_endurance(""), (None, None, None));
}
/// Whitespace-only returns all None.
#[test]
fn parse_endurance_whitespace() {
assert_eq!(parse_endurance(" "), (None, None, None));
}
}

View File

@ -1 +1,4 @@
pub mod client;
pub mod importer;
pub mod sync;
pub mod types;

243
rust/src/api/sync.rs Normal file
View File

@ -0,0 +1,243 @@
use anyhow::Result;
use sqlx::SqlitePool;
use super::client::LeagueApiClient;
#[derive(Debug)]
pub struct SyncResult {
pub teams: i64,
pub players: i64,
pub transactions: i64,
}
/// Sync all teams from the league API for the given season.
/// Uses INSERT OR REPLACE to handle both inserts and updates atomically.
pub async fn sync_teams(pool: &SqlitePool, season: i64, client: &LeagueApiClient) -> Result<i64> {
let response = client.get_teams(Some(season), None, true, false).await?;
let mut tx = pool.begin().await?;
let mut count: i64 = 0;
let synced_at = chrono::Utc::now().naive_utc();
for data in response.teams {
let manager1_name = data.manager1.and_then(|m| m.name);
let manager2_name = data.manager2.and_then(|m| m.name);
let gm_discord_id = data.gm_discord_id.map(|id| id.to_string());
let gm2_discord_id = data.gm2_discord_id.map(|id| id.to_string());
let division_id = data.division.as_ref().map(|d| d.id).flatten();
let division_name = data.division.as_ref().and_then(|d| d.division_name.clone());
let league_abbrev = data.division.as_ref().and_then(|d| d.league_abbrev.clone());
sqlx::query(
"INSERT OR REPLACE INTO teams \
(id, abbrev, short_name, long_name, season, manager1_name, manager2_name, \
gm_discord_id, gm2_discord_id, division_id, division_name, league_abbrev, \
thumbnail, color, dice_color, stadium, salary_cap, synced_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(data.id)
.bind(data.abbrev.unwrap_or_default())
.bind(data.short_name.unwrap_or_default())
.bind(data.long_name.unwrap_or_default())
.bind(season)
.bind(manager1_name)
.bind(manager2_name)
.bind(gm_discord_id)
.bind(gm2_discord_id)
.bind(division_id)
.bind(division_name)
.bind(league_abbrev)
.bind(data.thumbnail)
.bind(data.color)
.bind(data.dice_color)
.bind(data.stadium)
.bind(data.salary_cap)
.bind(synced_at)
.execute(&mut *tx)
.await?;
count += 1;
}
tx.commit().await?;
crate::db::queries::update_sync_status(pool, "teams", count, None).await?;
Ok(count)
}
/// Sync players from the league API.
/// Uses ON CONFLICT DO UPDATE with an explicit column list that intentionally omits `hand`,
/// since `hand` is populated only from CSV card imports and must not be overwritten by the API.
pub async fn sync_players(
pool: &SqlitePool,
season: i64,
team_id: Option<i64>,
client: &LeagueApiClient,
) -> Result<i64> {
let team_ids = team_id.map(|id| vec![id]);
let response = client
.get_players(Some(season), team_ids.as_deref(), None, None, false)
.await?;
let mut tx = pool.begin().await?;
let mut count: i64 = 0;
let synced_at = chrono::Utc::now().naive_utc();
for data in response.players {
let player_team_id = data.team.map(|t| t.id);
sqlx::query(
"INSERT INTO players \
(id, name, season, team_id, swar, card_image, card_image_alt, headshot, vanity_card, \
pos_1, pos_2, pos_3, pos_4, pos_5, pos_6, pos_7, pos_8, \
injury_rating, il_return, demotion_week, strat_code, bbref_id, sbaplayer_id, \
last_game, last_game2, synced_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \
ON CONFLICT(id) DO UPDATE SET \
name=excluded.name, \
season=excluded.season, \
team_id=excluded.team_id, \
swar=excluded.swar, \
card_image=excluded.card_image, \
card_image_alt=excluded.card_image_alt, \
headshot=excluded.headshot, \
vanity_card=excluded.vanity_card, \
pos_1=excluded.pos_1, \
pos_2=excluded.pos_2, \
pos_3=excluded.pos_3, \
pos_4=excluded.pos_4, \
pos_5=excluded.pos_5, \
pos_6=excluded.pos_6, \
pos_7=excluded.pos_7, \
pos_8=excluded.pos_8, \
injury_rating=excluded.injury_rating, \
il_return=excluded.il_return, \
demotion_week=excluded.demotion_week, \
strat_code=excluded.strat_code, \
bbref_id=excluded.bbref_id, \
sbaplayer_id=excluded.sbaplayer_id, \
last_game=excluded.last_game, \
last_game2=excluded.last_game2, \
synced_at=excluded.synced_at",
)
.bind(data.id)
.bind(data.name.unwrap_or_default())
.bind(season)
.bind(player_team_id)
.bind(data.swar)
.bind(data.card_image)
.bind(data.card_image_alt)
.bind(data.headshot)
.bind(data.vanity_card)
.bind(data.pos_1)
.bind(data.pos_2)
.bind(data.pos_3)
.bind(data.pos_4)
.bind(data.pos_5)
.bind(data.pos_6)
.bind(data.pos_7)
.bind(data.pos_8)
.bind(data.injury_rating)
.bind(data.il_return)
.bind(data.demotion_week)
.bind(data.strat_code)
.bind(data.bbref_id)
.bind(data.sbaplayer_id)
.bind(data.last_game)
.bind(data.last_game2)
.bind(synced_at)
.execute(&mut *tx)
.await?;
count += 1;
}
tx.commit().await?;
crate::db::queries::update_sync_status(pool, "players", count, None).await?;
Ok(count)
}
/// Sync transactions from the league API for the given season/week range.
/// Skips rows where move_id or player is missing.
/// ON CONFLICT only updates mutable fields (cancelled, frozen, synced_at) to preserve original
/// season/week/team data.
pub async fn sync_transactions(
pool: &SqlitePool,
season: i64,
week_start: i64,
week_end: Option<i64>,
team_abbrev: Option<&str>,
client: &LeagueApiClient,
) -> Result<i64> {
let abbrev_vec: Vec<&str> = team_abbrev.into_iter().collect();
let abbrev_slice: Option<&[&str]> =
if abbrev_vec.is_empty() { None } else { Some(&abbrev_vec) };
let response = client
.get_transactions(season, week_start, week_end, abbrev_slice, false, false, false)
.await?;
let mut tx = pool.begin().await?;
let mut count: i64 = 0;
let synced_at = chrono::Utc::now().naive_utc();
for data in response.transactions {
let (Some(move_id), Some(player)) = (data.move_id, data.player) else {
continue;
};
let move_id_str = move_id.to_string();
let player_id = player.id;
// Schema has from_team_id/to_team_id as NOT NULL; use 0 as sentinel for missing teams
let from_team_id = data.oldteam.map(|t| t.id).unwrap_or(0);
let to_team_id = data.newteam.map(|t| t.id).unwrap_or(0);
let week = data.week.unwrap_or(0);
let cancelled = data.cancelled.unwrap_or(false);
let frozen = data.frozen.unwrap_or(false);
sqlx::query(
"INSERT INTO transactions \
(season, week, move_id, player_id, from_team_id, to_team_id, cancelled, frozen, \
synced_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) \
ON CONFLICT(move_id, player_id) DO UPDATE SET \
cancelled=excluded.cancelled, \
frozen=excluded.frozen, \
synced_at=excluded.synced_at",
)
.bind(season)
.bind(week)
.bind(move_id_str)
.bind(player_id)
.bind(from_team_id)
.bind(to_team_id)
.bind(cancelled)
.bind(frozen)
.bind(synced_at)
.execute(&mut *tx)
.await?;
count += 1;
}
tx.commit().await?;
crate::db::queries::update_sync_status(pool, "transactions", count, None).await?;
Ok(count)
}
/// Orchestrate a full sync: teams, players, and transactions for the configured season.
pub async fn sync_all(pool: &SqlitePool, settings: &crate::config::Settings) -> Result<SyncResult> {
let client = LeagueApiClient::new(
&settings.api.base_url,
&settings.api.api_key,
settings.api.timeout,
)?;
let season = settings.team.season as i64;
let teams = sync_teams(pool, season, &client).await?;
let players = sync_players(pool, season, None, &client).await?;
// Start from week 1 (Python defaults to 0 which includes pre-season moves)
let transactions = sync_transactions(pool, season, 1, None, None, &client).await?;
Ok(SyncResult { teams, players, transactions })
}

175
rust/src/api/types.rs Normal file
View File

@ -0,0 +1,175 @@
use serde::Deserialize;
// =============================================================================
// Shared nested types
// =============================================================================
/// Minimal team reference used inside player/transaction responses.
#[derive(Debug, Deserialize)]
pub struct TeamRef {
pub id: i64,
}
/// Minimal player reference used inside transaction responses.
#[derive(Debug, Deserialize)]
pub struct PlayerRef {
pub id: i64,
}
/// Manager sub-object nested inside TeamData.
#[derive(Debug, Deserialize)]
pub struct Manager {
pub name: Option<String>,
}
/// Division sub-object nested inside TeamData.
#[derive(Debug, Deserialize)]
pub struct Division {
pub id: Option<i64>,
pub division_name: Option<String>,
pub league_abbrev: Option<String>,
}
// =============================================================================
// Teams
// =============================================================================
#[derive(Debug, Deserialize)]
pub struct TeamsResponse {
pub count: u32,
pub teams: Vec<TeamData>,
}
#[derive(Debug, Deserialize)]
pub struct TeamData {
pub id: i64,
#[serde(default)]
pub abbrev: Option<String>,
#[serde(rename = "sname", default)]
pub short_name: Option<String>,
#[serde(rename = "lname", default)]
pub long_name: Option<String>,
#[serde(default)]
pub thumbnail: Option<String>,
#[serde(default)]
pub color: Option<String>,
#[serde(default)]
pub dice_color: Option<String>,
#[serde(default)]
pub stadium: Option<String>,
#[serde(default)]
pub salary_cap: Option<f64>,
/// Discord user ID of the primary GM (API sends integer, DB stores as String).
#[serde(rename = "gmid", default)]
pub gm_discord_id: Option<i64>,
/// Discord user ID of the secondary GM (API sends integer, DB stores as String).
#[serde(rename = "gmid2", default)]
pub gm2_discord_id: Option<i64>,
#[serde(default)]
pub manager1: Option<Manager>,
#[serde(default)]
pub manager2: Option<Manager>,
#[serde(default)]
pub division: Option<Division>,
}
// =============================================================================
// Players
// =============================================================================
#[derive(Debug, Deserialize)]
pub struct PlayersResponse {
pub count: u32,
pub players: Vec<PlayerData>,
}
#[derive(Debug, Deserialize)]
pub struct PlayerData {
pub id: i64,
pub name: Option<String>,
pub headshot: Option<String>,
pub vanity_card: Option<String>,
/// Strat-O-Matic WAR equivalent — API field is "wara".
#[serde(rename = "wara", default)]
pub swar: Option<f64>,
/// SBA player ID — API field is "sbaplayer".
#[serde(rename = "sbaplayer", default)]
pub sbaplayer_id: Option<i64>,
/// Primary card image URL — API field is "image".
#[serde(rename = "image", default)]
pub card_image: Option<String>,
/// Alternate card image URL — API field is "image2".
#[serde(rename = "image2", default)]
pub card_image_alt: Option<String>,
#[serde(default)]
pub team: Option<TeamRef>,
#[serde(default)]
pub pos_1: Option<String>,
#[serde(default)]
pub pos_2: Option<String>,
#[serde(default)]
pub pos_3: Option<String>,
#[serde(default)]
pub pos_4: Option<String>,
#[serde(default)]
pub pos_5: Option<String>,
#[serde(default)]
pub pos_6: Option<String>,
#[serde(default)]
pub pos_7: Option<String>,
#[serde(default)]
pub pos_8: Option<String>,
#[serde(default)]
pub injury_rating: Option<String>,
#[serde(default)]
pub il_return: Option<String>,
#[serde(default)]
pub demotion_week: Option<i64>,
#[serde(default)]
pub strat_code: Option<String>,
#[serde(default)]
pub bbref_id: Option<String>,
#[serde(default)]
pub last_game: Option<String>,
#[serde(default)]
pub last_game2: Option<String>,
}
// =============================================================================
// Transactions
// =============================================================================
#[derive(Debug, Deserialize)]
pub struct TransactionsResponse {
pub count: u32,
pub transactions: Vec<TransactionData>,
}
#[derive(Debug, Deserialize)]
pub struct TransactionData {
/// Transaction move ID — API field is "moveid".
#[serde(rename = "moveid", default)]
pub move_id: Option<i64>,
#[serde(default)]
pub week: Option<i64>,
#[serde(default)]
pub cancelled: Option<bool>,
#[serde(default)]
pub frozen: Option<bool>,
#[serde(default)]
pub player: Option<PlayerRef>,
#[serde(default)]
pub oldteam: Option<TeamRef>,
#[serde(default)]
pub newteam: Option<TeamRef>,
}
// =============================================================================
// Current season info
// =============================================================================
#[derive(Debug, Deserialize)]
pub struct CurrentResponse {
pub season: i64,
pub week: i64,
}