use crate::compat::{Box, HashMap, String, Vec};
use crate::core::Rect;
use std::fmt;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
const SETTINGS_FILE_NAME: &str = "settings.json";
#[derive(Debug, Clone, PartialEq)]
pub enum SettingValue {
Bool(bool),
I64(i64),
F64(f64),
String(crate::compat::String),
Bytes(Vec<u8>),
}
#[derive(Debug)]
pub enum SettingsError {
Io(std::io::Error),
Json(serde_json::Error),
}
impl fmt::Display for SettingsError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SettingsError::Io(error) => write!(f, "settings I/O error: {error}"),
SettingsError::Json(error) => write!(f, "settings JSON error: {error}"),
}
}
}
impl std::error::Error for SettingsError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
SettingsError::Io(error) => Some(error),
SettingsError::Json(error) => Some(error),
}
}
}
impl From<std::io::Error> for SettingsError {
fn from(error: std::io::Error) -> Self {
SettingsError::Io(error)
}
}
impl From<serde_json::Error> for SettingsError {
fn from(error: serde_json::Error) -> Self {
SettingsError::Json(error)
}
}
pub trait SettingsStore {
fn read(&self, key: &str) -> Option<SettingValue>;
fn write(&mut self, key: &str, value: SettingValue);
fn flush(&mut self) -> Result<(), SettingsError>;
}
pub struct Settings {
store: Box<dyn SettingsStore>,
}
impl Settings {
pub fn open(org: &str, app: &str) -> Self {
Self::with_store(Box::new(JsonSettings::new(org, app)))
}
pub fn with_store(store: Box<dyn SettingsStore>) -> Self {
Self { store }
}
pub fn get(&self, key: &str) -> Option<SettingValue> {
self.store.read(key)
}
pub fn set(&mut self, key: &str, value: SettingValue) {
self.store.write(key, value);
}
pub fn flush(&mut self) -> Result<(), SettingsError> {
self.store.flush()
}
pub fn window_geometry(&self, name: &str) -> Option<Rect> {
let key = geometry_key(name);
match self.store.read(&key)? {
SettingValue::String(text) => decode_rect(&text),
_ => None,
}
}
pub fn set_window_geometry(&mut self, name: &str, rect: Rect) {
self.store.write(&geometry_key(name), SettingValue::String(encode_rect(rect)));
}
pub fn split_ratios(&self, name: &str) -> Option<Vec<f32>> {
let key = ratios_key(name);
match self.store.read(&key)? {
SettingValue::String(text) => decode_ratios(&text),
_ => None,
}
}
pub fn set_split_ratios(&mut self, name: &str, ratios: &[f32]) {
self.store.write(&ratios_key(name), SettingValue::String(encode_ratios(ratios)));
}
}
#[derive(Debug, Clone, Default)]
pub struct MemorySettings {
values: HashMap<String, SettingValue>,
}
impl MemorySettings {
pub fn new() -> Self {
Self::default()
}
}
impl SettingsStore for MemorySettings {
fn read(&self, key: &str) -> Option<SettingValue> {
self.values.get(key).cloned()
}
fn write(&mut self, key: &str, value: SettingValue) {
self.values.insert(key.to_string(), value);
}
fn flush(&mut self) -> Result<(), SettingsError> {
Ok(())
}
}
pub struct JsonSettings {
path: PathBuf,
values: HashMap<String, SettingValue>,
}
impl JsonSettings {
pub fn new(org: &str, app: &str) -> Self {
let base = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
let path = base.join(org).join(app).join(SETTINGS_FILE_NAME);
Self::at_path(path)
}
pub fn with_dir(dir: PathBuf) -> Self {
Self::at_path(dir.join(SETTINGS_FILE_NAME))
}
pub fn at_path(path: PathBuf) -> Self {
let values = Self::load(&path);
Self { path, values }
}
pub fn path(&self) -> &Path {
&self.path
}
fn load(path: &Path) -> HashMap<String, SettingValue> {
let Ok(content) = fs::read_to_string(path) else {
return HashMap::new();
};
let Ok(serde_json::Value::Object(object)) = serde_json::from_str(&content) else {
return HashMap::new();
};
let mut values = HashMap::new();
for (key, raw) in object {
if let Some(value) = value_from_json(&raw) {
values.insert(key, value);
}
}
values
}
}
impl SettingsStore for JsonSettings {
fn read(&self, key: &str) -> Option<SettingValue> {
self.values.get(key).cloned()
}
fn write(&mut self, key: &str, value: SettingValue) {
self.values.insert(key.to_string(), value);
}
fn flush(&mut self) -> Result<(), SettingsError> {
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent)?;
}
let mut object = serde_json::Map::new();
for (key, value) in &self.values {
object.insert(key.clone(), value_to_json(value));
}
let content = serde_json::to_string_pretty(&serde_json::Value::Object(object))?;
let temp_path = self.path.with_extension("tmp");
{
let mut file = fs::File::create(&temp_path)?;
if let Err(error) = file.write_all(content.as_bytes()).and_then(|()| file.sync_all()) {
drop(file);
let _ = fs::remove_file(&temp_path);
return Err(SettingsError::Io(error));
}
}
if let Err(error) = fs::rename(&temp_path, &self.path) {
let _ = fs::remove_file(&temp_path);
return Err(SettingsError::Io(error));
}
Ok(())
}
}
fn value_to_json(value: &SettingValue) -> serde_json::Value {
let mut object = serde_json::Map::new();
match value {
SettingValue::Bool(flag) => {
object.insert("type".to_string(), serde_json::Value::String("bool".to_string()));
object.insert("value".to_string(), serde_json::Value::Bool(*flag));
}
SettingValue::I64(number) => {
object.insert("type".to_string(), serde_json::Value::String("i64".to_string()));
object.insert("value".to_string(), serde_json::Value::Number((*number).into()));
}
SettingValue::F64(number) => {
object.insert("type".to_string(), serde_json::Value::String("f64".to_string()));
match serde_json::Number::from_f64(*number) {
Some(json_number) => {
object.insert("value".to_string(), serde_json::Value::Number(json_number));
}
None => {
object
.insert("value".to_string(), serde_json::Value::String(number.to_string()));
}
}
}
SettingValue::String(text) => {
object.insert("type".to_string(), serde_json::Value::String("string".to_string()));
object.insert("value".to_string(), serde_json::Value::String(text.clone()));
}
SettingValue::Bytes(bytes) => {
object.insert("type".to_string(), serde_json::Value::String("bytes".to_string()));
let items =
bytes.iter().map(|byte| serde_json::Value::Number((*byte).into())).collect();
object.insert("value".to_string(), serde_json::Value::Array(items));
}
}
serde_json::Value::Object(object)
}
fn value_from_json(value: &serde_json::Value) -> Option<SettingValue> {
let object = value.as_object()?;
let tag = object.get("type")?.as_str()?;
let raw = object.get("value")?;
match tag {
"bool" => raw.as_bool().map(SettingValue::Bool),
"i64" => raw.as_i64().map(SettingValue::I64),
"f64" => match raw {
serde_json::Value::Number(_) => raw.as_f64().map(SettingValue::F64),
serde_json::Value::String(text) => text.parse::<f64>().ok().map(SettingValue::F64),
_ => None,
},
"string" => raw.as_str().map(|text| SettingValue::String(text.to_string())),
"bytes" => {
let items = raw.as_array()?;
let mut bytes = Vec::with_capacity(items.len());
for item in items {
bytes.push(u8::try_from(item.as_u64()?).ok()?);
}
Some(SettingValue::Bytes(bytes))
}
_ => None,
}
}
fn geometry_key(name: &str) -> String {
format!("window/{name}/geometry")
}
fn ratios_key(name: &str) -> String {
format!("splitter/{name}/ratios")
}
fn encode_rect(rect: Rect) -> String {
format!("{},{},{},{}", rect.x, rect.y, rect.width, rect.height)
}
fn decode_rect(text: &str) -> Option<Rect> {
let mut parts = text.split(',');
let x = parts.next()?.trim().parse::<i32>().ok()?;
let y = parts.next()?.trim().parse::<i32>().ok()?;
let width = parts.next()?.trim().parse::<u32>().ok()?;
let height = parts.next()?.trim().parse::<u32>().ok()?;
if parts.next().is_some() {
return None;
}
Some(Rect::new(x, y, width, height))
}
fn encode_ratios(ratios: &[f32]) -> String {
let mut text = String::new();
for (index, ratio) in ratios.iter().enumerate() {
if index > 0 {
text.push(',');
}
text.push_str(&ratio.to_string());
}
text
}
fn decode_ratios(text: &str) -> Option<Vec<f32>> {
let trimmed = text.trim();
if trimmed.is_empty() {
return Some(Vec::new());
}
let mut ratios = Vec::new();
for part in trimmed.split(',') {
ratios.push(part.trim().parse::<f32>().ok()?);
}
Some(ratios)
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_dir(tag: &str) -> PathBuf {
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|elapsed| elapsed.as_nanos())
.unwrap_or(0);
let dir = std::env::temp_dir()
.join(format!("rust-widgets-settings-{tag}-{}-{unique}", std::process::id()));
fs::create_dir_all(&dir).expect("the temporary directory is creatable");
dir
}
#[test]
fn memory_store_round_trips_window_geometry() {
let mut settings = Settings::with_store(Box::new(MemorySettings::new()));
let rect = Rect::new(12, 34, 800, 600);
assert_eq!(settings.window_geometry("main"), None);
settings.set_window_geometry("main", rect);
assert_eq!(settings.window_geometry("main"), Some(rect));
assert_eq!(
settings.get("window/main/geometry"),
Some(SettingValue::String("12,34,800,600".to_string()))
);
assert!(settings.flush().is_ok(), "the memory backend always flushes");
}
#[test]
fn memory_store_round_trips_split_ratios() {
let mut settings = Settings::with_store(Box::new(MemorySettings::new()));
let ratios = [0.25f32, 0.5, 0.25];
assert_eq!(settings.split_ratios("editor"), None);
settings.set_split_ratios("editor", &ratios);
assert_eq!(settings.split_ratios("editor"), Some(ratios.to_vec()));
assert_eq!(
settings.get("splitter/editor/ratios"),
Some(SettingValue::String("0.25,0.5,0.25".to_string()))
);
}
#[test]
fn json_settings_writes_and_rereads_identically() {
let dir = temp_dir("json-round-trip");
let path = dir.join(SETTINGS_FILE_NAME);
let rect = Rect::new(-10, 20, 1024, 768);
let ratios = [0.2f32, 0.3, 0.5];
{
let mut settings = Settings::with_store(Box::new(JsonSettings::with_dir(dir.clone())));
settings.set_window_geometry("main", rect);
settings.set_split_ratios("main", &ratios);
settings.set("ui/theme/dark", SettingValue::Bool(true));
settings.set("app/version", SettingValue::I64(7));
settings.set("app/scale", SettingValue::F64(1.25));
settings.set("app/label", SettingValue::String("Widgets".to_string()));
settings.set("app/blob", SettingValue::Bytes(vec![0, 1, 255]));
settings.flush().expect("settings are written");
}
assert!(path.exists(), "flush creates the settings file");
let settings = Settings::with_store(Box::new(JsonSettings::with_dir(dir.clone())));
assert_eq!(settings.window_geometry("main"), Some(rect));
assert_eq!(settings.split_ratios("main"), Some(ratios.to_vec()));
assert_eq!(settings.get("ui/theme/dark"), Some(SettingValue::Bool(true)));
assert_eq!(settings.get("app/version"), Some(SettingValue::I64(7)));
assert_eq!(settings.get("app/scale"), Some(SettingValue::F64(1.25)));
assert_eq!(settings.get("app/label"), Some(SettingValue::String("Widgets".to_string())));
assert_eq!(settings.get("app/blob"), Some(SettingValue::Bytes(vec![0, 1, 255])));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn corrupted_file_starts_empty_and_flush_replaces_it() {
let dir = temp_dir("corrupt");
let path = dir.join(SETTINGS_FILE_NAME);
fs::write(&path, b"{ this is not json").expect("the corrupt fixture is writable");
let mut settings = Settings::with_store(Box::new(JsonSettings::with_dir(dir.clone())));
assert_eq!(settings.get("ui/theme"), None, "a corrupt file starts empty");
settings.set("ui/theme", SettingValue::String("dark".to_string()));
settings.flush().expect("a corrupt file is replaced on flush");
let reread = Settings::with_store(Box::new(JsonSettings::with_dir(dir.clone())));
assert_eq!(reread.get("ui/theme"), Some(SettingValue::String("dark".to_string())));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn default_path_follows_the_org_app_convention() {
let expected = dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("acme")
.join("widgets")
.join(SETTINGS_FILE_NAME);
let store = JsonSettings::new("acme", "widgets");
assert_eq!(store.path(), expected);
}
}