feat(runtime): add config storage boundary
test / workspace (push) Successful in 11m57s

This commit is contained in:
Tom You
2026-07-07 00:02:02 -05:00
parent 2f1b9fcd5d
commit 7bd60ebce0
5 changed files with 207 additions and 1 deletions
+184
View File
@@ -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<AppConfig, ConfigError>;
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<std::io::Error> for ConfigError {
fn from(value: std::io::Error) -> Self {
Self::Io(value)
}
}
impl From<serde_json::Error> for ConfigError {
fn from(value: serde_json::Error) -> Self {
Self::Json(value)
}
}
pub struct MemoryConfigStore {
config: Mutex<AppConfig>,
}
impl MemoryConfigStore {
pub fn new(config: AppConfig) -> Self {
Self {
config: Mutex::new(config),
}
}
}
impl ConfigStore for MemoryConfigStore {
fn load(&self) -> Result<AppConfig, ConfigError> {
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<PathBuf>) -> Self {
Self { path: path.into() }
}
pub fn path(&self) -> &Path {
&self.path
}
}
impl ConfigStore for JsonFileConfigStore {
fn load(&self) -> Result<AppConfig, ConfigError> {
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<PathBuf>) -> Self {
Self {
executable_path: executable_path.into(),
}
}
pub fn resolve(&self) -> Result<PathBuf, ConfigError> {
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"));
}
}
+1
View File
@@ -0,0 +1 @@
pub mod config_store;