use std::sync::Arc;
use std::time::Duration;
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::{Map, Value};
use crate::backend::ConfigStore;
pub type SettingsMigration = fn(&mut Map<String, Value>);
pub const MAX_SETTINGS_BYTES: usize = 16 << 20;
pub const DEFAULT_DEBOUNCE: Duration = Duration::from_millis(500);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LoadState {
Fresh,
Loaded { from: u64 },
Corrupt,
TooNew { found: u64 },
}
pub type SettingsWake = Arc<dyn Fn(Duration) + Send + Sync>;
const DUE_SLACK: Duration = Duration::from_millis(2);
pub struct Settings {
store: Arc<dyn ConfigStore>,
name: String,
schema_version: u64,
values: Map<String, Value>,
state: LoadState,
debounce: Duration,
generation: u64,
saved_gen: u64,
seen: Option<(u64, Duration)>,
last_tick: Option<Duration>,
wake: Option<SettingsWake>,
}
impl std::fmt::Debug for Settings {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Settings").field("name", &self.name).field("state", &self.state).field("values", &self.values).finish()
}
}
impl Settings {
pub fn load(store: Arc<dyn ConfigStore>, name: &str, migrations: &[SettingsMigration]) -> Settings {
let schema_version = migrations.len() as u64 + 1;
let mut s = Settings {
store,
name: name.to_string(),
schema_version,
values: Map::new(),
state: LoadState::Fresh,
debounce: DEFAULT_DEBOUNCE,
generation: 0,
saved_gen: 0,
seen: None,
last_tick: None,
wake: None,
};
let Some(text) = s.store.read(name) else { return s };
match parse(&text) {
Some((found, values)) if found > schema_version => {
log::warn!("settings `{name}` are from a newer schema (v{found} > v{schema_version}); they will not be saved");
s.values = values;
s.state = LoadState::TooNew { found };
}
Some((found, mut values)) => {
for m in migrations.iter().skip(found.saturating_sub(1) as usize) {
m(&mut values);
}
s.values = values;
s.state = LoadState::Loaded { from: found };
if found < schema_version {
s.generation = 1; }
}
None => {
log::warn!("settings `{name}` are unreadable; moved to `{name}.corrupt`");
let _ = s.store.write(&format!("{name}.corrupt"), &text);
s.state = LoadState::Corrupt;
}
}
s
}
pub fn with_debounce(mut self, debounce: Duration) -> Settings {
self.debounce = debounce;
self
}
pub fn set_wake(&mut self, wake: impl Fn(Duration) + Send + Sync + 'static) {
self.wake = Some(Arc::new(wake));
}
fn wake(&self, delay: Duration) {
if let Some(w) = &self.wake {
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| w(delay)));
}
}
pub fn state(&self) -> LoadState {
self.state
}
pub fn schema_version(&self) -> u64 {
self.schema_version
}
pub fn get_value(&self, key: &str) -> Option<&Value> {
self.values.get(key)
}
pub fn get<T: DeserializeOwned>(&self, key: &str) -> Option<T> {
T::deserialize(self.values.get(key)?).ok()
}
pub fn get_or<T: DeserializeOwned>(&self, key: &str, default: T) -> T {
self.get(key).unwrap_or(default)
}
pub fn set_value(&mut self, key: &str, value: Value) {
if self.values.get(key) != Some(&value) {
self.values.insert(key.to_string(), value);
self.changed();
}
}
pub fn set<T: Serialize + ?Sized>(&mut self, key: &str, value: &T) -> Result<(), serde_json::Error> {
let v = serde_json::to_value(value)?;
self.set_value(key, v);
Ok(())
}
pub fn remove(&mut self, key: &str) -> Option<Value> {
let old = self.values.remove(key);
if old.is_some() {
self.changed();
}
old
}
pub fn keys(&self) -> impl Iterator<Item = &str> {
self.values.keys().map(String::as_str)
}
fn changed(&mut self) {
self.generation = self.generation.wrapping_add(1);
self.wake(self.debounce);
}
pub fn is_dirty(&self) -> bool {
self.generation != self.saved_gen
}
pub fn tick(&mut self, now: Duration) -> std::io::Result<bool> {
let last = self.last_tick.replace(now);
if !self.is_dirty() {
self.seen = None;
return Ok(false);
}
match self.seen {
Some((g, since)) if g == self.generation => {
let waited = now.saturating_sub(since).saturating_add(DUE_SLACK);
if waited >= self.debounce {
self.flush()?;
return Ok(true);
}
}
_ => {
let since = last.filter(|l| *l <= now).unwrap_or(now);
self.seen = Some((self.generation, since));
if last.is_none() {
self.wake(self.debounce);
}
}
}
Ok(false)
}
pub fn flush(&mut self) -> std::io::Result<()> {
if !self.is_dirty() {
return Ok(());
}
if matches!(self.state, LoadState::TooNew { .. }) {
self.saved_gen = self.generation;
return Ok(());
}
let doc = serde_json::json!({ "schema_version": self.schema_version, "values": self.values });
let text = serde_json::to_string_pretty(&doc).map_err(std::io::Error::other)?;
self.store.write(&self.name, &text)?;
self.saved_gen = self.generation;
self.seen = None;
Ok(())
}
}
fn parse(text: &str) -> Option<(u64, Map<String, Value>)> {
if text.len() > MAX_SETTINGS_BYTES {
return None;
}
let Value::Object(mut o) = serde_json::from_str::<Value>(text).ok()? else { return None };
let version = o.get("schema_version")?.as_u64()?;
if version == 0 {
return None;
}
let Value::Object(values) = o.remove("values")? else { return None };
Some((version, values))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::MemoryConfig;
fn mem() -> Arc<dyn ConfigStore> {
Arc::new(MemoryConfig::new())
}
const MS: Duration = Duration::from_millis(1);
#[test]
fn typed_get_set_remove_keys() {
let mut s = Settings::load(mem(), "settings.json", &[]);
assert_eq!(s.state(), LoadState::Fresh);
s.set("editor.font_size", &14u32).unwrap();
s.set("theme", "dark").unwrap();
s.set("list", &vec![1, 2]).unwrap();
assert_eq!(s.get::<u32>("editor.font_size"), Some(14));
assert_eq!(s.get::<String>("editor.font_size"), None, "wrong type is None");
assert_eq!(s.get_or("missing", 7u8), 7);
assert_eq!(s.keys().collect::<Vec<_>>(), vec!["editor.font_size", "list", "theme"]);
assert_eq!(s.remove("theme"), Some(Value::from("dark")));
assert_eq!(s.remove("theme"), None);
assert!(s.set("bad", &f64::NAN).is_ok(), "NaN serializes to null");
let mut m = std::collections::HashMap::new();
m.insert((1, 2), 3);
assert!(s.set("bad", &m).is_err());
}
#[test]
fn debounced_save_waits_for_quiet() {
let store = mem();
let mut s = Settings::load(store.clone(), "s.json", &[]).with_debounce(100 * MS);
assert!(!s.tick(Duration::ZERO).unwrap());
s.set("a", &1).unwrap();
assert!(!s.tick(Duration::ZERO).unwrap());
assert!(!s.tick(50 * MS).unwrap());
s.set("a", &2).unwrap(); assert!(!s.tick(60 * MS).unwrap());
assert!(!s.tick(140 * MS).unwrap());
assert!(store.read("s.json").is_none());
assert!(s.tick(150 * MS).unwrap());
assert!(!s.is_dirty());
let back = Settings::load(store, "s.json", &[]);
assert_eq!(back.get::<i32>("a"), Some(2));
assert_eq!(back.state(), LoadState::Loaded { from: 1 });
}
#[test]
fn the_wake_hook_says_when_the_next_tick_is_due() {
let store = mem();
let asked = Arc::new(std::sync::Mutex::new(Vec::new()));
let mut s = Settings::load(store.clone(), "s.json", &[]).with_debounce(100 * MS);
let a = Arc::clone(&asked);
s.set_wake(move |d| a.lock().unwrap().push(d));
assert!(!s.tick(Duration::ZERO).unwrap());
assert!(asked.lock().unwrap().is_empty(), "nothing pending, nothing asked");
s.set("a", &1).unwrap();
assert_eq!(*asked.lock().unwrap(), vec![100 * MS], "a change asks for a tick after the debounce");
assert!(!s.tick(10 * MS).unwrap());
assert!(!s.tick(40 * MS).unwrap());
assert_eq!(asked.lock().unwrap().len(), 1, "the save is asked for once, when the change happens, not per tick");
assert!(s.tick(110 * MS).unwrap());
assert!(store.read("s.json").is_some());
let n = asked.lock().unwrap().len();
assert!(!s.tick(500 * MS).unwrap());
assert_eq!(asked.lock().unwrap().len(), n, "a clean tick asks for nothing");
s.set_wake(|_| panic!("hook"));
s.set("a", &2).unwrap();
assert!(s.is_dirty());
}
#[test]
fn unchanged_set_is_not_a_change() {
let mut s = Settings::load(mem(), "s.json", &[]);
s.set("a", &1).unwrap();
s.flush().unwrap();
s.set("a", &1).unwrap();
assert!(!s.is_dirty());
}
#[test]
fn corrupt_file_is_moved_aside() {
for bad in [
"{",
"",
"[]",
"null",
r#"{"values":{}}"#,
r#"{"schema_version":0,"values":{}}"#,
r#"{"schema_version":"1","values":{}}"#,
r#"{"schema_version":1,"values":[]}"#,
] {
let store = mem();
store.write("s.json", bad).unwrap();
let s = Settings::load(store.clone(), "s.json", &[]);
assert_eq!(s.state(), LoadState::Corrupt, "{bad}");
assert_eq!(store.read("s.json.corrupt").as_deref(), Some(bad));
assert_eq!(s.keys().count(), 0);
}
}
#[test]
fn newer_schema_is_read_but_never_overwritten() {
let store = mem();
let newer = r#"{"schema_version":18446744073709551615,"values":{"a":1}}"#;
store.write("s.json", newer).unwrap();
let mut s = Settings::load(store.clone(), "s.json", &[]);
assert_eq!(s.state(), LoadState::TooNew { found: u64::MAX });
assert_eq!(s.get::<i32>("a"), Some(1));
s.set("a", &2).unwrap();
s.flush().unwrap();
assert_eq!(store.read("s.json").as_deref(), Some(newer));
assert!(!s.is_dirty());
}
#[test]
fn migrations_run_from_the_file_version() {
fn v1_to_v2(m: &mut Map<String, Value>) {
if let Some(v) = m.remove("fontSize") {
m.insert("font_size".into(), v);
}
}
fn v2_to_v3(m: &mut Map<String, Value>) {
m.entry("theme").or_insert(Value::from("light"));
}
let store = mem();
store.write("s.json", r#"{"schema_version":1,"values":{"fontSize":12}}"#).unwrap();
let mut s = Settings::load(store.clone(), "s.json", &[v1_to_v2, v2_to_v3]);
assert_eq!(s.state(), LoadState::Loaded { from: 1 });
assert_eq!(s.get::<i32>("font_size"), Some(12));
assert_eq!(s.get::<String>("theme").as_deref(), Some("light"));
assert!(s.is_dirty(), "upgraded file is saved");
s.flush().unwrap();
let v: Value = serde_json::from_str(&store.read("s.json").unwrap()).unwrap();
assert_eq!(v["schema_version"], 3);
store.write("s.json", r#"{"schema_version":2,"values":{"fontSize":9}}"#).unwrap();
let s = Settings::load(store, "s.json", &[v1_to_v2, v2_to_v3]);
assert_eq!(s.get::<i32>("fontSize"), Some(9), "v1→v2 skipped for a v2 file");
}
}