Add Rust project scaffold for TUI rewrite

Initialize rust/ subdirectory with ratatui + tokio + sqlx stack,
mirroring the Python module structure. Includes all DB models,
config loader, matchup scoring logic, and screen stubs that
compile cleanly with cargo check.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-02-26 08:40:42 -06:00
parent 7afe4a5f55
commit 6ddbd82f7c
23 changed files with 4731 additions and 0 deletions

1
rust/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

4019
rust/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

37
rust/Cargo.toml Normal file
View File

@ -0,0 +1,37 @@
[package]
name = "sba-scout"
version = "0.1.0"
edition = "2024"
[dependencies]
# TUI
ratatui = "0.30"
crossterm = { version = "0.28", features = ["event-stream"] }
# Async runtime
tokio = { version = "1", features = ["full"] }
# Database
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
# HTTP client
reqwest = { version = "0.12", features = ["json"] }
# Config
figment = { version = "0.10", features = ["toml", "env"] }
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
# Error handling
anyhow = "1"
thiserror = "2"
# Logging
tracing = "0.1"
tracing-subscriber = "0.3"
# Date/time
chrono = { version = "0.4", features = ["serde"] }

23
rust/src/api/client.rs Normal file
View File

@ -0,0 +1,23 @@
use anyhow::Result;
use reqwest::Client;
use std::time::Duration;
pub struct LeagueApiClient {
client: Client,
base_url: String,
api_key: String,
}
impl LeagueApiClient {
pub fn new(base_url: &str, api_key: &str, timeout_secs: u64) -> Result<Self> {
let client = Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.build()?;
Ok(Self {
client,
base_url: base_url.trim_end_matches('/').to_string(),
api_key: api_key.to_string(),
})
}
}

1
rust/src/api/mod.rs Normal file
View File

@ -0,0 +1 @@
pub mod client;

69
rust/src/app.rs Normal file
View File

@ -0,0 +1,69 @@
use crossterm::event::KeyEvent;
use ratatui::{
layout::{Constraint, Layout},
text::Text,
widgets::{Block, Borders, Paragraph},
Frame,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Screen {
Dashboard,
Roster,
Matchup,
Lineup,
Gameday,
Settings,
}
pub struct App {
pub current_screen: Screen,
}
impl App {
pub fn new() -> Self {
Self {
current_screen: Screen::Dashboard,
}
}
pub fn handle_key(&mut self, key: KeyEvent) {
use crossterm::event::KeyCode;
match key.code {
KeyCode::Char('1') => self.current_screen = Screen::Dashboard,
KeyCode::Char('2') => self.current_screen = Screen::Roster,
KeyCode::Char('3') => self.current_screen = Screen::Matchup,
KeyCode::Char('4') => self.current_screen = Screen::Lineup,
KeyCode::Char('5') => self.current_screen = Screen::Gameday,
KeyCode::Char('6') => self.current_screen = Screen::Settings,
_ => {}
}
}
pub fn render(&self, frame: &mut Frame) {
let area = frame.area();
let chunks = Layout::vertical([Constraint::Length(3), Constraint::Min(0)]).split(area);
// Navigation bar
let nav = Paragraph::new(Text::raw(
" [1] Dashboard [2] Roster [3] Matchup [4] Lineup [5] Gameday [6] Settings [q] Quit",
))
.block(Block::default().borders(Borders::ALL).title("SBA Scout"));
frame.render_widget(nav, chunks[0]);
// Screen content
let content = match self.current_screen {
Screen::Dashboard => "Dashboard - Press a number key to navigate",
Screen::Roster => "Roster Screen (not yet implemented)",
Screen::Matchup => "Matchup Screen (not yet implemented)",
Screen::Lineup => "Lineup Screen (not yet implemented)",
Screen::Gameday => "Gameday Screen (not yet implemented)",
Screen::Settings => "Settings Screen (not yet implemented)",
};
let body = Paragraph::new(Text::raw(content))
.block(Block::default().borders(Borders::ALL).title(format!("{:?}", self.current_screen)));
frame.render_widget(body, chunks[1]);
}
}

View File

@ -0,0 +1,6 @@
/// Distribution statistics for a single stat across the league.
#[derive(Debug, Clone)]
pub struct StatDistribution {
pub avg: f64,
pub stdev: f64,
}

43
rust/src/calc/matchup.rs Normal file
View File

@ -0,0 +1,43 @@
use super::league_stats::StatDistribution;
use super::weights::StatWeight;
/// Convert a raw stat value to a standardized score (-3 to +3).
///
/// Uses standard deviation thresholds from the league mean.
/// A value of 0 always returns +3 (best possible).
pub fn standardize_value(value: f64, distribution: &StatDistribution, high_is_better: bool) -> i32 {
if value == 0.0 {
return 3;
}
let avg = distribution.avg;
let stdev = distribution.stdev;
let base_score = if value > avg + 2.0 * stdev {
-3
} else if value > avg + stdev {
-2
} else if value > avg + 0.33 * stdev {
-1
} else if value > avg - 0.33 * stdev {
0
} else if value > avg - stdev {
1
} else if value > avg - 2.0 * stdev {
2
} else {
3
};
if high_is_better { -base_score } else { base_score }
}
/// Calculate weighted score for a single stat.
pub fn calculate_weighted_score(
value: f64,
distribution: &StatDistribution,
stat_weight: &StatWeight,
) -> f64 {
let std_score = standardize_value(value, distribution, stat_weight.high_is_better);
std_score as f64 * stat_weight.weight as f64
}

3
rust/src/calc/mod.rs Normal file
View File

@ -0,0 +1,3 @@
pub mod league_stats;
pub mod matchup;
pub mod weights;

60
rust/src/calc/weights.rs Normal file
View File

@ -0,0 +1,60 @@
/// Weight and direction for a single stat in matchup scoring.
#[derive(Debug, Clone, Copy)]
pub struct StatWeight {
pub weight: i32,
pub high_is_better: bool,
}
impl StatWeight {
const fn new(weight: i32, high_is_better: bool) -> Self {
Self {
weight,
high_is_better,
}
}
}
/// Batter stat weights for matchup calculation.
/// Order: so, bb, hit, ob, tb, hr, bphr, bp1b, dp
pub const BATTER_WEIGHTS: &[(&str, StatWeight)] = &[
("so", StatWeight::new(1, false)), // Strikeouts - low is better
("bb", StatWeight::new(1, true)), // Walks - high is better
("hit", StatWeight::new(2, true)), // Hits - high is better
("ob", StatWeight::new(5, true)), // On-base - high is better
("tb", StatWeight::new(5, true)), // Total bases - high is better
("hr", StatWeight::new(2, true)), // Home runs - high is better
("bphr", StatWeight::new(3, true)), // Ballpark HR - high is better
("bp1b", StatWeight::new(1, true)), // Ballpark 1B - high is better
("dp", StatWeight::new(2, false)), // Double plays - low is better
];
/// Pitcher stat weights for matchup calculation.
/// Note: directions are inverted vs batter (pitcher wants low hits, high K's).
pub const PITCHER_WEIGHTS: &[(&str, StatWeight)] = &[
("so", StatWeight::new(3, true)), // Strikeouts - high is better for pitcher
("bb", StatWeight::new(1, false)), // Walks - low is better for pitcher
("hit", StatWeight::new(2, false)), // Hits - low is better for pitcher
("ob", StatWeight::new(5, false)), // On-base - low is better for pitcher
("tb", StatWeight::new(2, false)), // Total bases - low is better for pitcher
("hr", StatWeight::new(5, false)), // Home runs - low is better for pitcher
("bphr", StatWeight::new(2, false)), // Ballpark HR - low is better for pitcher
("bp1b", StatWeight::new(1, false)), // Ballpark 1B - low is better for pitcher
("dp", StatWeight::new(2, true)), // Double plays - high is better for pitcher
];
/// Max possible batter component score: sum of (3 * weight) for all stats.
pub const fn max_batter_score() -> i32 {
// 3 * (1+1+2+5+5+2+3+1+2) = 3 * 22 = 66
66
}
/// Max possible pitcher component score: sum of (3 * weight) for all stats.
pub const fn max_pitcher_score() -> i32 {
// 3 * (3+1+2+5+2+5+2+1+2) = 3 * 23 = 69
69
}
/// Max possible combined matchup score.
pub const fn max_matchup_score() -> i32 {
max_batter_score() + max_pitcher_score() // 135
}

112
rust/src/config.rs Normal file
View File

@ -0,0 +1,112 @@
use std::path::PathBuf;
use figment::{
providers::{Env, Format, Toml},
Figment,
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize)]
pub struct Settings {
pub db_path: PathBuf,
pub api: ApiSettings,
pub team: TeamSettings,
pub ui: UiSettings,
pub rating_weights: RatingWeights,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ApiSettings {
pub base_url: String,
pub api_key: String,
pub timeout: u64,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct TeamSettings {
pub abbrev: String,
pub season: i32,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct UiSettings {
pub theme: String,
pub refresh_interval: u64,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct RatingWeights {
pub hit: f64,
pub on_base: f64,
pub total_bases: f64,
pub home_run: f64,
pub walk: f64,
pub strikeout: f64,
pub double_play: f64,
pub bp_home_run: f64,
pub bp_single: f64,
}
impl Default for Settings {
fn default() -> Self {
Self {
db_path: PathBuf::from("data/sba_scout.db"),
api: ApiSettings::default(),
team: TeamSettings::default(),
ui: UiSettings::default(),
rating_weights: RatingWeights::default(),
}
}
}
impl Default for ApiSettings {
fn default() -> Self {
Self {
base_url: "https://sba.manticorum.com/".to_string(),
api_key: String::new(),
timeout: 30,
}
}
}
impl Default for TeamSettings {
fn default() -> Self {
Self {
abbrev: "WV".to_string(),
season: 13,
}
}
}
impl Default for UiSettings {
fn default() -> Self {
Self {
theme: "dark".to_string(),
refresh_interval: 300,
}
}
}
impl Default for RatingWeights {
fn default() -> Self {
Self {
hit: 1.0,
on_base: 0.8,
total_bases: 0.5,
home_run: 1.5,
walk: 0.6,
strikeout: -0.3,
double_play: -0.5,
bp_home_run: 0.3,
bp_single: 0.1,
}
}
}
pub fn load_settings() -> Result<Settings, figment::Error> {
Figment::new()
.merge(figment::providers::Serialized::defaults(Settings::default()))
.merge(Toml::file("settings.toml"))
.merge(Env::prefixed("SBA_SCOUT_").split("__"))
.extract()
}

3
rust/src/db/mod.rs Normal file
View File

@ -0,0 +1,3 @@
pub mod models;
pub mod queries;
pub mod schema;

253
rust/src/db/models.rs Normal file
View File

@ -0,0 +1,253 @@
use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
// =============================================================================
// Core Entities (synced from league API)
// =============================================================================
#[derive(Debug, FromRow, Serialize, Deserialize)]
pub struct Team {
pub id: i64,
pub abbrev: String,
pub short_name: String,
pub long_name: String,
pub season: i64,
pub manager1_name: Option<String>,
pub manager2_name: Option<String>,
pub gm_discord_id: Option<String>,
pub gm2_discord_id: Option<String>,
pub division_id: Option<i64>,
pub division_name: Option<String>,
pub league_abbrev: Option<String>,
pub thumbnail: Option<String>,
pub color: Option<String>,
pub dice_color: Option<String>,
pub stadium: Option<String>,
pub salary_cap: Option<f64>,
pub synced_at: Option<NaiveDateTime>,
}
#[derive(Debug, FromRow, Serialize, Deserialize)]
pub struct Player {
pub id: i64,
pub name: String,
pub season: i64,
pub team_id: Option<i64>,
pub swar: Option<f64>,
pub card_image: Option<String>,
pub card_image_alt: Option<String>,
pub headshot: Option<String>,
pub vanity_card: Option<String>,
pub pos_1: Option<String>,
pub pos_2: Option<String>,
pub pos_3: Option<String>,
pub pos_4: Option<String>,
pub pos_5: Option<String>,
pub pos_6: Option<String>,
pub pos_7: Option<String>,
pub pos_8: Option<String>,
pub hand: Option<String>,
pub injury_rating: Option<String>,
pub il_return: Option<String>,
pub demotion_week: Option<i64>,
pub strat_code: Option<String>,
pub bbref_id: Option<String>,
pub sbaplayer_id: Option<i64>,
pub last_game: Option<String>,
pub last_game2: Option<String>,
pub synced_at: Option<NaiveDateTime>,
}
impl Player {
pub fn positions(&self) -> Vec<&str> {
[
self.pos_1.as_deref(),
self.pos_2.as_deref(),
self.pos_3.as_deref(),
self.pos_4.as_deref(),
self.pos_5.as_deref(),
self.pos_6.as_deref(),
self.pos_7.as_deref(),
self.pos_8.as_deref(),
]
.into_iter()
.flatten()
.collect()
}
pub fn is_pitcher(&self) -> bool {
self.positions()
.iter()
.any(|p| matches!(*p, "SP" | "RP" | "CP"))
}
pub fn is_batter(&self) -> bool {
self.positions()
.iter()
.any(|p| matches!(*p, "C" | "1B" | "2B" | "3B" | "SS" | "LF" | "CF" | "RF" | "DH"))
}
}
// =============================================================================
// Card Data (imported from Strat-o-Matic)
// =============================================================================
#[derive(Debug, FromRow, Serialize, Deserialize)]
pub struct BatterCard {
pub id: i64,
pub player_id: i64,
// vs Left-Handed Pitchers
pub so_vlhp: f64,
pub bb_vlhp: f64,
pub hit_vlhp: f64,
pub ob_vlhp: f64,
pub tb_vlhp: f64,
pub hr_vlhp: f64,
pub dp_vlhp: f64,
// vs Right-Handed Pitchers
pub so_vrhp: f64,
pub bb_vrhp: f64,
pub hit_vrhp: f64,
pub ob_vrhp: f64,
pub tb_vrhp: f64,
pub hr_vrhp: f64,
pub dp_vrhp: f64,
// Ballpark modifiers
pub bphr_vlhp: f64,
pub bphr_vrhp: f64,
pub bp1b_vlhp: f64,
pub bp1b_vrhp: f64,
// Running game
pub stealing: Option<String>,
pub steal_rating: Option<String>,
pub speed: Option<i64>,
// Batting extras
pub bunt: Option<String>,
pub hit_run: Option<String>,
// Fielding
pub fielding: Option<String>,
// Catcher-specific
pub catcher_arm: Option<i64>,
pub catcher_pb: Option<i64>,
pub catcher_t: Option<i64>,
// Computed ratings
pub rating_vl: Option<f64>,
pub rating_vr: Option<f64>,
pub rating_overall: Option<f64>,
// Import metadata
pub imported_at: Option<NaiveDateTime>,
pub source: Option<String>,
}
#[derive(Debug, FromRow, Serialize, Deserialize)]
pub struct PitcherCard {
pub id: i64,
pub player_id: i64,
// vs Left-Handed Batters
pub so_vlhb: f64,
pub bb_vlhb: f64,
pub hit_vlhb: f64,
pub ob_vlhb: f64,
pub tb_vlhb: f64,
pub hr_vlhb: f64,
pub dp_vlhb: f64,
pub bphr_vlhb: f64,
pub bp1b_vlhb: f64,
// vs Right-Handed Batters
pub so_vrhb: f64,
pub bb_vrhb: f64,
pub hit_vrhb: f64,
pub ob_vrhb: f64,
pub tb_vrhb: f64,
pub hr_vrhb: f64,
pub dp_vrhb: f64,
pub bphr_vrhb: f64,
pub bp1b_vrhb: f64,
// Pitcher attributes
pub hold_rating: Option<i64>,
pub endurance_start: Option<i64>,
pub endurance_relief: Option<i64>,
pub endurance_close: Option<i64>,
pub fielding_range: Option<i64>,
pub fielding_error: Option<i64>,
pub wild_pitch: Option<i64>,
pub balk: Option<i64>,
pub batting_rating: Option<String>,
// Computed ratings
pub rating_vlhb: Option<f64>,
pub rating_vrhb: Option<f64>,
pub rating_overall: Option<f64>,
// Import metadata
pub imported_at: Option<NaiveDateTime>,
pub source: Option<String>,
}
// =============================================================================
// Transactions (synced from league API)
// =============================================================================
#[derive(Debug, FromRow, Serialize, Deserialize)]
pub struct Transaction {
pub id: i64,
pub season: i64,
pub week: i64,
pub move_id: String,
pub player_id: i64,
pub from_team_id: i64,
pub to_team_id: i64,
pub cancelled: bool,
pub frozen: bool,
pub synced_at: Option<NaiveDateTime>,
}
// =============================================================================
// User Data (local only)
// =============================================================================
#[derive(Debug, FromRow, Serialize, Deserialize)]
pub struct Lineup {
pub id: i64,
pub name: String,
pub description: Option<String>,
pub lineup_type: String,
pub batting_order: Option<String>, // JSON string
pub positions: Option<String>, // JSON string
pub starting_pitcher_id: Option<i64>,
pub created_at: Option<NaiveDateTime>,
pub updated_at: Option<NaiveDateTime>,
}
#[derive(Debug, FromRow, Serialize, Deserialize)]
pub struct MatchupCache {
pub id: i64,
pub batter_id: i64,
pub pitcher_id: i64,
pub rating: f64,
pub tier: Option<String>,
pub details: Option<String>, // JSON string
pub computed_at: Option<NaiveDateTime>,
pub weights_hash: Option<String>,
}
#[derive(Debug, FromRow, Serialize, Deserialize)]
pub struct StandardizedScoreCache {
pub id: i64,
pub batter_card_id: Option<i64>,
pub pitcher_card_id: Option<i64>,
pub split: String,
pub total_score: f64,
pub stat_scores: String, // JSON string
pub computed_at: Option<NaiveDateTime>,
pub weights_hash: Option<String>,
pub league_stats_hash: Option<String>,
}
#[derive(Debug, FromRow, Serialize, Deserialize)]
pub struct SyncStatus {
pub id: i64,
pub entity_type: String,
pub last_sync: Option<NaiveDateTime>,
pub last_sync_count: Option<i64>,
pub last_error: Option<String>,
}

2
rust/src/db/queries.rs Normal file
View File

@ -0,0 +1,2 @@
// Database query functions will be implemented here.
// Each function will use sqlx::query_as! to map results to model structs.

18
rust/src/db/schema.rs Normal file
View File

@ -0,0 +1,18 @@
use anyhow::Result;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
use std::path::Path;
use std::str::FromStr;
pub async fn init_pool(db_path: &Path) -> Result<SqlitePool> {
let db_url = format!("sqlite://{}?mode=rwc", db_path.display());
let options = SqliteConnectOptions::from_str(&db_url)?
.create_if_missing(true)
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal);
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect_with(options)
.await?;
Ok(pool)
}

39
rust/src/main.rs Normal file
View File

@ -0,0 +1,39 @@
mod api;
mod app;
mod calc;
mod config;
mod db;
mod screens;
use anyhow::Result;
use crossterm::event::{self, Event, KeyCode};
use ratatui::DefaultTerminal;
use app::App;
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt::init();
let mut terminal = ratatui::init();
let result = run(&mut terminal).await;
ratatui::restore();
result
}
async fn run(terminal: &mut DefaultTerminal) -> Result<()> {
let mut app = App::new();
loop {
terminal.draw(|frame| app.render(frame))?;
if let Event::Key(key) = event::read()? {
if key.code == KeyCode::Char('q') {
break;
}
app.handle_key(key);
}
}
Ok(())
}

View File

@ -0,0 +1,6 @@
use ratatui::{layout::Rect, widgets::Paragraph, Frame};
pub fn render(frame: &mut Frame, area: Rect) {
let widget = Paragraph::new("Dashboard");
frame.render_widget(widget, area);
}

View File

@ -0,0 +1,6 @@
use ratatui::{layout::Rect, widgets::Paragraph, Frame};
pub fn render(frame: &mut Frame, area: Rect) {
let widget = Paragraph::new("Gameday");
frame.render_widget(widget, area);
}

View File

@ -0,0 +1,6 @@
use ratatui::{layout::Rect, widgets::Paragraph, Frame};
pub fn render(frame: &mut Frame, area: Rect) {
let widget = Paragraph::new("Lineup");
frame.render_widget(widget, area);
}

View File

@ -0,0 +1,6 @@
use ratatui::{layout::Rect, widgets::Paragraph, Frame};
pub fn render(frame: &mut Frame, area: Rect) {
let widget = Paragraph::new("Matchup");
frame.render_widget(widget, area);
}

6
rust/src/screens/mod.rs Normal file
View File

@ -0,0 +1,6 @@
pub mod dashboard;
pub mod gameday;
pub mod lineup;
pub mod matchup;
pub mod roster;
pub mod settings;

View File

@ -0,0 +1,6 @@
use ratatui::{layout::Rect, widgets::Paragraph, Frame};
pub fn render(frame: &mut Frame, area: Rect) {
let widget = Paragraph::new("Roster");
frame.render_widget(widget, area);
}

View File

@ -0,0 +1,6 @@
use ratatui::{layout::Rect, widgets::Paragraph, Frame};
pub fn render(frame: &mut Frame, area: Rect) {
let widget = Paragraph::new("Settings");
frame.render_widget(widget, area);
}