Skip to main content

ashlar_fs/
settings.rs

1//! Keyed JSON settings with a schema version and a debounced, durable save.
2//!
3//! The file is `{"schema_version": N, "values": {"key": value, …}}`. Keys are flat strings (use
4//! dotted names such as `"editor.font_size"` for grouping). Changes are saved by [`Settings::tick`]
5//! once no change has happened for the debounce period, or at once by [`Settings::flush`] (call it
6//! on exit). The clock is the caller's (a monotonic `Duration` since any fixed start), so the
7//! type works the same on the web, in tests and on desktop. An event-driven host sets a wake hook
8//! ([`Settings::set_wake`]) instead of ticking on a timer: it is told when the next tick is due.
9//!
10//! Loading never fails: a missing file gives empty settings, an unreadable one is moved aside to
11//! `<name>.corrupt` and replaced by empty settings, and a file from a newer schema is read but
12//! never overwritten (so going back to an older build doesn't destroy a newer one's settings).
13
14use std::sync::Arc;
15use std::time::Duration;
16
17use serde::Serialize;
18use serde::de::DeserializeOwned;
19use serde_json::{Map, Value};
20
21use crate::backend::ConfigStore;
22
23/// Upgrades the values from schema `n` to `n + 1`.
24pub type SettingsMigration = fn(&mut Map<String, Value>);
25
26/// Largest settings file read (bigger is treated as corrupt).
27pub const MAX_SETTINGS_BYTES: usize = 16 << 20;
28
29/// Quiet period before a change is saved, unless set with [`Settings::with_debounce`].
30pub const DEFAULT_DEBOUNCE: Duration = Duration::from_millis(500);
31
32/// How the settings were loaded.
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub enum LoadState {
35    /// No file yet.
36    Fresh,
37    /// Read (and migrated if `from` is older than the current schema).
38    Loaded { from: u64 },
39    /// The file could not be read; it was moved aside to `<name>.corrupt`.
40    Corrupt,
41    /// The file is from a newer schema: values are readable, saves are skipped.
42    TooNew { found: u64 },
43}
44
45/// Asks the owner to call [`Settings::tick`] again after the given delay.
46pub type SettingsWake = Arc<dyn Fn(Duration) + Send + Sync>;
47
48/// A tick this close to the end of the debounce counts as due (wakes start a frame a hair early).
49const DUE_SLACK: Duration = Duration::from_millis(2);
50
51/// Keyed settings persisted in a [`ConfigStore`].
52pub struct Settings {
53    store: Arc<dyn ConfigStore>,
54    name: String,
55    schema_version: u64,
56    values: Map<String, Value>,
57    state: LoadState,
58    debounce: Duration,
59    /// Bumped by every change; `saved_gen` is the one on disk.
60    generation: u64,
61    saved_gen: u64,
62    /// The generation seen by the last tick and when its quiet period started.
63    seen: Option<(u64, Duration)>,
64    /// `now` of the last tick: a change between ticks is dated to it, so the wake the change asks
65    /// for (`debounce` after the change) never comes before the save is due.
66    last_tick: Option<Duration>,
67    wake: Option<SettingsWake>,
68}
69
70impl std::fmt::Debug for Settings {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        f.debug_struct("Settings").field("name", &self.name).field("state", &self.state).field("values", &self.values).finish()
73    }
74}
75
76impl Settings {
77    /// Load `name` from `store`. `migrations[i]` upgrades schema `i + 1` to `i + 2`; the current
78    /// schema is `migrations.len() + 1`.
79    pub fn load(store: Arc<dyn ConfigStore>, name: &str, migrations: &[SettingsMigration]) -> Settings {
80        let schema_version = migrations.len() as u64 + 1;
81        let mut s = Settings {
82            store,
83            name: name.to_string(),
84            schema_version,
85            values: Map::new(),
86            state: LoadState::Fresh,
87            debounce: DEFAULT_DEBOUNCE,
88            generation: 0,
89            saved_gen: 0,
90            seen: None,
91            last_tick: None,
92            wake: None,
93        };
94        let Some(text) = s.store.read(name) else { return s };
95        match parse(&text) {
96            Some((found, values)) if found > schema_version => {
97                log::warn!("settings `{name}` are from a newer schema (v{found} > v{schema_version}); they will not be saved");
98                s.values = values;
99                s.state = LoadState::TooNew { found };
100            }
101            Some((found, mut values)) => {
102                for m in migrations.iter().skip(found.saturating_sub(1) as usize) {
103                    m(&mut values);
104                }
105                s.values = values;
106                s.state = LoadState::Loaded { from: found };
107                if found < schema_version {
108                    s.generation = 1; // save the upgraded file
109                }
110            }
111            None => {
112                log::warn!("settings `{name}` are unreadable; moved to `{name}.corrupt`");
113                let _ = s.store.write(&format!("{name}.corrupt"), &text);
114                s.state = LoadState::Corrupt;
115            }
116        }
117        s
118    }
119
120    /// Change the debounce period.
121    pub fn with_debounce(mut self, debounce: Duration) -> Settings {
122        self.debounce = debounce;
123        self
124    }
125
126    /// Be told when a save is pending: `wake(delay)` asks for a [`Settings::tick`] after `delay`.
127    /// It is called on every change (with the debounce period) and by every tick that finds a
128    /// save pending but not yet due (with the time left), so a host that ticks once per frame
129    /// and turns each call into one timed frame request never polls. A failed save is not
130    /// rescheduled: the next change, tick or [`Settings::flush`] retries it.
131    pub fn set_wake(&mut self, wake: impl Fn(Duration) + Send + Sync + 'static) {
132        self.wake = Some(Arc::new(wake));
133    }
134
135    fn wake(&self, delay: Duration) {
136        if let Some(w) = &self.wake {
137            // the host's hook must not take the settings owner down
138            let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| w(delay)));
139        }
140    }
141
142    pub fn state(&self) -> LoadState {
143        self.state
144    }
145
146    pub fn schema_version(&self) -> u64 {
147        self.schema_version
148    }
149
150    // ------------------------------------------------------------------ values
151
152    /// The raw value of `key`.
153    pub fn get_value(&self, key: &str) -> Option<&Value> {
154        self.values.get(key)
155    }
156
157    /// `key` as a `T`; `None` when missing or of another shape.
158    pub fn get<T: DeserializeOwned>(&self, key: &str) -> Option<T> {
159        T::deserialize(self.values.get(key)?).ok()
160    }
161
162    /// `key` as a `T`, or `default` when missing or of another shape.
163    pub fn get_or<T: DeserializeOwned>(&self, key: &str, default: T) -> T {
164        self.get(key).unwrap_or(default)
165    }
166
167    /// Set the raw value of `key` (a no-op when unchanged).
168    pub fn set_value(&mut self, key: &str, value: Value) {
169        if self.values.get(key) != Some(&value) {
170            self.values.insert(key.to_string(), value);
171            self.changed();
172        }
173    }
174
175    /// Set `key` to `value`. Fails only when `value` can't be represented as JSON (a map with
176    /// non-string keys, a non-finite float…).
177    pub fn set<T: Serialize + ?Sized>(&mut self, key: &str, value: &T) -> Result<(), serde_json::Error> {
178        let v = serde_json::to_value(value)?;
179        self.set_value(key, v);
180        Ok(())
181    }
182
183    /// Remove `key`; returns its old value.
184    pub fn remove(&mut self, key: &str) -> Option<Value> {
185        let old = self.values.remove(key);
186        if old.is_some() {
187            self.changed();
188        }
189        old
190    }
191
192    /// Every key, sorted.
193    pub fn keys(&self) -> impl Iterator<Item = &str> {
194        self.values.keys().map(String::as_str)
195    }
196
197    fn changed(&mut self) {
198        self.generation = self.generation.wrapping_add(1);
199        self.wake(self.debounce);
200    }
201
202    // ------------------------------------------------------------------ saving
203
204    /// Unsaved changes exist.
205    pub fn is_dirty(&self) -> bool {
206        self.generation != self.saved_gen
207    }
208
209    /// Save if there are changes and none happened for the debounce period. Call it every frame or
210    /// on a timer with a monotonic `now`. Returns `Ok(true)` when it wrote.
211    pub fn tick(&mut self, now: Duration) -> std::io::Result<bool> {
212        let last = self.last_tick.replace(now);
213        if !self.is_dirty() {
214            self.seen = None;
215            return Ok(false);
216        }
217        match self.seen {
218            Some((g, since)) if g == self.generation => {
219                // the frame asked for may start a little early (clock rounding): within a couple of
220                // milliseconds counts as due
221                let waited = now.saturating_sub(since).saturating_add(DUE_SLACK);
222                if waited >= self.debounce {
223                    self.flush()?;
224                    return Ok(true);
225                }
226                // asked when the save was scheduled; the host keeps that wake until it is due
227            }
228            // a change since the last tick (it asked for its wake when it happened)
229            _ => {
230                let since = last.filter(|l| *l <= now).unwrap_or(now);
231                self.seen = Some((self.generation, since));
232                if last.is_none() {
233                    self.wake(self.debounce);
234                }
235            }
236        }
237        Ok(false)
238    }
239
240    /// Save now if there are changes (on exit). A file from a newer schema is never overwritten.
241    pub fn flush(&mut self) -> std::io::Result<()> {
242        if !self.is_dirty() {
243            return Ok(());
244        }
245        if matches!(self.state, LoadState::TooNew { .. }) {
246            self.saved_gen = self.generation;
247            return Ok(());
248        }
249        let doc = serde_json::json!({ "schema_version": self.schema_version, "values": self.values });
250        let text = serde_json::to_string_pretty(&doc).map_err(std::io::Error::other)?;
251        self.store.write(&self.name, &text)?;
252        self.saved_gen = self.generation;
253        self.seen = None;
254        Ok(())
255    }
256}
257
258fn parse(text: &str) -> Option<(u64, Map<String, Value>)> {
259    if text.len() > MAX_SETTINGS_BYTES {
260        return None;
261    }
262    let Value::Object(mut o) = serde_json::from_str::<Value>(text).ok()? else { return None };
263    let version = o.get("schema_version")?.as_u64()?;
264    if version == 0 {
265        return None;
266    }
267    let Value::Object(values) = o.remove("values")? else { return None };
268    Some((version, values))
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use crate::backend::MemoryConfig;
275
276    fn mem() -> Arc<dyn ConfigStore> {
277        Arc::new(MemoryConfig::new())
278    }
279
280    const MS: Duration = Duration::from_millis(1);
281
282    #[test]
283    fn typed_get_set_remove_keys() {
284        let mut s = Settings::load(mem(), "settings.json", &[]);
285        assert_eq!(s.state(), LoadState::Fresh);
286        s.set("editor.font_size", &14u32).unwrap();
287        s.set("theme", "dark").unwrap();
288        s.set("list", &vec![1, 2]).unwrap();
289        assert_eq!(s.get::<u32>("editor.font_size"), Some(14));
290        assert_eq!(s.get::<String>("editor.font_size"), None, "wrong type is None");
291        assert_eq!(s.get_or("missing", 7u8), 7);
292        assert_eq!(s.keys().collect::<Vec<_>>(), vec!["editor.font_size", "list", "theme"]);
293        assert_eq!(s.remove("theme"), Some(Value::from("dark")));
294        assert_eq!(s.remove("theme"), None);
295        assert!(s.set("bad", &f64::NAN).is_ok(), "NaN serializes to null");
296        let mut m = std::collections::HashMap::new();
297        m.insert((1, 2), 3);
298        assert!(s.set("bad", &m).is_err());
299    }
300
301    #[test]
302    fn debounced_save_waits_for_quiet() {
303        let store = mem();
304        let mut s = Settings::load(store.clone(), "s.json", &[]).with_debounce(100 * MS);
305        assert!(!s.tick(Duration::ZERO).unwrap());
306        s.set("a", &1).unwrap();
307        assert!(!s.tick(Duration::ZERO).unwrap());
308        assert!(!s.tick(50 * MS).unwrap());
309        s.set("a", &2).unwrap(); // restarts the quiet period, dated to the last tick (50 ms)
310        assert!(!s.tick(60 * MS).unwrap());
311        assert!(!s.tick(140 * MS).unwrap());
312        assert!(store.read("s.json").is_none());
313        assert!(s.tick(150 * MS).unwrap());
314        assert!(!s.is_dirty());
315        let back = Settings::load(store, "s.json", &[]);
316        assert_eq!(back.get::<i32>("a"), Some(2));
317        assert_eq!(back.state(), LoadState::Loaded { from: 1 });
318    }
319
320    #[test]
321    fn the_wake_hook_says_when_the_next_tick_is_due() {
322        let store = mem();
323        let asked = Arc::new(std::sync::Mutex::new(Vec::new()));
324        let mut s = Settings::load(store.clone(), "s.json", &[]).with_debounce(100 * MS);
325        let a = Arc::clone(&asked);
326        s.set_wake(move |d| a.lock().unwrap().push(d));
327        assert!(!s.tick(Duration::ZERO).unwrap());
328        assert!(asked.lock().unwrap().is_empty(), "nothing pending, nothing asked");
329        s.set("a", &1).unwrap();
330        assert_eq!(*asked.lock().unwrap(), vec![100 * MS], "a change asks for a tick after the debounce");
331        assert!(!s.tick(10 * MS).unwrap());
332        assert!(!s.tick(40 * MS).unwrap());
333        assert_eq!(asked.lock().unwrap().len(), 1, "the save is asked for once, when the change happens, not per tick");
334        // driving ticks only when asked saves on time
335        assert!(s.tick(110 * MS).unwrap());
336        assert!(store.read("s.json").is_some());
337        let n = asked.lock().unwrap().len();
338        assert!(!s.tick(500 * MS).unwrap());
339        assert_eq!(asked.lock().unwrap().len(), n, "a clean tick asks for nothing");
340        // a panicking hook is contained
341        s.set_wake(|_| panic!("hook"));
342        s.set("a", &2).unwrap();
343        assert!(s.is_dirty());
344    }
345
346    #[test]
347    fn unchanged_set_is_not_a_change() {
348        let mut s = Settings::load(mem(), "s.json", &[]);
349        s.set("a", &1).unwrap();
350        s.flush().unwrap();
351        s.set("a", &1).unwrap();
352        assert!(!s.is_dirty());
353    }
354
355    #[test]
356    fn corrupt_file_is_moved_aside() {
357        for bad in [
358            "{",
359            "",
360            "[]",
361            "null",
362            r#"{"values":{}}"#,
363            r#"{"schema_version":0,"values":{}}"#,
364            r#"{"schema_version":"1","values":{}}"#,
365            r#"{"schema_version":1,"values":[]}"#,
366        ] {
367            let store = mem();
368            store.write("s.json", bad).unwrap();
369            let s = Settings::load(store.clone(), "s.json", &[]);
370            assert_eq!(s.state(), LoadState::Corrupt, "{bad}");
371            assert_eq!(store.read("s.json.corrupt").as_deref(), Some(bad));
372            assert_eq!(s.keys().count(), 0);
373        }
374    }
375
376    #[test]
377    fn newer_schema_is_read_but_never_overwritten() {
378        let store = mem();
379        let newer = r#"{"schema_version":18446744073709551615,"values":{"a":1}}"#;
380        store.write("s.json", newer).unwrap();
381        let mut s = Settings::load(store.clone(), "s.json", &[]);
382        assert_eq!(s.state(), LoadState::TooNew { found: u64::MAX });
383        assert_eq!(s.get::<i32>("a"), Some(1));
384        s.set("a", &2).unwrap();
385        s.flush().unwrap();
386        assert_eq!(store.read("s.json").as_deref(), Some(newer));
387        assert!(!s.is_dirty());
388    }
389
390    #[test]
391    fn migrations_run_from_the_file_version() {
392        fn v1_to_v2(m: &mut Map<String, Value>) {
393            if let Some(v) = m.remove("fontSize") {
394                m.insert("font_size".into(), v);
395            }
396        }
397        fn v2_to_v3(m: &mut Map<String, Value>) {
398            m.entry("theme").or_insert(Value::from("light"));
399        }
400        let store = mem();
401        store.write("s.json", r#"{"schema_version":1,"values":{"fontSize":12}}"#).unwrap();
402        let mut s = Settings::load(store.clone(), "s.json", &[v1_to_v2, v2_to_v3]);
403        assert_eq!(s.state(), LoadState::Loaded { from: 1 });
404        assert_eq!(s.get::<i32>("font_size"), Some(12));
405        assert_eq!(s.get::<String>("theme").as_deref(), Some("light"));
406        assert!(s.is_dirty(), "upgraded file is saved");
407        s.flush().unwrap();
408        let v: Value = serde_json::from_str(&store.read("s.json").unwrap()).unwrap();
409        assert_eq!(v["schema_version"], 3);
410
411        store.write("s.json", r#"{"schema_version":2,"values":{"fontSize":9}}"#).unwrap();
412        let s = Settings::load(store, "s.json", &[v1_to_v2, v2_to_v3]);
413        assert_eq!(s.get::<i32>("fontSize"), Some(9), "v1→v2 skipped for a v2 file");
414    }
415}