From 3c70ecc71a41d91bc9e07adca3442070a37f7080 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 27 Feb 2026 22:56:50 -0600 Subject: [PATCH] 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 --- rust/PHASE2_PROJECT_PLAN.json | 291 +++++++++++++++ rust/src/api/client.rs | 202 +++++++++++ rust/src/api/importer.rs | 666 ++++++++++++++++++++++++++++++++++ rust/src/api/mod.rs | 3 + rust/src/api/sync.rs | 243 +++++++++++++ rust/src/api/types.rs | 175 +++++++++ 6 files changed, 1580 insertions(+) create mode 100644 rust/PHASE2_PROJECT_PLAN.json create mode 100644 rust/src/api/importer.rs create mode 100644 rust/src/api/sync.rs create mode 100644 rust/src/api/types.rs diff --git a/rust/PHASE2_PROJECT_PLAN.json b/rust/PHASE2_PROJECT_PLAN.json new file mode 100644 index 0000000..8d65f3f --- /dev/null +++ b/rust/PHASE2_PROJECT_PLAN.json @@ -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 }`\n2. `TeamData` — flat fields plus nested `manager1: Option`, `manager2: Option`, `division: Option`. 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 }`. Division struct: `{ id: Option, division_name: Option, league_abbrev: Option }`.\n3. `PlayersResponse { count: u32, players: Vec }`\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` where `TeamRef { id: i64 }`.\n5. `TransactionsResponse { count: u32, transactions: Vec }`\n6. `TransactionData` — `#[serde(rename = \"moveid\")]` for `move_id`. Nested `player: Option`, `oldteam: Option`, `newteam: Option`.\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 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(&self, path: &str, params: &[(& str, String)]) -> Result\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 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` 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`:\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, client: &LeagueApiClient) -> Result`:\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, team_abbrev: Option<&str>, client: &LeagueApiClient) -> Result`:\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`:\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, Option, Option)` — 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`:\n\nDefine `ImportResult { imported: i64, skipped: i64, errors: Vec }`.\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 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`:\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`:\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 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 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." + } +} diff --git a/rust/src/api/client.rs b/rust/src/api/client.rs index 353aead..64d1ffd 100644 --- a/rust/src/api/client.rs +++ b/rust/src/api/client.rs @@ -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( + &self, + path: &str, + params: &[(String, String)], + ) -> Result { + 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::(&body)?; + Ok(json) + } + + // ------------------------------------------------------------------------- + // Public endpoint methods + // ------------------------------------------------------------------------- + + pub async fn get_teams( + &self, + season: Option, + team_abbrev: Option<&[&str]>, + active_only: bool, + short_output: bool, + ) -> Result { + 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", ¶ms).await + } + + pub async fn get_team(&self, team_id: i64) -> Result { + self.get(&format!("/teams/{}", team_id), &[]).await + } + + pub async fn get_team_roster( + &self, + team_id: i64, + which: &str, + ) -> Result { + self.get(&format!("/teams/{}/roster/{}", team_id, which), &[]).await + } + + pub async fn get_players( + &self, + season: Option, + team_id: Option<&[i64]>, + pos: Option<&[&str]>, + name: Option<&str>, + short_output: bool, + ) -> Result { + 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", ¶ms).await + } + + pub async fn get_player( + &self, + player_id: i64, + short_output: bool, + ) -> Result { + 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), ¶ms).await + } + + pub async fn search_players( + &self, + query: &str, + season: Option, + limit: Option, + ) -> Result { + 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", ¶ms).await + } + + pub async fn get_transactions( + &self, + season: i64, + week_start: i64, + week_end: Option, + team_abbrev: Option<&[&str]>, + cancelled: bool, + frozen: bool, + short_output: bool, + ) -> Result { + 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", ¶ms).await + } + + pub async fn get_current(&self) -> Result { + self.get("/current", &[]).await + } + + pub async fn get_schedule( + &self, + season: i64, + week: Option, + team_id: Option, + ) -> Result { + 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", ¶ms).await + } + + pub async fn get_standings(&self, season: i64) -> Result { + let params = vec![("season".to_string(), season.to_string())]; + self.get("/standings", ¶ms).await + } } diff --git a/rust/src/api/importer.rs b/rust/src/api/importer.rs new file mode 100644 index 0000000..06cc7a5 --- /dev/null +++ b/rust/src/api/importer.rs @@ -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, +} + +/// 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 = + LazyLock::new(|| Regex::new(r"S\((\d+)\*?\)").expect("valid regex")); + +static RE_RELIEF: LazyLock = + LazyLock::new(|| Regex::new(r"R\((\d+)\)").expect("valid regex")); + +static RE_CLOSE: LazyLock = + 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::().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::() { + 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, Option, Option) { + 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::().ok()); + + let relief = RE_RELIEF + .captures(value) + .and_then(|c| c.get(1)) + .and_then(|m| m.as_str().parse::().ok()); + + let close = RE_CLOSE + .captures(value) + .and_then(|c| c.get(1)) + .and_then(|m| m.as_str().parse::().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 { + 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 = + 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 = + 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 = + 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 = 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 { + let t = v.trim(); + if t.is_empty() { None } else { Some(t.to_string()) } + }; + let opt_float = |v: &str| -> Option { + 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 { + 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 = + 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 = + 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 = + 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 { + let t = v.trim(); + if t.is_empty() { None } else { Some(t.to_string()) } + }; + let opt_float = |v: &str| -> Option { + if v.trim().is_empty() { None } else { Some(parse_float(v, 0.0)) } + }; + let opt_int = |v: &str| -> Option { + 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 { + 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)); + } +} diff --git a/rust/src/api/mod.rs b/rust/src/api/mod.rs index b9babe5..f77cad7 100644 --- a/rust/src/api/mod.rs +++ b/rust/src/api/mod.rs @@ -1 +1,4 @@ pub mod client; +pub mod importer; +pub mod sync; +pub mod types; diff --git a/rust/src/api/sync.rs b/rust/src/api/sync.rs new file mode 100644 index 0000000..a71d595 --- /dev/null +++ b/rust/src/api/sync.rs @@ -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 { + 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, + client: &LeagueApiClient, +) -> Result { + 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, + team_abbrev: Option<&str>, + client: &LeagueApiClient, +) -> Result { + 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 { + 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 }) +} diff --git a/rust/src/api/types.rs b/rust/src/api/types.rs new file mode 100644 index 0000000..43db060 --- /dev/null +++ b/rust/src/api/types.rs @@ -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, +} + +/// Division sub-object nested inside TeamData. +#[derive(Debug, Deserialize)] +pub struct Division { + pub id: Option, + pub division_name: Option, + pub league_abbrev: Option, +} + +// ============================================================================= +// Teams +// ============================================================================= + +#[derive(Debug, Deserialize)] +pub struct TeamsResponse { + pub count: u32, + pub teams: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct TeamData { + pub id: i64, + #[serde(default)] + pub abbrev: Option, + #[serde(rename = "sname", default)] + pub short_name: Option, + #[serde(rename = "lname", default)] + pub long_name: Option, + #[serde(default)] + pub thumbnail: Option, + #[serde(default)] + pub color: Option, + #[serde(default)] + pub dice_color: Option, + #[serde(default)] + pub stadium: Option, + #[serde(default)] + pub salary_cap: Option, + /// Discord user ID of the primary GM (API sends integer, DB stores as String). + #[serde(rename = "gmid", default)] + pub gm_discord_id: Option, + /// Discord user ID of the secondary GM (API sends integer, DB stores as String). + #[serde(rename = "gmid2", default)] + pub gm2_discord_id: Option, + #[serde(default)] + pub manager1: Option, + #[serde(default)] + pub manager2: Option, + #[serde(default)] + pub division: Option, +} + +// ============================================================================= +// Players +// ============================================================================= + +#[derive(Debug, Deserialize)] +pub struct PlayersResponse { + pub count: u32, + pub players: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct PlayerData { + pub id: i64, + pub name: Option, + pub headshot: Option, + pub vanity_card: Option, + /// Strat-O-Matic WAR equivalent — API field is "wara". + #[serde(rename = "wara", default)] + pub swar: Option, + /// SBA player ID — API field is "sbaplayer". + #[serde(rename = "sbaplayer", default)] + pub sbaplayer_id: Option, + /// Primary card image URL — API field is "image". + #[serde(rename = "image", default)] + pub card_image: Option, + /// Alternate card image URL — API field is "image2". + #[serde(rename = "image2", default)] + pub card_image_alt: Option, + #[serde(default)] + pub team: Option, + #[serde(default)] + pub pos_1: Option, + #[serde(default)] + pub pos_2: Option, + #[serde(default)] + pub pos_3: Option, + #[serde(default)] + pub pos_4: Option, + #[serde(default)] + pub pos_5: Option, + #[serde(default)] + pub pos_6: Option, + #[serde(default)] + pub pos_7: Option, + #[serde(default)] + pub pos_8: Option, + #[serde(default)] + pub injury_rating: Option, + #[serde(default)] + pub il_return: Option, + #[serde(default)] + pub demotion_week: Option, + #[serde(default)] + pub strat_code: Option, + #[serde(default)] + pub bbref_id: Option, + #[serde(default)] + pub last_game: Option, + #[serde(default)] + pub last_game2: Option, +} + +// ============================================================================= +// Transactions +// ============================================================================= + +#[derive(Debug, Deserialize)] +pub struct TransactionsResponse { + pub count: u32, + pub transactions: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct TransactionData { + /// Transaction move ID — API field is "moveid". + #[serde(rename = "moveid", default)] + pub move_id: Option, + #[serde(default)] + pub week: Option, + #[serde(default)] + pub cancelled: Option, + #[serde(default)] + pub frozen: Option, + #[serde(default)] + pub player: Option, + #[serde(default)] + pub oldteam: Option, + #[serde(default)] + pub newteam: Option, +} + +// ============================================================================= +// Current season info +// ============================================================================= + +#[derive(Debug, Deserialize)] +pub struct CurrentResponse { + pub season: i64, + pub week: i64, +}