use rabby_core::AppConfig; use std::fmt; use std::path::{Path, PathBuf}; use std::sync::Mutex; pub trait ConfigStore { fn load(&self) -> Result; fn save(&self, config: &AppConfig) -> Result<(), ConfigError>; } #[derive(Debug)] pub enum ConfigError { Io(std::io::Error), Json(serde_json::Error), Invalid(String), } impl fmt::Display for ConfigError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Io(err) => write!(f, "config I/O error: {err}"), Self::Json(err) => write!(f, "config JSON error: {err}"), Self::Invalid(message) => write!(f, "invalid config: {message}"), } } } impl std::error::Error for ConfigError {} impl From for ConfigError { fn from(value: std::io::Error) -> Self { Self::Io(value) } } impl From for ConfigError { fn from(value: serde_json::Error) -> Self { Self::Json(value) } } pub struct MemoryConfigStore { config: Mutex, } impl MemoryConfigStore { pub fn new(config: AppConfig) -> Self { Self { config: Mutex::new(config), } } } impl ConfigStore for MemoryConfigStore { fn load(&self) -> Result { self.config .lock() .map(|config| config.clone()) .map_err(|_| ConfigError::Invalid("memory config lock poisoned".to_string())) } fn save(&self, config: &AppConfig) -> Result<(), ConfigError> { config.validate().map_err(ConfigError::Invalid)?; *self .config .lock() .map_err(|_| ConfigError::Invalid("memory config lock poisoned".to_string()))? = config.clone(); Ok(()) } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct JsonFileConfigStore { path: PathBuf, } impl JsonFileConfigStore { pub fn new(path: impl Into) -> Self { Self { path: path.into() } } pub fn path(&self) -> &Path { &self.path } } impl ConfigStore for JsonFileConfigStore { fn load(&self) -> Result { let raw = std::fs::read_to_string(&self.path)?; let config: AppConfig = serde_json::from_str(&raw)?; config.validate().map_err(ConfigError::Invalid)?; Ok(config) } fn save(&self, config: &AppConfig) -> Result<(), ConfigError> { config.validate().map_err(ConfigError::Invalid)?; if let Some(parent) = self.path.parent() { std::fs::create_dir_all(parent)?; } let raw = serde_json::to_string_pretty(config)?; std::fs::write(&self.path, raw)?; Ok(()) } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct ConfigPathResolver { executable_path: PathBuf, } impl ConfigPathResolver { pub fn new(executable_path: impl Into) -> Self { Self { executable_path: executable_path.into(), } } pub fn resolve(&self) -> Result { let exe_dir = self .executable_path .parent() .ok_or_else(|| ConfigError::Invalid("executable path has no parent".to_string()))?; let portable_data = exe_dir.join("data"); if portable_data.is_dir() { return Ok(portable_data.join("config.json")); } Ok(exe_dir.join("rabby-config.json")) } } #[cfg(test)] mod tests { use super::*; use rabby_core::AppConfig; use std::path::PathBuf; fn temp_path(name: &str) -> PathBuf { let unique = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos(); std::env::temp_dir().join(format!("rabby-{name}-{unique}")) } #[test] fn memory_store_round_trips_config() { let store = MemoryConfigStore::new(AppConfig::default_linux()); let mut config = store.load().unwrap(); config.theme_id = "solarized-dark".to_string(); store.save(&config).unwrap(); assert_eq!(store.load().unwrap().theme_id, "solarized-dark"); } #[test] fn json_file_store_writes_and_reads_config() { let dir = temp_path("json-store"); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("config.json"); let store = JsonFileConfigStore::new(path.clone()); let mut config = AppConfig::default_linux(); config.theme_id = "tabby-compatible".to_string(); store.save(&config).unwrap(); let raw = std::fs::read_to_string(&path).unwrap(); assert!(raw.contains("tabby-compatible")); assert_eq!(store.load().unwrap(), config); } #[test] fn portable_mode_prefers_data_dir_beside_executable() { let dir = temp_path("portable"); let exe = dir.join("rabby"); let data = dir.join("data"); std::fs::create_dir_all(&data).unwrap(); let resolved = ConfigPathResolver::new(exe).resolve().unwrap(); assert_eq!(resolved, data.join("config.json")); } }