From 7bd60ebce0652ffaabde3be245bdef656f45ee53 Mon Sep 17 00:00:00 2001 From: Tom You Date: Tue, 7 Jul 2026 00:02:02 -0500 Subject: [PATCH] feat(runtime): add config storage boundary --- Cargo.lock | 8 ++ Cargo.toml | 2 +- rabby-runtime/Cargo.toml | 13 +++ rabby-runtime/src/config_store.rs | 184 ++++++++++++++++++++++++++++++ rabby-runtime/src/lib.rs | 1 + 5 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 rabby-runtime/Cargo.toml create mode 100644 rabby-runtime/src/config_store.rs create mode 100644 rabby-runtime/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 32fd097..f25b15c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2305,6 +2305,14 @@ dependencies = [ "serde_json", ] +[[package]] +name = "rabby-runtime" +version = "0.1.0" +dependencies = [ + "rabby-core", + "serde_json", +] + [[package]] name = "raw-window-handle" version = "0.6.2" diff --git a/Cargo.toml b/Cargo.toml index 0fc6d0c..326e3af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["rabby-core", "src-tauri"] +members = ["rabby-core", "rabby-runtime", "src-tauri"] resolver = "2" [workspace.package] diff --git a/rabby-runtime/Cargo.toml b/rabby-runtime/Cargo.toml new file mode 100644 index 0000000..522842b --- /dev/null +++ b/rabby-runtime/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "rabby-runtime" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[lib] +path = "src/lib.rs" + +[dependencies] +rabby-core = { path = "../rabby-core" } +serde_json = "1" diff --git a/rabby-runtime/src/config_store.rs b/rabby-runtime/src/config_store.rs new file mode 100644 index 0000000..5ca7835 --- /dev/null +++ b/rabby-runtime/src/config_store.rs @@ -0,0 +1,184 @@ +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")); + } +} diff --git a/rabby-runtime/src/lib.rs b/rabby-runtime/src/lib.rs new file mode 100644 index 0000000..279c93a --- /dev/null +++ b/rabby-runtime/src/lib.rs @@ -0,0 +1 @@ +pub mod config_store;