185 lines
5.0 KiB
Rust
185 lines
5.0 KiB
Rust
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"));
|
|
}
|
|
}
|