use serde::{Deserialize, Serialize};
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use crate::ui::theme::{self, Mode};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Settings {
#[serde(default = "default_zoom")]
pub zoom: f32,
#[serde(default)]
pub mode: ModeSetting,
#[serde(default)]
pub accent: usize,
#[serde(default)]
pub colorblind: bool,
}
fn default_zoom() -> f32 {
theme::ZOOM_DEFAULT
}
impl Default for Settings {
fn default() -> Self {
Settings {
zoom: theme::ZOOM_DEFAULT,
mode: ModeSetting::default(),
accent: 0,
colorblind: false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ModeSetting {
#[default]
Dark,
Light,
}
impl From<Mode> for ModeSetting {
fn from(m: Mode) -> Self {
match m {
Mode::Dark => ModeSetting::Dark,
Mode::Light => ModeSetting::Light,
}
}
}
impl From<ModeSetting> for Mode {
fn from(m: ModeSetting) -> Self {
match m {
ModeSetting::Dark => Mode::Dark,
ModeSetting::Light => Mode::Light,
}
}
}
pub fn config_path() -> Option<PathBuf> {
keyroost_keyring::config_dir().map(|d| d.join("settings.json"))
}
impl Settings {
pub fn load() -> Settings {
match config_path() {
Some(path) => Self::load_from(&path),
None => Settings::default(),
}
}
pub fn load_from(path: &Path) -> Settings {
let mut s = match fs::read_to_string(path) {
Ok(text) => serde_json::from_str::<Settings>(&text).unwrap_or_default(),
Err(_) => Settings::default(),
};
s.zoom = theme::clamp_zoom(s.zoom);
s
}
pub fn save(&self) {
if let Some(path) = config_path() {
let _ = self.save_to(&path);
}
}
pub fn save_to(&self, path: &Path) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = fs::metadata(parent) {
let mut perms = meta.permissions();
if perms.mode() & 0o077 != 0 && parent.ends_with("keyroost") {
perms.set_mode(0o700);
let _ = fs::set_permissions(parent, perms);
}
}
}
}
let on_disk = Settings {
zoom: theme::clamp_zoom(self.zoom),
..self.clone()
};
let json = serde_json::to_string_pretty(&on_disk)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
let tmp = path.with_extension("json.tmp");
let _ = fs::remove_file(&tmp);
{
let mut opts = fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
use std::io::Write;
let mut f = opts.open(&tmp)?;
f.write_all(json.as_bytes())?;
f.write_all(b"\n")?;
f.sync_all()?;
}
fs::rename(&tmp, path)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn json_round_trip() {
let s = Settings {
zoom: 1.25,
mode: ModeSetting::Light,
accent: 2,
colorblind: true,
};
let json = serde_json::to_string_pretty(&s).unwrap();
let back: Settings = serde_json::from_str(&json).unwrap();
assert_eq!(back, s);
assert_eq!(back.mode, ModeSetting::Light);
assert_eq!(back.accent, 2);
assert!(back.colorblind);
assert_eq!(back.zoom, 1.25);
}
#[test]
fn mode_encodes_as_lowercase_strings() {
let dark = serde_json::to_string(&ModeSetting::Dark).unwrap();
let light = serde_json::to_string(&ModeSetting::Light).unwrap();
assert_eq!(dark, "\"dark\"");
assert_eq!(light, "\"light\"");
}
#[test]
fn missing_file_is_defaults() {
let s = Settings::load_from(Path::new("/nonexistent/keyroost/settings.json"));
assert_eq!(s, Settings::default());
assert_eq!(s.mode, ModeSetting::Dark);
assert_eq!(s.zoom, theme::ZOOM_DEFAULT);
}
#[test]
fn corrupt_json_is_defaults() {
let garbage: Settings = serde_json::from_str("not json {{{").unwrap_or_default();
assert_eq!(garbage, Settings::default());
let wrong_shape: Settings = serde_json::from_str("[1, 2, 3]").unwrap_or_default();
assert_eq!(wrong_shape, Settings::default());
}
#[test]
fn partial_json_fills_defaults() {
let s: Settings = serde_json::from_str(r#"{"accent": 1}"#).unwrap();
assert_eq!(s.accent, 1);
assert_eq!(s.mode, ModeSetting::Dark);
assert_eq!(s.zoom, theme::ZOOM_DEFAULT);
assert!(!s.colorblind);
}
#[test]
fn bad_zoom_is_clamped_on_load() {
let dir = std::env::temp_dir().join(format!("keyroost-settings-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("settings.json");
std::fs::write(&path, r#"{"zoom": 999.0, "mode": "light"}"#).unwrap();
let s = Settings::load_from(&path);
assert_eq!(s.zoom, theme::ZOOM_MAX);
assert_eq!(s.mode, ModeSetting::Light);
std::fs::write(&path, r#"{"zoom": -5.0}"#).unwrap();
assert_eq!(Settings::load_from(&path).zoom, theme::ZOOM_DEFAULT);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(unix)]
#[test]
fn save_creates_owner_only_file() {
use std::os::unix::fs::PermissionsExt;
let dir =
std::env::temp_dir().join(format!("keyroost-settings-perm-{}", std::process::id()));
let path = dir.join("settings.json");
let s = Settings {
zoom: 1.5,
mode: ModeSetting::Light,
accent: 1,
colorblind: true,
};
s.save_to(&path).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600, "settings.json must be owner-only");
assert_eq!(Settings::load_from(&path), s);
assert!(!path.with_extension("json.tmp").exists());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn settings_path_is_config_dir_plus_settings_json() {
match keyroost_keyring::config_dir() {
Some(dir) => assert_eq!(config_path(), Some(dir.join("settings.json"))),
None => assert_eq!(config_path(), None),
}
}
#[test]
fn settings_path_honours_an_xdg_override() {
let dir = keyroost_keyring::config_dir_from(
Some(std::ffi::OsStr::new("/tmp/keyroost-xdg-cfg")),
None,
)
.expect("an explicit XDG value always resolves");
assert_eq!(
dir.join("settings.json"),
PathBuf::from("/tmp/keyroost-xdg-cfg")
.join("keyroost")
.join("settings.json")
);
}
}