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}