Skip to main content

teksilo_settings/
store.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Dynamic, dotted-key K/V store backed by TOML.
5//!
6//! [`SettingsStore`] is the QSettings analogue: callers ask for any
7//! dotted key with a type; the store returns a cached `Signal<T>` whose
8//! mutations write back into an in-memory `toml::Value` and schedule a
9//! debounced flush to disk.
10//!
11//! Keys carry static names via [`SettingsKey<T>`], or are passed as
12//! ad-hoc strings via [`SettingsStore::signal`]. Same key, same type,
13//! across any number of call sites returns clones of the same `Signal`.
14//!
15//! ## When to use
16//!
17//! Use `SettingsStore` for **scalar and array-of-scalar** preferences
18//! (numbers, strings, booleans, `Vec<String>`). It is the right choice
19//! for the majority of user-facing prefs that have a flat, well-known key
20//! name. For rich structs with migrations, use
21//! [`SettingsFile<T>`](crate::SettingsFile) instead — struct values
22//! serialize as TOML tables and collide with the dotted-key model.
23//!
24//! ## Invariants enforced at registration
25//!
26//! * **Type stability** — once a key has been registered with type
27//!   `T`, calling `signal::<U>` on the same key panics. Settings are
28//!   programmer-named; type drift is a code bug, surfaced immediately.
29//! * **No path-shape collisions** — `"editor.font_size"` cannot coexist
30//!   with `"editor"` as a leaf value, in either order. Both directions
31//!   panic at the call site that creates the conflict.
32//!
33//! ## Merging by dirty key, not by whole-document overwrite
34//!
35//! Every `Signal<T>::set` schedules a [`crate::flush::Patch`] that carries
36//! only the keys dirtied since the last schedule — never a full render of
37//! `raw`. The patch, applied at flush time against the document read fresh
38//! off disk under a lock, `write_nested`s just those keys onto it — so a
39//! peer process's change to some *other* key survives. This is the fix for
40//! Skribisto's `general.toml`: today, changing any one of its 26 keys
41//! reverts every other key a peer process changed, because the whole
42//! document gets re-serialized from an increasingly stale in-memory copy.
43//!
44//! ## Reload and the re-entrancy guard
45//!
46//! [`Reloadable::reload_from_disk`]
47//! pushes a peer's on-disk change straight into the already-handed-out
48//! `Signal<T>` for that key — see [`SignalCell::apply_external`]'s doc
49//! comment for why that requires capturing the concrete `T` at
50//! registration time. Setting a signal from a reload would otherwise
51//! re-trigger this same write-back observer and bounce the value straight
52//! back out to disk as if it were a local edit; `StoreInner::applying_external`
53//! is the flag the observer checks to short-circuit that.
54//!
55//! ## Cycle-free observer wiring
56//!
57//! The cell each key owns includes an [`ObserverHandle`] returned by
58//! `signal.observe(|new_val| …)`. The observer's closure captures a
59//! `Weak<RefCell<StoreInner>>` — never a strong `Rc` — and bails when
60//! the store has already been dropped. This avoids a reference cycle: a
61//! strong capture would trap the entire store inside its own observer,
62//! leaking for the life of the process.
63//!
64//! ## Example
65//!
66//! ```ignore
67//! use teksilo_settings::{SettingsKey, SettingsStore};
68//! use std::time::Duration;
69//!
70//! // Declare a typed, statically-named key once — typically at the module level.
71//! const FONT_SIZE: SettingsKey<f32> = SettingsKey::new("editor.font_size", || 14.0);
72//!
73//! // Open the store (uses `tempfile` in tests, a real path in production).
74//! let store = SettingsStore::open_with_delay(
75//!     "settings.toml".into(),
76//!     Duration::from_millis(500),
77//! )?;
78//!
79//! // Each call for the same key returns a clone of the same Signal<T>.
80//! let font_size = store.signal_for(&FONT_SIZE); // Signal<f32>, seeded from disk
81//! font_size.set(18.0);                          // writes back to TOML on next flush
82//! store.flush_now()?;                           // force sync (useful in tests)
83//! # Ok::<(), teksilo_settings::SettingsStoreError>(())
84//! ```
85
86use std::any::{Any, TypeId};
87use std::cell::{Cell, RefCell};
88use std::collections::HashMap;
89use std::fs;
90use std::io;
91use std::path::{Path, PathBuf};
92use std::rc::{Rc, Weak};
93use std::time::{Duration, SystemTime};
94
95use serde::Serialize;
96use serde::de::DeserializeOwned;
97
98use teksilo_core::ObserverHandle;
99use teksilo_core::signal::Signal;
100
101use crate::file::{SettingsFileError, disk_stamp};
102use crate::flush::{DebouncedWriter, FlushError, Patch};
103use crate::reload::Reloadable;
104
105/// Default debounce window for store flushes.
106pub const DEFAULT_DEBOUNCE: Duration = Duration::from_millis(500);
107
108/// Errors surfaced by [`SettingsStore::open`].
109#[derive(Debug, thiserror::Error)]
110pub enum SettingsStoreError {
111    /// The settings file could not be read or written (missing directory,
112    /// permission denied, etc.).
113    #[error("settings store I/O: {0}")]
114    Io(#[from] io::Error),
115    /// The settings file exists but its contents are not valid TOML.
116    #[error("settings store parse: {0}")]
117    Parse(#[source] toml::de::Error),
118    /// An attempt to flush the in-memory state to disk failed.
119    #[error("settings store flush: {0}")]
120    Flush(#[source] FlushError),
121}
122
123/// Lets [`SettingsStore::reload_from_disk`] surface a `SettingsFileError`
124/// via `?`, since [`crate::Reloadable`] is shared across every persisted
125/// type in this crate and standardizes on that error type.
126impl From<SettingsStoreError> for SettingsFileError {
127    fn from(e: SettingsStoreError) -> Self {
128        match e {
129            SettingsStoreError::Io(e) => SettingsFileError::Io(e),
130            SettingsStoreError::Parse(e) => SettingsFileError::Parse(e),
131            SettingsStoreError::Flush(e) => SettingsFileError::Flush(e),
132        }
133    }
134}
135
136/// A statically-named setting. Centralizes the dotted key, the value
137/// type, and the default factory. Construct as a `const`:
138///
139/// ```
140/// use teksilo_settings::SettingsKey;
141///
142/// const FONT_SIZE: SettingsKey<f32> =
143///     SettingsKey::new("editor.font_size", || 14.0);
144/// ```
145pub struct SettingsKey<T: 'static> {
146    /// The dotted TOML path used to look up this setting (e.g. `"editor.font_size"`).
147    pub key: &'static str,
148    /// Factory that produces the default value when the key is absent from disk.
149    pub default: fn() -> T,
150}
151
152impl<T: 'static> SettingsKey<T> {
153    /// Create a new key descriptor; intended for use in `const` declarations.
154    pub const fn new(key: &'static str, default: fn() -> T) -> Self {
155        Self { key, default }
156    }
157}
158
159/// Persisted user-controlled global text-scale factor (`1.0` = 100 %).
160///
161/// Read at startup by `teksilo-app` to seed every window's text scale, and
162/// bound by the `TextScaleControl` widget so edits persist. The key accepts
163/// any `f32`; the UI control restricts the user-facing range to 80 %–200 %.
164/// The effective rendered scale is this value multiplied by the OS
165/// accessibility text-scale preference.
166pub const TEXT_SCALE_KEY: SettingsKey<f32> =
167    SettingsKey::new("accessibility.text_scale", || 1.0_f32);
168
169impl<T: 'static> std::fmt::Debug for SettingsKey<T> {
170    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171        f.debug_struct("SettingsKey")
172            .field("key", &self.key)
173            .field("type", &std::any::type_name::<T>())
174            .finish()
175    }
176}
177
178/// One cached Signal per registered key.
179struct SignalCell {
180    type_id: TypeId,
181    type_name: &'static str,
182    signal: Box<dyn Any>,
183    /// Pushes a freshly-parsed `toml::Value` for this key into the live
184    /// `Signal<T>` this cell wraps — captured at [`SettingsStore::signal`]
185    /// registration time, which is the **only** place both the concrete
186    /// `T` (needed to deserialize) and the live `Signal<T>` (needed to
187    /// `.set()`) are simultaneously in scope. `signal` above is type-erased
188    /// (`Box<dyn Any>`) specifically so one `HashMap` can hold every key's
189    /// differently-typed cell — but that erasure is exactly what makes a
190    /// reload otherwise unable to push a disk value into an
191    /// already-handed-out signal: there is nothing generic to deserialize
192    /// into. This closure is the escape hatch.
193    apply_external: Box<dyn Fn(&toml::Value)>,
194    /// RAII handle for the observer that pipes Signal mutations back
195    /// into the in-memory `toml::Value`. Dropping it would unhook the
196    /// write-back, so the cell — and therefore the observer — lives
197    /// for the life of the store.
198    _handle: ObserverHandle,
199}
200
201/// Whether a dirty-key entry is an explicit user edit or a mere
202/// registration-time default. See [`StoreInner::dirty`]'s doc comment.
203#[derive(Clone, Copy, Debug, PartialEq, Eq)]
204enum DirtyKind {
205    /// A registration-time seed: written only if the key is still absent
206    /// from the document at the moment the patch actually runs.
207    SeedIfAbsent,
208    /// An explicit `Signal::set()` (or a deferred re-entrant one) — always
209    /// wins, unconditionally, over whatever is on disk.
210    Set,
211}
212
213struct StoreInner {
214    raw: toml::Value,
215    cells: HashMap<String, SignalCell>,
216    writer: DebouncedWriter,
217    /// Keys dirtied since the last patch was scheduled from them. Drained
218    /// into one owned `Vec<(key, value)>` per schedule, which becomes the
219    /// patch's payload — see the module docs' "merging by dirty key"
220    /// section. Each entry's [`DirtyKind`] distinguishes an explicit user
221    /// `set()` (always wins) from a registration-time default seed (wins
222    /// only if the key is still absent by the time the patch actually
223    /// runs — otherwise it would stomp a peer's already-set real value
224    /// with this store's mere default, exactly the bug this whole design
225    /// exists to prevent).
226    dirty: Vec<(String, toml::Value, DirtyKind)>,
227    /// Set for the duration of [`SettingsStore::reload_from_disk`] pushing
228    /// fresh values into signals. The write-back observer checks this
229    /// (via a shared, non-conflicting immutable borrow — see that method's
230    /// implementation) and does nothing while it's set, so a reload cannot
231    /// bounce straight back out to disk as if it were a local edit.
232    applying_external: Cell<bool>,
233    /// `(mtime, len)` as of the last time this store read or wrote the
234    /// file — the cheap staleness / self-write-suppression stamp behind
235    /// [`Reloadable::reload_from_disk`].
236    last_known_stamp: Cell<(Option<SystemTime>, Option<u64>)>,
237}
238
239impl StoreInner {
240    /// Drain the current dirty-key batch into one patch and schedule it.
241    /// No-op if nothing is dirty (e.g. called defensively after a
242    /// no-op deferred-drain).
243    fn schedule_dirty_flush(&mut self) {
244        if self.dirty.is_empty() {
245            return;
246        }
247        let batch: Vec<(String, toml::Value, DirtyKind)> = std::mem::take(&mut self.dirty);
248        let patch: Patch = Box::new(move |current: Option<String>| {
249            let mut doc: toml::Value = match current {
250                Some(s) => toml::from_str(&s).map_err(|e| FlushError::Merge(e.to_string()))?,
251                None => empty_table(),
252            };
253            if !doc.is_table() {
254                doc = empty_table();
255            }
256            for (k, v, kind) in &batch {
257                match kind {
258                    DirtyKind::Set => write_nested(&mut doc, k, v.clone()),
259                    DirtyKind::SeedIfAbsent => {
260                        if get_nested(&doc, k).is_none() {
261                            write_nested(&mut doc, k, v.clone());
262                        }
263                    }
264                }
265            }
266            toml::to_string_pretty(&doc).map_err(|e| FlushError::Merge(e.to_string()))
267        });
268        self.writer.schedule(patch);
269    }
270}
271
272/// A dynamic dotted-key reactive settings store.
273///
274/// `Clone` is cheap (an `Rc` bump). All clones share one cache and one
275/// I/O thread.
276pub struct SettingsStore {
277    inner: Rc<RefCell<StoreInner>>,
278    /// Write-backs that couldn't borrow `inner` (a re-entrant `set` during
279    /// another `set`'s observer chain, while `inner` is already borrowed) are
280    /// queued here instead of being dropped. They are drained into `raw` +
281    /// `dirty` — preserving the in-memory → disk invariant — the next time
282    /// `inner` is successfully borrowed for a write-back, and on
283    /// `flush_now`. Held in its own cell so it can be pushed to even while
284    /// `inner` is borrowed.
285    pending: Rc<RefCell<Vec<(String, toml::Value)>>>,
286    /// Duplicated from `inner.writer.path()` so [`Reloadable::path`] can
287    /// return a plain `&Path` without needing a `RefCell` borrow to
288    /// outlive `&self` (paths never change post-construction).
289    path: PathBuf,
290}
291
292impl Clone for SettingsStore {
293    fn clone(&self) -> Self {
294        Self {
295            inner: Rc::clone(&self.inner),
296            pending: Rc::clone(&self.pending),
297            path: self.path.clone(),
298        }
299    }
300}
301
302impl SettingsStore {
303    /// Open a store at `path` with the default debounce window.
304    pub fn open(path: PathBuf) -> Result<Self, SettingsStoreError> {
305        Self::open_with_delay(path, DEFAULT_DEBOUNCE)
306    }
307
308    /// Open a store at `path` with a custom debounce window. `delay =
309    /// Duration::ZERO` is useful for tests — every set writes through
310    /// on the next worker iteration, and `flush_now()` is fully
311    /// deterministic.
312    pub fn open_with_delay(path: PathBuf, delay: Duration) -> Result<Self, SettingsStoreError> {
313        let raw = match fs::read_to_string(&path) {
314            Ok(s) => toml::from_str::<toml::Value>(&s).map_err(SettingsStoreError::Parse)?,
315            Err(e) if e.kind() == io::ErrorKind::NotFound => empty_table(),
316            Err(e) => return Err(SettingsStoreError::Io(e)),
317        };
318
319        // The root must be a table — top-level scalars are nonsense for
320        // a multi-key store. Coerce or reject.
321        let raw = match raw {
322            v @ toml::Value::Table(_) => v,
323            _ => empty_table(),
324        };
325
326        let stamp = disk_stamp(&path);
327        let writer = DebouncedWriter::new(path.clone(), delay);
328        let inner = Rc::new(RefCell::new(StoreInner {
329            raw,
330            cells: HashMap::new(),
331            writer,
332            dirty: Vec::new(),
333            applying_external: Cell::new(false),
334            last_known_stamp: Cell::new(stamp),
335        }));
336
337        Ok(Self {
338            inner,
339            pending: Rc::new(RefCell::new(Vec::new())),
340            path,
341        })
342    }
343
344    /// Path of the underlying file.
345    pub fn path(&self) -> &Path {
346        &self.path
347    }
348
349    /// Force any pending payload to disk synchronously.
350    pub fn flush_now(&self) -> Result<(), SettingsStoreError> {
351        // Fold any deferred (re-entrant) write-backs into `raw` + `dirty`
352        // and reschedule before forcing the write, so a value queued while
353        // `inner` was borrowed still reaches disk. Acquire the `inner`
354        // borrow first, then drain — otherwise a failed borrow would lose
355        // the drained values. `try_borrow_mut` keeps this safe if
356        // `flush_now` runs inside a borrow.
357        if let Ok(mut inner) = self.inner.try_borrow_mut() {
358            let deferred: Vec<(String, toml::Value)> =
359                self.pending.borrow_mut().drain(..).collect();
360            if !deferred.is_empty() {
361                for (k, v) in deferred {
362                    write_nested(&mut inner.raw, &k, v.clone());
363                    // These came from the write-back observer's
364                    // deferred-on-reentrancy path, i.e. a real `set()` —
365                    // always wins.
366                    inner.dirty.push((k, v, DirtyKind::Set));
367                }
368                inner.schedule_dirty_flush();
369            }
370        }
371        self.inner
372            .borrow()
373            .writer
374            .flush_now()
375            .map_err(SettingsStoreError::Flush)?;
376
377        // Re-sync with reality rather than just bumping the stamp: our
378        // write just merged against whatever was on disk, which may have
379        // included keys a peer set that this store never locally ingested
380        // (it only knows the keys *it* wrote). A blind stamp bump here
381        // would make a later `reload_from_disk` wrongly believe nothing
382        // changed — since the stamp would already match — even though a
383        // peer's concurrently-merged key was never pushed into this
384        // store's live signals. Folding this through the same
385        // read-parse-compare path `reload_from_disk` uses keeps the
386        // "stamp matches disk <=> in-memory reflects disk" invariant
387        // intact in both the plain-self-write and the merged-with-a-peer
388        // case.
389        let _ = self.resync_with_disk()?;
390        Ok(())
391    }
392
393    /// Whether the given key has already been registered.
394    pub fn has(&self, key: &str) -> bool {
395        self.inner.borrow().cells.contains_key(key)
396    }
397
398    /// All keys registered so far. Order is unspecified.
399    pub fn registered_keys(&self) -> Vec<String> {
400        self.inner.borrow().cells.keys().cloned().collect()
401    }
402
403    /// Get-or-create a `Signal<T>` for `key`, seeded from disk or
404    /// `default` if absent. Subsequent calls for the same key return
405    /// clones of the same signal.
406    ///
407    /// # Panics
408    ///
409    /// * If the key was previously registered with a different type.
410    /// * If the key's path conflicts with an existing leaf-value /
411    ///   table shape (e.g. `"editor"` is a string and now you ask for
412    ///   `"editor.font_size"`).
413    pub fn signal<T>(&self, key: &str, default: T) -> Signal<T>
414    where
415        T: Clone + Serialize + DeserializeOwned + 'static,
416    {
417        if let Some(existing) = self.try_existing::<T>(key) {
418            return existing;
419        }
420
421        let mut inner = self.inner.borrow_mut();
422
423        // Re-check inside the lock in case of races between borrow drops.
424        // (Single-threaded, but defensive.)
425        if let Some(cell) = inner.cells.get(key) {
426            return downcast_or_panic::<T>(key, cell);
427        }
428
429        // Validate the path shape before we do anything else.
430        if let Err(err) = check_path_shape(&inner.raw, key) {
431            panic!("{}", err.message_for(key, std::any::type_name::<T>()));
432        }
433
434        // Seed: deserialize from raw if present; else default.
435        let initial = match get_nested(&inner.raw, key) {
436            Some(v) => match T::deserialize(v.clone()) {
437                Ok(v) => v,
438                Err(_) => default,
439            },
440            None => default,
441        };
442
443        // Stamp the seed back into raw so that the on-disk shape
444        // matches the program's understanding immediately.
445        let initial_value =
446            serialize_to_value(&initial).expect("initial T value must serialize as TOML");
447
448        // Reject struct-shaped values at the leaf: they serialize as
449        // TOML tables, which collide with the store's nested-key model
450        // (we cannot distinguish "table is a struct value" from
451        // "table is a parent of nested keys" on a re-read). Apps that
452        // need to persist struct values should use `SettingsFile<T>`
453        // directly. Arrays and scalars are fine.
454        if matches!(&initial_value, toml::Value::Table(_)) {
455            panic!(
456                "SettingsStore: cannot register key \"{key}\" as {ty} — \
457                 struct values serialize as TOML tables, which collide with \
458                 the store's nested-key model. Use SettingsFile<{ty}> \
459                 instead.",
460                ty = std::any::type_name::<T>(),
461            );
462        }
463
464        let sig: Signal<T> = Signal::new(initial.clone());
465
466        write_nested(&mut inner.raw, key, initial_value.clone());
467
468        // Wire write-back. The closure captures Weaks so a dropped store does
469        // not stay alive via its own observer.
470        let key_owned = key.to_string();
471        let weak: Weak<RefCell<StoreInner>> = Rc::downgrade(&self.inner);
472        let weak_pending: Weak<RefCell<Vec<(String, toml::Value)>>> = Rc::downgrade(&self.pending);
473        let handle = sig.observe(move |new_val: &T| {
474            let Some(inner_rc) = weak.upgrade() else {
475                return;
476            };
477            let Some(pending_rc) = weak_pending.upgrade() else {
478                return;
479            };
480            // Re-entrancy guard: a reload-driven `.set()` must not write
481            // back — it would bounce the value it just read straight back
482            // out to disk as if it were a fresh local edit. A shared
483            // borrow is enough to check the flag, and does not conflict
484            // with the shared borrow `reload_from_disk` may itself be
485            // holding while it drives this very observer.
486            if inner_rc
487                .try_borrow()
488                .map(|r| r.applying_external.get())
489                .unwrap_or(false)
490            {
491                return;
492            }
493            let value = match serialize_to_value(new_val) {
494                Ok(v) => v,
495                Err(_) => return,
496            };
497            match inner_rc.try_borrow_mut() {
498                Ok(mut inner) => {
499                    // Apply any deferred re-entrant writes first, then this one,
500                    // so `raw` (and therefore disk) never diverges from the
501                    // in-memory signals. Drain into a local Vec before touching
502                    // `raw` so a re-entrant push during `write_nested` doesn't
503                    // contend on the `pending` borrow.
504                    let deferred: Vec<(String, toml::Value)> =
505                        pending_rc.borrow_mut().drain(..).collect();
506                    for (k, v) in deferred {
507                        write_nested(&mut inner.raw, &k, v.clone());
508                        inner.dirty.push((k, v, DirtyKind::Set));
509                    }
510                    write_nested(&mut inner.raw, &key_owned, value.clone());
511                    inner.dirty.push((key_owned.clone(), value, DirtyKind::Set));
512                    inner.schedule_dirty_flush();
513                }
514                Err(_) => {
515                    // The store is borrowed elsewhere — a re-entrant set during
516                    // another set's observer chain. Defer rather than drop:
517                    // queue the new value so the next successful write-back (or
518                    // `flush_now`) folds it into `raw` + `dirty`. Dropping it
519                    // here would silently diverge disk from the in-memory
520                    // signal.
521                    pending_rc.borrow_mut().push((key_owned.clone(), value));
522                }
523            }
524        });
525
526        let apply_external: Box<dyn Fn(&toml::Value)> = {
527            let sig_for_apply = sig.clone();
528            Box::new(move |fresh: &toml::Value| {
529                if let Ok(value) = T::deserialize(fresh.clone()) {
530                    sig_for_apply.set(value);
531                }
532            })
533        };
534
535        let cell = SignalCell {
536            type_id: TypeId::of::<T>(),
537            type_name: std::any::type_name::<T>(),
538            signal: Box::new(sig.clone()),
539            apply_external,
540            _handle: handle,
541        };
542        inner.cells.insert(key.to_string(), cell);
543
544        // Flush the seed-stamped raw so brand-new keys hit disk on first
545        // registration even without a `set` — but only if the key is
546        // still absent by the time this patch actually runs. A peer
547        // process may register the same key with the same hardcoded
548        // default and, by pure timing, have its own seed-patch fire
549        // *after* this store (or a third party) has already set a real
550        // value there; an unconditional write would stomp that real
551        // value back to a mere default. `SeedIfAbsent` makes registration
552        // idempotent with respect to a peer's concurrent real edit.
553        inner
554            .dirty
555            .push((key.to_string(), initial_value, DirtyKind::SeedIfAbsent));
556        inner.schedule_dirty_flush();
557
558        sig
559    }
560
561    /// Like [`signal`](Self::signal), but driven by a strongly-named
562    /// [`SettingsKey<T>`] constant.
563    pub fn signal_for<T>(&self, key: &SettingsKey<T>) -> Signal<T>
564    where
565        T: Clone + Serialize + DeserializeOwned + 'static,
566    {
567        self.signal(key.key, (key.default)())
568    }
569
570    fn try_existing<T: Clone + 'static>(&self, key: &str) -> Option<Signal<T>> {
571        let inner = self.inner.borrow();
572        let cell = inner.cells.get(key)?;
573        Some(downcast_or_panic::<T>(key, cell))
574    }
575
576    /// The actual re-sync-with-disk logic shared by [`flush_now`](Self::flush_now)
577    /// (which needs it right after every write, merged or not — see that
578    /// method's doc comment) and [`Reloadable::reload_from_disk`] (the
579    /// public, watcher-facing entry point). Kept as a `SettingsStoreError`-returning
580    /// private method so `flush_now` doesn't have to round-trip through
581    /// `SettingsFileError` for a case that can only ever produce the I/O /
582    /// parse variants.
583    fn resync_with_disk(&self) -> Result<bool, SettingsStoreError> {
584        let current_stamp = disk_stamp(&self.path);
585        if current_stamp == self.inner.borrow().last_known_stamp.get() {
586            return Ok(false);
587        }
588
589        let raw_text = match fs::read_to_string(&self.path) {
590            Ok(s) => s,
591            Err(e) if e.kind() == io::ErrorKind::NotFound => String::new(),
592            Err(e) => return Err(SettingsStoreError::Io(e)),
593        };
594        let parsed: toml::Value = if raw_text.trim().is_empty() {
595            empty_table()
596        } else {
597            toml::from_str(&raw_text).map_err(SettingsStoreError::Parse)?
598        };
599        let parsed = match parsed {
600            v @ toml::Value::Table(_) => v,
601            _ => empty_table(),
602        };
603
604        // Backstop: compare the whole parsed document to what's already
605        // live. Unchanged content (e.g. a peer wrote back byte-identical
606        // bytes, or this really was our own write and the stamp merely
607        // didn't line up) touches nothing.
608        {
609            let mut inner = self.inner.borrow_mut();
610            if inner.raw == parsed {
611                inner.last_known_stamp.set(current_stamp);
612                return Ok(false);
613            }
614            inner.raw = parsed.clone();
615            inner.last_known_stamp.set(current_stamp);
616        }
617
618        // Push each registered cell's fresh sub-value into its live
619        // signal, with the re-entrancy guard held for the whole batch.
620        {
621            let inner_ref = self.inner.borrow();
622            inner_ref.applying_external.set(true);
623            for (key, cell) in inner_ref.cells.iter() {
624                if let Some(value) = get_nested(&parsed, key) {
625                    (cell.apply_external)(value);
626                }
627            }
628            inner_ref.applying_external.set(false);
629        }
630
631        Ok(true)
632    }
633}
634
635impl Reloadable for SettingsStore {
636    fn path(&self) -> &Path {
637        &self.path
638    }
639
640    fn reload_from_disk(&self) -> Result<bool, SettingsFileError> {
641        self.resync_with_disk().map_err(SettingsFileError::from)
642    }
643}
644
645impl std::fmt::Debug for SettingsStore {
646    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
647        let inner = self.inner.borrow();
648        f.debug_struct("SettingsStore")
649            .field("path", &self.path)
650            .field("registered_keys", &inner.cells.len())
651            .finish()
652    }
653}
654
655// ---------------------------------------------------------------------------
656// Helpers
657// ---------------------------------------------------------------------------
658
659fn empty_table() -> toml::Value {
660    toml::Value::Table(toml::map::Map::new())
661}
662
663fn downcast_or_panic<T: Clone + 'static>(key: &str, cell: &SignalCell) -> Signal<T> {
664    if cell.type_id != TypeId::of::<T>() {
665        panic!(
666            "SettingsStore: key \"{key}\" was registered as {prev}, but \
667             signal::<{new}>(...) was called. Pick one type per key.",
668            prev = cell.type_name,
669            new = std::any::type_name::<T>(),
670        );
671    }
672    cell.signal
673        .downcast_ref::<Signal<T>>()
674        .expect("type id matched but downcast failed — teksilo-settings bug")
675        .clone()
676}
677
678fn serialize_to_value<T: Serialize>(value: &T) -> Result<toml::Value, toml::ser::Error> {
679    toml::Value::try_from(value)
680}
681
682/// Walk a dotted key into a `toml::Value`, returning the leaf if all
683/// intermediate steps are tables and the leaf exists.
684fn get_nested<'a>(raw: &'a toml::Value, key: &str) -> Option<&'a toml::Value> {
685    let mut current = raw;
686    for part in key.split('.') {
687        current = current.get(part)?;
688    }
689    Some(current)
690}
691
692/// Insert `value` at the dotted `key`, creating intermediate tables as
693/// needed. Caller must have already verified that the path shape is
694/// compatible via [`check_path_shape`].
695fn write_nested(raw: &mut toml::Value, key: &str, value: toml::Value) {
696    let parts: Vec<&str> = key.split('.').collect();
697    let last = parts.len() - 1;
698    let mut current = raw;
699    for (i, part) in parts.iter().enumerate() {
700        let table = current
701            .as_table_mut()
702            .expect("write_nested: path validated by check_path_shape, but encountered non-table");
703        if i == last {
704            table.insert((*part).to_string(), value);
705            return;
706        }
707        let entry = table
708            .entry((*part).to_string())
709            .or_insert_with(|| toml::Value::Table(toml::map::Map::new()));
710        current = entry;
711    }
712}
713
714#[derive(Debug)]
715enum CollisionKind {
716    /// An intermediate component of the dotted path is a non-table
717    /// scalar, so the path can't be deepened.
718    IntermediateIsValue {
719        existing_path: String,
720        existing_kind: &'static str,
721    },
722    /// The leaf is currently a table, so the path can't be assigned a
723    /// scalar.
724    LeafIsTable { existing_path: String },
725}
726
727impl CollisionKind {
728    fn message_for(&self, requested_key: &str, requested_type: &str) -> String {
729        match self {
730            CollisionKind::IntermediateIsValue {
731                existing_path,
732                existing_kind,
733            } => format!(
734                "SettingsStore: cannot register key \"{requested_key}\" as {requested_type} — \
735                 the prefix \"{existing_path}\" is already a {existing_kind} value. \
736                 A key cannot be both a value and a parent.",
737            ),
738            CollisionKind::LeafIsTable { existing_path } => format!(
739                "SettingsStore: cannot register key \"{requested_key}\" as {requested_type} — \
740                 \"{existing_path}\" is already a table (parent of other keys). \
741                 A key cannot be both a value and a parent.",
742            ),
743        }
744    }
745}
746
747fn check_path_shape(raw: &toml::Value, key: &str) -> Result<(), CollisionKind> {
748    let parts: Vec<&str> = key.split('.').collect();
749    let last = parts.len() - 1;
750    let mut current = raw;
751    let mut walked = String::new();
752    for (i, part) in parts.iter().enumerate() {
753        if !walked.is_empty() {
754            walked.push('.');
755        }
756        walked.push_str(part);
757
758        let Some(child) = current.get(part) else {
759            return Ok(());
760        };
761        let is_last = i == last;
762        if is_last {
763            if child.is_table() {
764                return Err(CollisionKind::LeafIsTable {
765                    existing_path: walked,
766                });
767            }
768            return Ok(());
769        }
770        if !child.is_table() {
771            return Err(CollisionKind::IntermediateIsValue {
772                existing_path: walked,
773                existing_kind: kind_name(child),
774            });
775        }
776        current = child;
777    }
778    Ok(())
779}
780
781fn kind_name(value: &toml::Value) -> &'static str {
782    match value {
783        toml::Value::String(_) => "string",
784        toml::Value::Integer(_) => "integer",
785        toml::Value::Float(_) => "float",
786        toml::Value::Boolean(_) => "boolean",
787        toml::Value::Datetime(_) => "datetime",
788        toml::Value::Array(_) => "array",
789        toml::Value::Table(_) => "table",
790    }
791}
792
793// Allow `Path` references for ergonomics in tests / examples.
794impl SettingsStore {
795    /// Convenience constructor accepting `&Path`.
796    pub fn open_path(path: &Path) -> Result<Self, SettingsStoreError> {
797        Self::open(path.to_path_buf())
798    }
799}
800
801#[cfg(test)]
802mod tests {
803    use super::*;
804    use serde::{Deserialize, Serialize};
805    use tempfile::tempdir;
806
807    #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
808    struct Window {
809        x: i32,
810        y: i32,
811        title: String,
812    }
813
814    fn open_in(dir: &Path, name: &str) -> SettingsStore {
815        SettingsStore::open_with_delay(dir.join(name), Duration::ZERO).unwrap()
816    }
817
818    #[test]
819    fn signal_returns_default_when_key_absent() {
820        let dir = tempdir().unwrap();
821        let store = open_in(dir.path(), "store.toml");
822        let sig = store.signal::<f32>("editor.font_size", 14.0);
823        assert_eq!(sig.get(), 14.0);
824    }
825
826    #[test]
827    fn signal_dedupes_per_key() {
828        let dir = tempdir().unwrap();
829        let store = open_in(dir.path(), "store.toml");
830        let a = store.signal::<f32>("editor.font_size", 14.0);
831        let b = store.signal::<f32>("editor.font_size", 99.0); // default ignored
832        assert_eq!(b.get(), 14.0);
833        a.set(22.0);
834        assert_eq!(b.get(), 22.0);
835    }
836
837    #[test]
838    fn set_persists_after_flush_now_and_reopens() {
839        let dir = tempdir().unwrap();
840        let path = dir.path().join("p.toml");
841
842        {
843            let store = SettingsStore::open_with_delay(path.clone(), Duration::ZERO).unwrap();
844            let sig = store.signal::<f32>("editor.font_size", 14.0);
845            sig.set(18.0);
846            store.flush_now().unwrap();
847        }
848
849        let store = SettingsStore::open_with_delay(path, Duration::ZERO).unwrap();
850        let sig = store.signal::<f32>("editor.font_size", 14.0);
851        assert_eq!(sig.get(), 18.0);
852    }
853
854    #[test]
855    #[should_panic(expected = "struct values serialize as TOML tables")]
856    fn struct_values_rejected_at_registration() {
857        // The store does not support struct values: they serialize as
858        // TOML tables, which collide with the dotted-key model.
859        let dir = tempdir().unwrap();
860        let store = open_in(dir.path(), "p.toml");
861        let _w = store.signal::<Window>(
862            "window.main",
863            Window {
864                x: 0,
865                y: 0,
866                title: String::new(),
867            },
868        );
869    }
870
871    #[test]
872    fn array_of_scalars_roundtrip() {
873        // Arrays serialize as TOML arrays (not tables), so they
874        // coexist with the dotted-key model just fine.
875        let dir = tempdir().unwrap();
876        let path = dir.path().join("p.toml");
877
878        {
879            let store = SettingsStore::open_with_delay(path.clone(), Duration::ZERO).unwrap();
880            let palette =
881                store.signal::<Vec<String>>("ui.palette", vec!["red".into(), "blue".into()]);
882            palette.set(vec!["green".into(), "yellow".into(), "purple".into()]);
883            store.flush_now().unwrap();
884        }
885
886        let store = SettingsStore::open_with_delay(path, Duration::ZERO).unwrap();
887        let palette = store.signal::<Vec<String>>("ui.palette", vec![]);
888        assert_eq!(palette.get(), vec!["green", "yellow", "purple"]);
889    }
890
891    #[test]
892    #[should_panic(expected = "registered as f32")]
893    fn type_mismatch_panics() {
894        let dir = tempdir().unwrap();
895        let store = open_in(dir.path(), "p.toml");
896        let _a = store.signal::<f32>("k", 1.0);
897        let _b = store.signal::<i32>("k", 2);
898    }
899
900    #[test]
901    #[should_panic(expected = "is already a string value")]
902    fn intermediate_value_collision_panics() {
903        let dir = tempdir().unwrap();
904        let store = open_in(dir.path(), "p.toml");
905        let _ = store.signal::<String>("editor", "blue".into());
906        let _ = store.signal::<f32>("editor.font_size", 14.0);
907    }
908
909    #[test]
910    #[should_panic(expected = "is already a table")]
911    fn leaf_table_collision_panics() {
912        let dir = tempdir().unwrap();
913        let store = open_in(dir.path(), "p.toml");
914        let _ = store.signal::<f32>("editor.font_size", 14.0);
915        let _ = store.signal::<String>("editor", "blue".into());
916    }
917
918    #[test]
919    fn deeply_nested_collision_caught() {
920        let dir = tempdir().unwrap();
921        let path = dir.path().join("p.toml");
922        // Pre-populate file with `[a.b] c = 5`
923        fs::write(&path, "[a.b]\nc = 5\n").unwrap();
924
925        let store = SettingsStore::open_with_delay(path, Duration::ZERO).unwrap();
926        // a.b is a table; asking for a.b as a scalar should panic.
927        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
928            store.signal::<i32>("a.b", 0);
929        }));
930        assert!(result.is_err());
931    }
932
933    #[test]
934    fn registered_keys_lists_touched_keys_only() {
935        let dir = tempdir().unwrap();
936        let store = open_in(dir.path(), "p.toml");
937        assert!(store.registered_keys().is_empty());
938        let _ = store.signal::<i32>("a", 1);
939        let _ = store.signal::<bool>("b.c", true);
940        let mut keys = store.registered_keys();
941        keys.sort();
942        assert_eq!(keys, vec!["a".to_string(), "b.c".to_string()]);
943        assert!(store.has("a"));
944        assert!(!store.has("nonexistent"));
945    }
946
947    #[test]
948    fn signal_for_uses_constant_default() {
949        const HEIGHT: SettingsKey<f32> = SettingsKey::new("layout.height", || 42.0);
950
951        let dir = tempdir().unwrap();
952        let store = open_in(dir.path(), "p.toml");
953        let sig = store.signal_for(&HEIGHT);
954        assert_eq!(sig.get(), 42.0);
955    }
956
957    #[test]
958    fn dropping_store_does_not_leak_via_observer() {
959        // Regression for the cycle bug: a strong Rc capture in the
960        // observer closure would prevent StoreInner from being freed
961        // even after the user drops their last clone.
962        let dir = tempdir().unwrap();
963        let store = open_in(dir.path(), "p.toml");
964        let weak = {
965            let inner_rc = Rc::clone(&store.inner);
966            let weak = Rc::downgrade(&inner_rc);
967            let _sig = store.signal::<f32>("k", 1.0);
968            drop(inner_rc);
969            weak
970        };
971        // `store` still alive — weak is upgradable.
972        assert!(weak.upgrade().is_some());
973        drop(store);
974        // After last strong Rc drops, the weak cannot upgrade.
975        assert!(
976            weak.upgrade().is_none(),
977            "observer must not keep StoreInner alive via a strong capture"
978        );
979    }
980
981    #[test]
982    fn observer_writes_back_to_raw() {
983        let dir = tempdir().unwrap();
984        let path = dir.path().join("p.toml");
985        let store = SettingsStore::open_with_delay(path.clone(), Duration::ZERO).unwrap();
986        let sig = store.signal::<i32>("answer", 0);
987        sig.set(42);
988        store.flush_now().unwrap();
989
990        let on_disk = fs::read_to_string(&path).unwrap();
991        assert!(on_disk.contains("answer = 42"));
992    }
993
994    #[test]
995    fn pre_existing_file_seeds_signals() {
996        let dir = tempdir().unwrap();
997        let path = dir.path().join("p.toml");
998        fs::write(&path, "[editor]\nfont_size = 18.0\n").unwrap();
999
1000        let store = SettingsStore::open_with_delay(path, Duration::ZERO).unwrap();
1001        let sig = store.signal::<f32>("editor.font_size", 1.0);
1002        assert_eq!(sig.get(), 18.0);
1003    }
1004
1005    #[test]
1006    fn path_with_top_level_scalar_recovers_with_empty_table() {
1007        // A weird but possible file: top-level scalar (e.g., from
1008        // hand-edits). The store should not panic; it should treat the
1009        // root as empty.
1010        let dir = tempdir().unwrap();
1011        let path = dir.path().join("p.toml");
1012        // toml does not actually allow a bare scalar at the top level,
1013        // but we also test the more common path: empty file works.
1014        fs::write(&path, "").unwrap();
1015        let store = SettingsStore::open_with_delay(path, Duration::ZERO).unwrap();
1016        let sig = store.signal::<i32>("k", 7);
1017        assert_eq!(sig.get(), 7);
1018    }
1019
1020    // -----------------------------------------------------------------
1021    // Cross-process merge + Reloadable
1022    // -----------------------------------------------------------------
1023
1024    /// THE HEADLINE TEST. Two independent `SettingsStore` handles over the
1025    /// same file — standing in for two Skribisto processes sharing
1026    /// `general.toml` — each set a *different* key with no coordination.
1027    /// Because every write merges its dirty key onto the document read
1028    /// fresh under the lock, both keys must survive, and reloading must
1029    /// push the peer's key into this process's *already-live* `Signal`
1030    /// with no restart needed.
1031    #[test]
1032    fn two_concurrent_stores_each_setting_a_different_key_both_survive_and_reload_updates_live_signal()
1033     {
1034        let dir = tempdir().unwrap();
1035        let path = dir.path().join("shared_store.toml");
1036
1037        let a = SettingsStore::open_with_delay(path.clone(), Duration::ZERO).unwrap();
1038        let b = SettingsStore::open_with_delay(path.clone(), Duration::ZERO).unwrap();
1039
1040        let dark_a = a.signal::<bool>("ui.dark", false);
1041        let dark_b = b.signal::<bool>("ui.dark", false);
1042        let width_b = b.signal::<f32>("editor.column_width", 80.0);
1043
1044        // Not yet reloaded: b's live signal for a's key still reads its
1045        // own local default.
1046        assert!(!dark_b.get(), "b hasn't seen a's write yet");
1047
1048        dark_a.set(true);
1049        a.flush_now().unwrap();
1050
1051        // b's write only ever touches its own key — but reloading must
1052        // push a's concurrent change into b's *already-live* `Signal`,
1053        // with no restart needed.
1054        assert!(Reloadable::reload_from_disk(&b).unwrap());
1055        assert!(dark_b.get(), "b's live signal must reflect a's write");
1056
1057        width_b.set(120.0);
1058        b.flush_now().unwrap();
1059
1060        // A third, fresh handle proves both keys are actually on disk
1061        // together, not just cached in `a`'s or `b`'s memory.
1062        let c = SettingsStore::open_with_delay(path, Duration::ZERO).unwrap();
1063        assert!(c.signal::<bool>("ui.dark", false).get());
1064        assert_eq!(c.signal::<f32>("editor.column_width", 80.0).get(), 120.0);
1065    }
1066
1067    #[test]
1068    fn reload_driven_set_schedules_no_write() {
1069        let dir = tempdir().unwrap();
1070        let path = dir.path().join("no_bounce.toml");
1071
1072        let a = SettingsStore::open_with_delay(path.clone(), Duration::ZERO).unwrap();
1073        let b = SettingsStore::open_with_delay(path.clone(), Duration::ZERO).unwrap();
1074        let _dark_b = b.signal::<bool>("ui.dark", false);
1075
1076        a.signal::<bool>("ui.dark", false).set(true);
1077        a.flush_now().unwrap();
1078
1079        assert!(Reloadable::reload_from_disk(&b).unwrap());
1080        // If the reload's `sig.set()` had scheduled a write, `b.inner`
1081        // would have a non-empty `dirty` batch right now.
1082        assert!(
1083            b.inner.borrow().dirty.is_empty(),
1084            "a reload-driven set must not enqueue a write-back"
1085        );
1086    }
1087
1088    #[test]
1089    fn reload_from_disk_returns_false_and_touches_nothing_when_unchanged() {
1090        let dir = tempdir().unwrap();
1091        let path = dir.path().join("unchanged_store.toml");
1092        let store = SettingsStore::open_with_delay(path, Duration::ZERO).unwrap();
1093        let sig = store.signal::<f32>("k", 1.0);
1094        sig.set(2.0);
1095        store.flush_now().unwrap();
1096
1097        assert!(!Reloadable::reload_from_disk(&store).unwrap());
1098        assert_eq!(sig.get(), 2.0);
1099    }
1100
1101    #[test]
1102    fn reload_from_disk_ignores_our_own_last_write_via_cheap_stamp_check() {
1103        let dir = tempdir().unwrap();
1104        let path = dir.path().join("self_write_store.toml");
1105        let store = SettingsStore::open_with_delay(path, Duration::ZERO).unwrap();
1106        store.signal::<f32>("k", 1.0).set(9.0);
1107        store.flush_now().unwrap();
1108
1109        // flush_now() re-stamps last_known_stamp right after the write
1110        // completes, so an immediate reload sees a matching stamp.
1111        assert!(!Reloadable::reload_from_disk(&store).unwrap());
1112    }
1113}