// SQLite settings storage backend (#627) // Persistent key-value settings with typed columns via rusqlite (bundled). // Per-player isolation via player_id column — multiplayer-ready (D-010). use rusqlite::{params, Connection}; use std::collections::BTreeMap; use std::path::Path; use super::types::{SettingEntry, SettingValue}; /// SQLite-backed settings store. /// /// Architecture: single `settings` table with typed value columns. /// Each setting is a row keyed by (player_id, key). No JSON blobs — /// future settings are just new rows, zero migrations. /// /// The `bundled` feature compiles SQLite into the binary, so there's /// no runtime dependency on a system SQLite library. pub struct SettingsStore { conn: Connection, } impl SettingsStore { /// Open (or create) the settings database at `path`. /// Creates the schema if the table doesn't exist. pub fn open(path: &Path) -> Result { let conn = Connection::open(path)?; conn.execute_batch( "PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; PRAGMA foreign_keys = ON;", )?; conn.execute( "CREATE TABLE IF NOT EXISTS settings ( player_id TEXT NOT NULL, key TEXT NOT NULL, value_type TEXT NOT NULL, value_text TEXT, value_int INTEGER, value_float REAL, value_bool INTEGER, PRIMARY KEY (player_id, key) )", [], )?; Ok(Self { conn }) } /// Open an in-memory database (for tests). pub fn open_in_memory() -> Result { let conn = Connection::open_in_memory()?; conn.execute_batch("PRAGMA foreign_keys = ON;")?; conn.execute( "CREATE TABLE settings ( player_id TEXT NOT NULL, key TEXT NOT NULL, value_type TEXT NOT NULL, value_text TEXT, value_int INTEGER, value_float REAL, value_bool INTEGER, PRIMARY KEY (player_id, key) )", [], )?; Ok(Self { conn }) } /// Set a single setting for a player. Upserts (insert or replace). pub fn set( &self, player_id: &str, key: &str, value: &SettingValue, ) -> Result<(), rusqlite::Error> { let (vtype, vtext, vint, vfloat, vbool) = value_to_columns(value); self.conn.execute( "INSERT OR REPLACE INTO settings (player_id, key, value_type, value_text, value_int, value_float, value_bool) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![player_id, key, vtype, vtext, vint, vfloat, vbool], )?; Ok(()) } /// Get a single setting for a player. Returns None if not found. pub fn get(&self, player_id: &str, key: &str) -> Result, rusqlite::Error> { let mut stmt = self.conn.prepare( "SELECT value_type, value_text, value_int, value_float, value_bool FROM settings WHERE player_id = ?1 AND key = ?2", )?; let mut rows = stmt.query(params![player_id, key])?; match rows.next()? { Some(row) => { let vtype: String = row.get(0)?; Ok(Some(columns_to_value(&vtype, row, 1)?)) } None => Ok(None), } } /// Get all settings for a player as a sorted map. pub fn get_all( &self, player_id: &str, ) -> Result, rusqlite::Error> { let mut stmt = self.conn.prepare( "SELECT key, value_type, value_text, value_int, value_float, value_bool FROM settings WHERE player_id = ?1 ORDER BY key", )?; let mut rows = stmt.query(params![player_id])?; let mut result = BTreeMap::new(); while let Some(row) = rows.next()? { let key: String = row.get(0)?; let vtype: String = row.get(1)?; let value = columns_to_value(&vtype, row, 2)?; result.insert(key, value); } Ok(result) } /// Delete a single setting for a player. Returns true if a row was deleted. pub fn delete(&self, player_id: &str, key: &str) -> Result { let count = self.conn.execute( "DELETE FROM settings WHERE player_id = ?1 AND key = ?2", params![player_id, key], )?; Ok(count > 0) } /// Get all settings as a Vec for wire serialization. pub fn get_all_entries(&self, player_id: &str) -> Result, rusqlite::Error> { self.get_all(player_id).map(|map| { map.into_iter() .map(|(key, value)| SettingEntry { key, value }) .collect() }) } } /// Map a SettingValue to SQLite column values. fn value_to_columns( value: &SettingValue, ) -> ( &'static str, Option, Option, Option, Option, ) { match value { SettingValue::String(s) => ("string", Some(s.clone()), None, None, None), SettingValue::Int(i) => ("int", None, Some(*i), None, None), SettingValue::Float(f) => ("float", None, None, Some(*f), None), SettingValue::Bool(b) => ("bool", None, None, None, Some(*b)), } } /// Read a SettingValue from a row where value columns start at `offset`. /// `offset` is the index of the value_text column (value_int = offset+1, etc.). /// /// Used by `get()` (offset=1) and `get_all()` (offset=2) which select different /// column prefixes before the value columns. fn columns_to_value( vtype: &str, row: &rusqlite::Row<'_>, offset: usize, ) -> Result { match vtype { "string" => Ok(SettingValue::String(row.get::<_, String>(offset)?)), "int" => Ok(SettingValue::Int(row.get::<_, i64>(offset + 1)?)), "float" => Ok(SettingValue::Float(row.get::<_, f64>(offset + 2)?)), "bool" => Ok(SettingValue::Bool(row.get::<_, bool>(offset + 3)?)), other => { tracing::warn!( "unknown settings value_type '{}', treating as String", other ); Ok(SettingValue::String(format!("", other))) } } } #[cfg(test)] mod tests { use super::*; fn test_store() -> SettingsStore { SettingsStore::open_in_memory().expect("open in-memory store") } #[test] fn set_and_get_string() { let store = test_store(); store .set("p1", "display.theme", &SettingValue::String("dark".into())) .unwrap(); let val = store.get("p1", "display.theme").unwrap(); assert_eq!(val, Some(SettingValue::String("dark".into()))); } #[test] fn set_and_get_int() { let store = test_store(); store .set("p1", "audio.volume", &SettingValue::Int(75)) .unwrap(); let val = store.get("p1", "audio.volume").unwrap(); assert_eq!(val, Some(SettingValue::Int(75))); } #[test] fn set_and_get_float() { let store = test_store(); store .set("p1", "audio.master", &SettingValue::Float(0.85)) .unwrap(); let val = store.get("p1", "audio.master").unwrap(); assert_eq!(val, Some(SettingValue::Float(0.85))); } #[test] fn set_and_get_bool() { let store = test_store(); store .set("p1", "ai_dialogue.enabled", &SettingValue::Bool(true)) .unwrap(); let val = store.get("p1", "ai_dialogue.enabled").unwrap(); assert_eq!(val, Some(SettingValue::Bool(true))); } #[test] fn get_missing_returns_none() { let store = test_store(); let val = store.get("p1", "nonexistent").unwrap(); assert_eq!(val, None); } #[test] fn upsert_overwrites() { let store = test_store(); store .set("p1", "audio.volume", &SettingValue::Int(50)) .unwrap(); store .set("p1", "audio.volume", &SettingValue::Int(80)) .unwrap(); let val = store.get("p1", "audio.volume").unwrap(); assert_eq!(val, Some(SettingValue::Int(80))); } #[test] fn player_isolation() { let store = test_store(); store.set("p1", "volume", &SettingValue::Int(50)).unwrap(); store.set("p2", "volume", &SettingValue::Int(90)).unwrap(); assert_eq!( store.get("p1", "volume").unwrap(), Some(SettingValue::Int(50)) ); assert_eq!( store.get("p2", "volume").unwrap(), Some(SettingValue::Int(90)) ); } #[test] fn get_all_sorted() { let store = test_store(); store.set("p1", "z_key", &SettingValue::Int(1)).unwrap(); store.set("p1", "a_key", &SettingValue::Int(2)).unwrap(); store.set("p1", "m_key", &SettingValue::Int(3)).unwrap(); let all = store.get_all("p1").unwrap(); let keys: Vec<&String> = all.keys().collect(); assert_eq!(keys, vec!["a_key", "m_key", "z_key"]); } #[test] fn delete_setting() { let store = test_store(); store.set("p1", "key", &SettingValue::Int(1)).unwrap(); assert!(store.delete("p1", "key").unwrap()); assert_eq!(store.get("p1", "key").unwrap(), None); } #[test] fn delete_nonexistent_returns_false() { let store = test_store(); assert!(!store.delete("p1", "nope").unwrap()); } #[test] fn get_all_entries_wire_format() { let store = test_store(); store .set("p1", "audio.volume", &SettingValue::Float(0.5)) .unwrap(); store .set("p1", "ai_dialogue.enabled", &SettingValue::Bool(true)) .unwrap(); let entries = store.get_all_entries("p1").unwrap(); assert_eq!(entries.len(), 2); assert_eq!(entries[0].key, "ai_dialogue.enabled"); assert_eq!(entries[1].key, "audio.volume"); } }