This commit is contained in:
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user