teksilo_settings/file.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `SettingsFile<T>` — typed single-struct persistence.
5//!
6//! Used when the persisted shape is a known struct (recents, window
7//! state) rather than a dynamic K/V map. The current value lives in a
8//! `RefCell<T>` inside an `Rc<>`-shared inner so that multiple handles
9//! can observe and mutate the same projection.
10//!
11//! ## Cross-process safety is the only mode
12//!
13//! Every read and every write goes through the exclusive advisory lock on
14//! `<path>.lock` (see [`crate::lock`]):
15//!
16//! * [`load`](SettingsFile::load) acquires the lock, reads + migrates the file
17//! fresh, and retains the [`Migrator`] for the handle's whole lifetime —
18//! not just for this one read — because [`mutate`](SettingsFile::mutate),
19//! [`replace`](SettingsFile::replace),
20//! [`reload_if_stale`](SettingsFile::reload_if_stale)
21//! and [`reload_from_disk`](crate::Reloadable::reload_from_disk) all need
22//! to re-migrate a peer's still-older on-disk schema on demand, not just
23//! once at construction.
24//! * [`mutate`](SettingsFile::mutate) and [`replace`](SettingsFile::replace) perform a
25//! **locked read-modify-write**: acquire the lock, re-read + re-migrate
26//! the file from disk *under the lock*, apply the caller's change to that
27//! fresh value, write it back atomically, refresh the in-memory snapshot,
28//! then release the lock. A lock alone would only stop the two writes
29//! from interleaving on disk — it does nothing to stop a stale in-memory
30//! snapshot from clobbering a peer's newer data, so the re-read has to
31//! happen *under* the same lock that guards the write. These writes are
32//! synchronous, on the calling thread, bypassing the shared debounced I/O
33//! worker entirely — deliberately: `SettingsFile<T>` is for **rare**
34//! writes (a settings change, one record per backup), so there is no
35//! burst to coalesce. Contrast [`crate::SettingsStore`] and
36//! [`crate::PersistedListModel`], which write far more often and keep
37//! the debounce.
38//! * [`reload_if_stale`](SettingsFile::reload_if_stale) and
39//! [`reload_from_disk`](crate::Reloadable::reload_from_disk) are how
40//! *reads* pick up a peer's change — a cheap mtime/len check, escalating
41//! to a full re-read only when something actually moved.
42//!
43//! ```ignore
44//! use teksilo_settings::{SettingsFile, Migrator, Versioned};
45//! use serde::{Serialize, Deserialize};
46//!
47//! #[derive(Serialize, Deserialize, Debug, Default, Clone)]
48//! struct AppPrefs { version: u32, font_size: f32 }
49//! impl Versioned for AppPrefs {
50//! const CURRENT_VERSION: u32 = 1;
51//! fn version(&self) -> u32 { self.version }
52//! fn set_version(&mut self, v: u32) { self.version = v; }
53//! }
54//!
55//! let path = dirs::config_dir().unwrap().join("myapp/prefs.toml");
56//! let file: SettingsFile<AppPrefs> =
57//! SettingsFile::load(path, Migrator::new()).unwrap();
58//!
59//! file.mutate(|p| p.font_size = 16.0).unwrap();
60//! ```
61
62use std::cell::{Cell, Ref, RefCell};
63use std::fs;
64use std::io;
65use std::path::{Path, PathBuf};
66use std::rc::Rc;
67use std::thread;
68use std::time::{Duration, SystemTime, UNIX_EPOCH};
69
70use serde::Serialize;
71use serde::de::DeserializeOwned;
72
73use crate::flush::{FlushError, write_atomic};
74use crate::lock::FileLock;
75use crate::migration::{MigrationError, Migrator, Versioned};
76use crate::reload::Reloadable;
77
78/// Number of times a locked read (initial load, a locked read-modify-write, or
79/// [`SettingsFile::reload_if_stale`] / [`Reloadable::reload_from_disk`]) will
80/// retry a TOML parse failure before surfacing it. Atomic rename means a
81/// well-behaved peer should never hand us a torn write, but we retry briefly
82/// rather than treat a transient failure as fatal — and, critically, rather
83/// than quarantine the file, which could destroy a peer's legitimate data.
84pub(crate) const MAX_READ_ATTEMPTS: u32 = 5;
85/// Delay between retries in [`MAX_READ_ATTEMPTS`].
86pub(crate) const READ_RETRY_DELAY: Duration = Duration::from_millis(5);
87
88/// Errors surfaced by [`SettingsFile`] operations (and, by extension, every
89/// other persisted type in this crate — they all share this error type).
90#[derive(Debug, thiserror::Error)]
91pub enum SettingsFileError {
92 /// An OS-level file I/O error (read, write, or rename).
93 #[error("settings file I/O: {0}")]
94 Io(#[from] io::Error),
95 /// The file's TOML could not be parsed.
96 #[error("settings file parse: {0}")]
97 Parse(#[source] toml::de::Error),
98 /// A migration step failed; the file version could not be brought
99 /// up to `T::CURRENT_VERSION`.
100 #[error("settings file migration: {0}")]
101 Migrate(#[source] MigrationError),
102 /// The in-memory value could not be serialized to TOML before writing.
103 #[error("settings file serialize: {0}")]
104 Serialize(#[source] toml::ser::Error),
105 /// The debounced background write failed.
106 #[error("settings file flush: {0}")]
107 Flush(#[source] FlushError),
108}
109
110struct Inner<T: Versioned + DeserializeOwned> {
111 current: RefCell<T>,
112 /// The file this handle reads from and writes to. Every write here
113 /// (`mutate`/`replace`) is a synchronous locked read-modify-write on
114 /// the calling thread — this type never registers with the shared
115 /// debounced-write worker pool at all, so there is nothing to keep
116 /// uniform with `SettingsStore`/`PersistedListModel` beyond the path
117 /// itself.
118 path: PathBuf,
119 /// The on-disk `(mtime, len)` as of the last time we read or wrote the
120 /// file (via construction, a locked read-modify-write, or
121 /// [`SettingsFile::reload_if_stale`] / [`Reloadable::reload_from_disk`]).
122 /// `(None, None)` means "file did not exist as of our last look." Used
123 /// both as the cheap staleness probe and, symmetrically, as the
124 /// self-write-suppression stamp a file watcher's `reload_from_disk`
125 /// call relies on (see `reload.rs`'s module docs).
126 last_known_stamp: Cell<(Option<SystemTime>, Option<u64>)>,
127 /// Retained for the handle's whole lifetime: every locked read (not
128 /// just the first one at construction) re-migrates through this, since
129 /// a peer process may still be writing an older on-disk schema.
130 migrator: Migrator<T>,
131}
132
133/// A reactive handle to a single typed file on disk.
134///
135/// `Clone` is cheap (an `Rc` bump). All clones share one in-memory
136/// projection and one path. There is no I/O thread: every write is a
137/// synchronous locked read-modify-write on the calling thread (see the
138/// module docs).
139pub struct SettingsFile<T: Versioned + DeserializeOwned> {
140 inner: Rc<Inner<T>>,
141}
142
143impl<T: Versioned + DeserializeOwned> Clone for SettingsFile<T> {
144 fn clone(&self) -> Self {
145 Self {
146 inner: Rc::clone(&self.inner),
147 }
148 }
149}
150
151impl<T> SettingsFile<T>
152where
153 T: Versioned + Serialize + DeserializeOwned + Default + Clone + 'static,
154{
155 /// Load the file from disk (running migrations) or initialize with
156 /// `T::default()` if the file does not exist.
157 ///
158 /// The initial read is lock-protected, exactly like every subsequent
159 /// `mutate` / `replace`: a peer that is mid-write when this process
160 /// starts up cannot hand us a torn read.
161 ///
162 /// `migrator` is taken **by value** and retained for the lifetime of
163 /// the handle: every later locked read re-runs it, since a peer might
164 /// still be on an older on-disk schema at any point, not just at
165 /// startup.
166 ///
167 /// On a genuine parse failure (the bytes are not valid TOML at all,
168 /// surviving `MAX_READ_ATTEMPTS` retries) the offending file is
169 /// renamed to `<path>.broken-<ts>` and the returned `SettingsFile`
170 /// starts from `T::default()` — the file really is corrupt, and the
171 /// quarantine lets the next launch start clean instead of repeatedly
172 /// failing to load it.
173 ///
174 /// A [`SettingsFileError::Migrate`] or [`SettingsFileError::Io`]
175 /// failure, by contrast, is **not** quarantined:
176 ///
177 /// * `Migrate` means the TOML parsed fine, but this build's own
178 /// [`Migrator`] chain doesn't know how to bring it up to
179 /// `T::CURRENT_VERSION` — the classic symptom of an *older* build
180 /// opening a file a *newer* peer process already wrote in a newer
181 /// schema. The file is not corrupt; renaming it would destroy that
182 /// peer's live, legitimate, still-in-use data.
183 /// * `Io` means we couldn't even read the file (permissions, a
184 /// transient failure) — we never saw its content, so there is no
185 /// basis at all for deciding it's corrupt, and renaming (itself
186 /// another I/O operation, on a path we just failed to read) would
187 /// be reckless.
188 ///
189 /// In both of those cases the handle falls back to `T::default()` for
190 /// this session only, but the file on disk is left completely
191 /// untouched. Use [`load_strict`](Self::load_strict) in tests that
192 /// want to assert on the specific failure instead.
193 pub fn load(path: PathBuf, migrator: Migrator<T>) -> Result<Self, SettingsFileError> {
194 let lock = FileLock::acquire_exclusive(&path).map_err(SettingsFileError::Io)?;
195 let initial = match Self::read_or_default(&path, &migrator) {
196 Ok(value) => value,
197 Err(SettingsFileError::Migrate(e)) => {
198 // Not corruption: a peer on a newer schema. Leave the file
199 // alone so that peer's data survives; fall back to
200 // in-memory defaults for this session only.
201 eprintln!(
202 "teksilo-settings: {} is on a schema this build cannot migrate ({}); using in-memory defaults for this session, file left untouched",
203 path.display(),
204 e,
205 );
206 let mut v = T::default();
207 v.set_version(T::CURRENT_VERSION);
208 v
209 }
210 Err(SettingsFileError::Io(e)) => {
211 // We never even read the content, so we have no basis to
212 // judge it corrupt. Fall back to in-memory defaults for
213 // this session only.
214 eprintln!(
215 "teksilo-settings: could not read {} ({}); using in-memory defaults for this session, file left untouched",
216 path.display(),
217 e,
218 );
219 let mut v = T::default();
220 v.set_version(T::CURRENT_VERSION);
221 v
222 }
223 Err(other) => {
224 // A genuinely unparsable-after-retries document: real
225 // corruption. Quarantine it so the next launch starts
226 // clean instead of repeatedly failing.
227 quarantine(&path);
228 eprintln!(
229 "teksilo-settings: load failed for {}: {}; quarantined, falling back to defaults",
230 path.display(),
231 other,
232 );
233 let mut v = T::default();
234 v.set_version(T::CURRENT_VERSION);
235 v
236 }
237 };
238 let stamp = disk_stamp(&path);
239 drop(lock);
240 Ok(Self::new_inner(path, initial, stamp, migrator))
241 }
242
243 /// Like [`load`](Self::load), but returns parse / migration errors
244 /// instead of quarantining the file. Intended for tests that want
245 /// to assert on a specific failure mode.
246 pub fn load_strict(path: PathBuf, migrator: Migrator<T>) -> Result<Self, SettingsFileError> {
247 let lock = FileLock::acquire_exclusive(&path).map_err(SettingsFileError::Io)?;
248 let initial = Self::read_or_default(&path, &migrator)?;
249 let stamp = disk_stamp(&path);
250 drop(lock);
251 Ok(Self::new_inner(path, initial, stamp, migrator))
252 }
253
254 fn new_inner(
255 path: PathBuf,
256 initial: T,
257 stamp: (Option<SystemTime>, Option<u64>),
258 migrator: Migrator<T>,
259 ) -> Self {
260 // Writes never go through the shared debounced-write worker pool
261 // (see the module docs): every `mutate` / `replace` is a
262 // synchronous locked read-modify-write on the calling thread. This
263 // type never registers with that pool at all — it just remembers
264 // its own `path` directly.
265 Self {
266 inner: Rc::new(Inner {
267 current: RefCell::new(initial),
268 path,
269 last_known_stamp: Cell::new(stamp),
270 migrator,
271 }),
272 }
273 }
274
275 /// [`read_toml_with_retry`], then run `migrator`, stamping the current
276 /// version. A missing file falls back to `T::default()`.
277 fn read_or_default(path: &Path, migrator: &Migrator<T>) -> Result<T, SettingsFileError> {
278 match read_toml_with_retry(path)? {
279 Some(raw) => {
280 let mut value = migrator.run(raw).map_err(SettingsFileError::Migrate)?;
281 value.set_version(T::CURRENT_VERSION);
282 Ok(value)
283 }
284 None => {
285 let mut v = T::default();
286 v.set_version(T::CURRENT_VERSION);
287 Ok(v)
288 }
289 }
290 }
291
292 /// Borrow the current value. The returned `Ref` holds a `RefCell`
293 /// guard; do not call any mutating method on this `SettingsFile`
294 /// while a `Ref` is alive.
295 pub fn borrow(&self) -> Ref<'_, T> {
296 self.inner.current.borrow()
297 }
298
299 /// Clone the current value out. Convenient when you don't want to
300 /// juggle a borrow.
301 pub fn snapshot(&self) -> T {
302 self.inner.current.borrow().clone()
303 }
304
305 /// Replace the current value and persist it via a locked
306 /// read-modify-write. The disk read is discarded — `replace` always
307 /// wins over whatever was on disk — but the lock still serializes it
308 /// against a concurrent peer write, and the fresh disk stamp is
309 /// recorded so a subsequent reload doesn't re-read our own write back
310 /// in as if it were new. `T::set_version(T::CURRENT_VERSION)` is called
311 /// so the version stamp is always coherent, even if the caller forgot.
312 pub fn replace(&self, new: T) -> Result<(), SettingsFileError> {
313 self.locked_read_modify_write(move |v| *v = new)
314 }
315
316 /// Mutate the current value in place and persist it via a locked
317 /// read-modify-write: the file is re-read and re-migrated from disk
318 /// *under an exclusive lock* before `f` is applied, so `f` always sees
319 /// a fresh value — not this handle's possibly-stale in-memory snapshot
320 /// — and the result is written back atomically before the lock is
321 /// released.
322 ///
323 /// Takes `f` as `FnOnce` (not `Fn`) and imposes no `Send` bound on `T`:
324 /// this write is synchronous on the calling thread, never replayed on a
325 /// background worker, so there is no reason to tax every call site with
326 /// a `Send`/`Fn` requirement it doesn't need.
327 pub fn mutate<F: FnOnce(&mut T)>(&self, f: F) -> Result<(), SettingsFileError> {
328 self.locked_read_modify_write(f)
329 }
330
331 /// The locked read-modify-write behind `mutate`/`replace`.
332 ///
333 /// Acquires the exclusive advisory lock, re-reads + re-migrates the
334 /// document fresh from disk (falling back to `T::default()` if it
335 /// is absent, exactly like `load`), applies `f` to that fresh value,
336 /// writes the result back atomically, then refreshes this handle's
337 /// in-memory `current` and stamp baseline — all before releasing
338 /// the lock.
339 fn locked_read_modify_write<F: FnOnce(&mut T)>(&self, f: F) -> Result<(), SettingsFileError> {
340 let path = self.inner.path.clone();
341 let lock = FileLock::acquire_exclusive(&path).map_err(SettingsFileError::Io)?;
342
343 let mut fresh = Self::read_or_default(&path, &self.inner.migrator)?;
344 f(&mut fresh);
345 fresh.set_version(T::CURRENT_VERSION);
346 let serialized = toml::to_string_pretty(&fresh).map_err(SettingsFileError::Serialize)?;
347 write_atomic(&path, &serialized).map_err(SettingsFileError::Io)?;
348
349 let new_stamp = disk_stamp(&path);
350 *self.inner.current.borrow_mut() = fresh;
351 self.inner.last_known_stamp.set(new_stamp);
352
353 drop(lock);
354 Ok(())
355 }
356
357 /// Pick up a peer's change: if the on-disk `(mtime, len)` differs from
358 /// the last one this handle observed, re-read and re-migrate the file
359 /// and refresh `current`. Returns whether a reload happened.
360 ///
361 /// This is the cheap public probe — a `stat`, safe to call
362 /// speculatively (e.g. on every focus-in, or on a timer). It does not
363 /// perform the content-equality backstop that
364 /// [`Reloadable::reload_from_disk`] adds on top (which additionally
365 /// requires `T: PartialEq`); use that when a value-level "did anything
366 /// actually change" guarantee is needed (e.g. driven by a file
367 /// watcher, where a coincident stamp match must never be relied on
368 /// alone).
369 pub fn reload_if_stale(&self) -> Result<bool, SettingsFileError> {
370 let path = self.inner.path.as_path();
371 let current_stamp = disk_stamp(path);
372 if current_stamp == self.inner.last_known_stamp.get() {
373 return Ok(false);
374 }
375
376 let value = Self::read_or_default(path, &self.inner.migrator)?;
377 *self.inner.current.borrow_mut() = value;
378 self.inner.last_known_stamp.set(current_stamp);
379 Ok(true)
380 }
381
382 /// Synchronously write any pending payload to disk. A genuine no-op:
383 /// `mutate` / `replace` already write synchronously on the calling
384 /// thread, so nothing is ever pending — this type never registers
385 /// with the shared debounced-write worker pool at all, so there is
386 /// nothing to flush and nothing that can fail. Kept so callers that
387 /// hold a `SettingsFile` alongside debounced types (`SettingsStore`,
388 /// `PersistedListModel`) can flush everything uniformly without
389 /// special-casing this type.
390 pub fn flush_now(&self) -> Result<(), SettingsFileError> {
391 Ok(())
392 }
393
394 /// The path being written to.
395 pub fn path(&self) -> &Path {
396 self.inner.path.as_path()
397 }
398}
399
400/// The content-equality backstop on top of [`SettingsFile::reload_if_stale`]
401/// — see `reload.rs`'s module docs for the two-layer contract. Requires
402/// `T: PartialEq` (only for this impl block; every other `SettingsFile`
403/// method is unaffected), since this is the only place that needs to ask
404/// "is the freshly-read value actually different from what's live."
405impl<T> Reloadable for SettingsFile<T>
406where
407 T: Versioned + Serialize + DeserializeOwned + Default + Clone + PartialEq + 'static,
408{
409 fn path(&self) -> &Path {
410 SettingsFile::path(self)
411 }
412
413 fn reload_from_disk(&self) -> Result<bool, SettingsFileError> {
414 let path = self.inner.path.as_path();
415 let current_stamp = disk_stamp(path);
416 if current_stamp == self.inner.last_known_stamp.get() {
417 // Cheap check: this is almost always our own last write
418 // being noticed by a watcher. Nothing touched.
419 return Ok(false);
420 }
421
422 let value = Self::read_or_default(path, &self.inner.migrator)?;
423 self.inner.last_known_stamp.set(current_stamp);
424
425 if *self.inner.current.borrow() == value {
426 // Backstop: the stamp moved (e.g. a peer wrote back
427 // byte-identical content, or the filesystem's mtime
428 // resolution coincided with an unrelated change) but the
429 // value itself is unchanged. Touch nothing.
430 return Ok(false);
431 }
432
433 *self.inner.current.borrow_mut() = value;
434 Ok(true)
435 }
436}
437
438impl<T: Versioned + DeserializeOwned + std::fmt::Debug> std::fmt::Debug for SettingsFile<T> {
439 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
440 f.debug_struct("SettingsFile")
441 .field("path", &self.inner.path)
442 .field("current", &*self.inner.current.borrow())
443 .finish()
444 }
445}
446
447/// The file's `(modified time, byte length)` as of right now, or
448/// `(None, None)` if it doesn't exist (or the platform can't report a
449/// modification time). The pairing — not mtime alone — is deliberately
450/// used everywhere in this crate as the staleness / self-write-suppression
451/// stamp: some filesystems have coarse mtime resolution, and a length
452/// mismatch at an unchanged mtime (or vice versa) is still a reliable
453/// enough signal that *something* is different and a real comparison is
454/// warranted.
455pub(crate) fn disk_stamp(path: &Path) -> (Option<SystemTime>, Option<u64>) {
456 match fs::metadata(path) {
457 Ok(m) => (m.modified().ok(), Some(m.len())),
458 Err(_) => (None, None),
459 }
460}
461
462/// Read `path` as TOML, retrying a few times on parse failure (see
463/// [`MAX_READ_ATTEMPTS`]'s doc comment for why: atomic rename means a
464/// well-behaved peer should never hand us a torn write, but a brief retry
465/// is cheap insurance). Returns `Ok(None)` if the file does not exist.
466///
467/// Shared by every persisted type in this crate that reads raw TOML off
468/// disk before running its own migration ([`SettingsFile`],
469/// [`crate::collection::list::PersistedListModel`],
470/// [`crate::WindowStateService`]).
471pub(crate) fn read_toml_with_retry(path: &Path) -> Result<Option<toml::Value>, SettingsFileError> {
472 let mut last_parse_err = None;
473 for attempt in 0..MAX_READ_ATTEMPTS {
474 match fs::read_to_string(path) {
475 Ok(text) => match toml::from_str::<toml::Value>(&text) {
476 Ok(v) => return Ok(Some(v)),
477 Err(e) => {
478 last_parse_err = Some(e);
479 if attempt + 1 < MAX_READ_ATTEMPTS {
480 thread::sleep(READ_RETRY_DELAY);
481 }
482 }
483 },
484 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
485 Err(e) => return Err(SettingsFileError::Io(e)),
486 }
487 }
488 Err(SettingsFileError::Parse(last_parse_err.unwrap()))
489}
490
491/// Rename a broken/unrecoverable settings file out of the way so the next
492/// launch starts clean instead of repeatedly failing to load it. Shared by
493/// every persisted type in this crate that can hit an unrecoverable parse
494/// or migration error.
495pub(crate) fn quarantine(path: &Path) {
496 if !path.exists() {
497 return;
498 }
499 let ts = SystemTime::now()
500 .duration_since(UNIX_EPOCH)
501 .map(|d| d.as_secs())
502 .unwrap_or(0);
503 let mut quarantine_path = path.to_path_buf();
504 let new_name = match path.file_name() {
505 Some(name) => format!("{}.broken-{ts}", name.to_string_lossy()),
506 None => format!("settings.broken-{ts}"),
507 };
508 quarantine_path.set_file_name(new_name);
509 if let Err(e) = fs::rename(path, &quarantine_path) {
510 eprintln!(
511 "teksilo-settings: could not quarantine {} -> {}: {}",
512 path.display(),
513 quarantine_path.display(),
514 e,
515 );
516 }
517}
518
519#[cfg(test)]
520mod tests {
521 use super::*;
522 use serde::{Deserialize, Serialize};
523 use tempfile::tempdir;
524
525 #[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Default)]
526 struct Settings {
527 version: u32,
528 font_size: f32,
529 theme: String,
530 }
531 impl Versioned for Settings {
532 const CURRENT_VERSION: u32 = 1;
533 fn version(&self) -> u32 {
534 self.version
535 }
536 fn set_version(&mut self, v: u32) {
537 self.version = v;
538 }
539 }
540
541 #[test]
542 fn load_creates_default_when_file_missing() {
543 let dir = tempdir().unwrap();
544 let path = dir.path().join("missing.toml");
545 let file: SettingsFile<Settings> = SettingsFile::load(path, Migrator::new()).unwrap();
546 assert_eq!(
547 file.snapshot(),
548 Settings {
549 version: 1,
550 font_size: 0.0,
551 theme: String::new(),
552 }
553 );
554 }
555
556 #[test]
557 fn replace_persists_immediately() {
558 let dir = tempdir().unwrap();
559 let path = dir.path().join("s.toml");
560 let file: SettingsFile<Settings> =
561 SettingsFile::load(path.clone(), Migrator::new()).unwrap();
562
563 file.replace(Settings {
564 version: 1,
565 font_size: 16.0,
566 theme: "dark".into(),
567 })
568 .unwrap();
569
570 // No flush_now needed: replace() is synchronous.
571 let raw = fs::read_to_string(&path).unwrap();
572 let again: Settings = toml::from_str(&raw).unwrap();
573 assert_eq!(again.font_size, 16.0);
574 assert_eq!(again.theme, "dark");
575 }
576
577 #[test]
578 fn mutate_modifies_in_place() {
579 let dir = tempdir().unwrap();
580 let path = dir.path().join("s.toml");
581 let file: SettingsFile<Settings> = SettingsFile::load(path, Migrator::new()).unwrap();
582
583 file.mutate(|s| {
584 s.font_size = 22.0;
585 s.theme = "light".into();
586 })
587 .unwrap();
588
589 assert_eq!(file.snapshot().font_size, 22.0);
590 }
591
592 #[test]
593 fn corrupt_file_falls_back_to_default_and_quarantines() {
594 let dir = tempdir().unwrap();
595 let path = dir.path().join("broken.toml");
596 fs::write(&path, "this is = not valid TOML = at all").unwrap();
597
598 let file: SettingsFile<Settings> =
599 SettingsFile::load(path.clone(), Migrator::new()).unwrap();
600 assert_eq!(file.snapshot().version, 1);
601
602 // A genuine, unparsable-after-retries parse failure IS real
603 // corruption: the original path must be gone and a .broken-<ts>
604 // sibling must exist in its place. This proves the F2 fix did not
605 // regress the legitimate-corruption case while fixing the two
606 // illegitimate ones below.
607 assert!(
608 !path.exists(),
609 "the corrupt original should have been renamed away"
610 );
611 let entries: Vec<_> = fs::read_dir(dir.path())
612 .unwrap()
613 .filter_map(|e| e.ok())
614 .collect();
615 let has_quarantine = entries
616 .iter()
617 .any(|e| e.file_name().to_string_lossy().contains(".broken-"));
618 assert!(has_quarantine, "expected a .broken-<ts> file");
619 }
620
621 /// F2 regression: a file that parses fine but is on a schema version
622 /// this build's `Migrator` cannot handle (e.g. written by a *newer*
623 /// peer process) must NOT be quarantined — that would destroy the
624 /// peer's still-live, legitimate data. Before the fix, `load` treated
625 /// every `Err` from `read_or_default` (including `Migrate`) alike and
626 /// renamed the file away; this test fails on the old code (the
627 /// original path would no longer exist) and passes on the fix (the
628 /// file survives byte-for-byte, and the handle falls back to
629 /// `T::default()` in memory only, for this session).
630 #[test]
631 fn migration_failure_does_not_quarantine_a_peers_newer_schema() {
632 let dir = tempdir().unwrap();
633 let path = dir.path().join("newer_schema.toml");
634 // `Settings::CURRENT_VERSION` is 1; a `version = 99` document
635 // looks like it came from a much newer build. An empty
636 // `Migrator` has no idea how to bring version 99 down to 1 (it
637 // only walks forward), so `Migrator::run` reports
638 // `MigrationError::NewerThanCurrent`.
639 let original_contents = "version = 99\nfont_size = 12.0\ntheme = \"from-the-future\"\n";
640 fs::write(&path, original_contents).unwrap();
641
642 let file: SettingsFile<Settings> =
643 SettingsFile::load(path.clone(), Migrator::new()).unwrap();
644
645 // The handle falls back to in-memory defaults for this session
646 // (stamped to `CURRENT_VERSION`, exactly like every other
647 // fallback-to-default path in `load`)...
648 assert_eq!(
649 file.snapshot(),
650 Settings {
651 version: Settings::CURRENT_VERSION,
652 ..Settings::default()
653 }
654 );
655
656 // ...but the file on disk must be completely untouched: same
657 // path, same bytes, no .broken-<ts> sibling anywhere.
658 assert!(path.exists(), "the peer's file must not be renamed away");
659 let on_disk = fs::read_to_string(&path).unwrap();
660 assert_eq!(
661 on_disk, original_contents,
662 "the peer's file must be byte-identical before and after load()"
663 );
664 let entries: Vec<_> = fs::read_dir(dir.path())
665 .unwrap()
666 .filter_map(|e| e.ok())
667 .collect();
668 assert!(
669 !entries
670 .iter()
671 .any(|e| e.file_name().to_string_lossy().contains(".broken-")),
672 "a migration failure must never produce a quarantine sibling"
673 );
674 }
675
676 /// F2 regression: an I/O error means we never even read the file's
677 /// content, so there is no basis at all for judging it corrupt.
678 /// Before the fix, `load` quarantined (attempted to rename) the file
679 /// on any `Err`, including a plain I/O failure. Skipped when running
680 /// as root, since root ignores the read-permission bit and the setup
681 /// wouldn't actually reproduce an I/O error.
682 #[test]
683 #[cfg(unix)]
684 fn io_error_does_not_quarantine() {
685 use std::os::unix::fs::PermissionsExt;
686
687 let dir = tempdir().unwrap();
688 let path = dir.path().join("unreadable.toml");
689 fs::write(&path, "version = 1\nfont_size = 1.0\ntheme = \"x\"\n").unwrap();
690 fs::set_permissions(&path, fs::Permissions::from_mode(0o000)).unwrap();
691
692 // If we can still read it (e.g. running as root in CI), this
693 // setup doesn't reproduce the bug's precondition; skip rather
694 // than assert something meaningless.
695 if fs::read_to_string(&path).is_ok() {
696 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
697 return;
698 }
699
700 let file: SettingsFile<Settings> =
701 SettingsFile::load(path.clone(), Migrator::new()).unwrap();
702 assert_eq!(
703 file.snapshot(),
704 Settings {
705 version: Settings::CURRENT_VERSION,
706 ..Settings::default()
707 }
708 );
709
710 // Restore permissions so tempdir cleanup can remove the file.
711 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
712
713 assert!(path.exists(), "an unreadable file must not be renamed away");
714 let entries: Vec<_> = fs::read_dir(dir.path())
715 .unwrap()
716 .filter_map(|e| e.ok())
717 .collect();
718 assert!(
719 !entries
720 .iter()
721 .any(|e| e.file_name().to_string_lossy().contains(".broken-")),
722 "an I/O error must never produce a quarantine sibling"
723 );
724 }
725
726 #[test]
727 fn load_strict_propagates_parse_error() {
728 let dir = tempdir().unwrap();
729 let path = dir.path().join("broken.toml");
730 fs::write(&path, "= = =").unwrap();
731
732 let result: Result<SettingsFile<Settings>, _> =
733 SettingsFile::load_strict(path, Migrator::new());
734 assert!(matches!(result, Err(SettingsFileError::Parse(_))));
735 }
736
737 #[test]
738 fn clones_share_state() {
739 let dir = tempdir().unwrap();
740 let path = dir.path().join("s.toml");
741 let a: SettingsFile<Settings> = SettingsFile::load(path, Migrator::new()).unwrap();
742 let b = a.clone();
743
744 a.mutate(|s| s.font_size = 99.0).unwrap();
745 assert_eq!(b.snapshot().font_size, 99.0);
746 }
747
748 /// THE HEADLINE TEST. Two independent `SettingsFile::load` handles over
749 /// the *same* path — standing in for two Skribisto processes sharing
750 /// `backup.toml` — each mutate a *different* field. Because the locked
751 /// read-modify-write re-reads the file fresh from disk under the lock
752 /// before applying each change, both changes must survive: neither
753 /// handle's stale in-memory snapshot gets a chance to clobber the
754 /// other's write.
755 #[test]
756 fn two_concurrent_handles_both_writes_survive() {
757 let dir = tempdir().unwrap();
758 let path = dir.path().join("shared.toml");
759
760 let a: SettingsFile<Settings> = SettingsFile::load(path.clone(), Migrator::new()).unwrap();
761 let b: SettingsFile<Settings> = SettingsFile::load(path.clone(), Migrator::new()).unwrap();
762
763 a.mutate(|s| s.font_size = 42.0).unwrap();
764 b.mutate(|s| s.theme = "solarized".into()).unwrap();
765
766 // A third, fresh handle proves both writes are actually on disk
767 // together, not just cached in `a`'s or `b`'s memory.
768 let c: SettingsFile<Settings> = SettingsFile::load(path.clone(), Migrator::new()).unwrap();
769 let snapshot = c.snapshot();
770 assert_eq!(snapshot.font_size, 42.0, "a's write must survive");
771 assert_eq!(snapshot.theme, "solarized", "b's write must survive");
772
773 // And what actually landed on disk (not just what a fresh load
774 // produces) carries both fields too.
775 let raw = fs::read_to_string(&path).unwrap();
776 let on_disk: Settings = toml::from_str(&raw).unwrap();
777 assert_eq!(on_disk.font_size, 42.0);
778 assert_eq!(on_disk.theme, "solarized");
779 }
780
781 /// Bug-repro, updated for the single-mode design: this exact scenario
782 /// — two handles opened over the same path, each mutating a different
783 /// field with no explicit flush/reload between them — used to lose
784 /// data in the old dual-mode design's *default* mode (debounced
785 /// schedule of a whole re-serialized snapshot, with no re-read under a
786 /// lock). Now that the locked read-modify-write is the *only* mode,
787 /// the same sequence must no longer clobber anything.
788 #[test]
789 fn two_non_synchronized_handles_no_longer_clobber_each_other() {
790 let dir = tempdir().unwrap();
791 let path = dir.path().join("nonshared.toml");
792
793 let a: SettingsFile<Settings> = SettingsFile::load(path.clone(), Migrator::new()).unwrap();
794 let b: SettingsFile<Settings> = SettingsFile::load(path.clone(), Migrator::new()).unwrap();
795
796 a.mutate(|s| s.font_size = 42.0).unwrap();
797
798 // Before the fix, b's in-memory snapshot still had font_size ==
799 // 0.0 from its own `load` — it never re-read a's write, and its
800 // debounced write re-serialized *that* stale snapshot (with b's
801 // own change layered on top), overwriting a's change on disk.
802 // Now, b's mutate re-reads fresh from disk under the lock first.
803 b.mutate(|s| s.theme = "solarized".into()).unwrap();
804
805 let raw = fs::read_to_string(&path).unwrap();
806 let on_disk: Settings = toml::from_str(&raw).unwrap();
807 assert_eq!(on_disk.theme, "solarized", "b's own write is present");
808 assert_eq!(
809 on_disk.font_size, 42.0,
810 "a's write must survive b's later, unrelated mutate"
811 );
812 }
813
814 #[test]
815 fn reload_if_stale_picks_up_a_peers_write_and_reports_no_change_when_unchanged() {
816 let dir = tempdir().unwrap();
817 let path = dir.path().join("reload.toml");
818
819 let a: SettingsFile<Settings> = SettingsFile::load(path.clone(), Migrator::new()).unwrap();
820 let b: SettingsFile<Settings> = SettingsFile::load(path.clone(), Migrator::new()).unwrap();
821
822 // Nothing has changed since both handles were constructed.
823 assert!(!b.reload_if_stale().unwrap());
824
825 a.mutate(|s| s.font_size = 7.0).unwrap();
826
827 assert!(b.reload_if_stale().unwrap(), "b should notice a's write");
828 assert_eq!(b.snapshot().font_size, 7.0);
829
830 // Calling again with no further changes reports nothing new.
831 assert!(!b.reload_if_stale().unwrap());
832 }
833
834 #[test]
835 fn round_trips_versioned_migration() {
836 #[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Default)]
837 struct Prefs {
838 version: u32,
839 name: String,
840 pinned: bool,
841 }
842 impl Versioned for Prefs {
843 const CURRENT_VERSION: u32 = 2;
844 fn version(&self) -> u32 {
845 self.version
846 }
847 fn set_version(&mut self, v: u32) {
848 self.version = v;
849 }
850 }
851
852 let dir = tempdir().unwrap();
853 let path = dir.path().join("migrated.toml");
854 // A legacy v1 file, missing the v2 `pinned` field.
855 fs::write(&path, "version = 1\nname = \"legacy\"\n").unwrap();
856
857 let migrator: Migrator<Prefs> = Migrator::new().step(1, |mut v| {
858 if let Some(t) = v.as_table_mut() {
859 t.insert("pinned".into(), toml::Value::Boolean(true));
860 }
861 Ok(v)
862 });
863
864 let file: SettingsFile<Prefs> = SettingsFile::load(path.clone(), migrator).unwrap();
865 let snapshot = file.snapshot();
866 assert_eq!(snapshot.version, 2);
867 assert_eq!(snapshot.name, "legacy");
868 assert!(snapshot.pinned);
869
870 // A subsequent locked mutate must also preserve the migrated
871 // shape (not just the initial load).
872 file.mutate(|p| p.name = "renamed".into()).unwrap();
873 let raw = fs::read_to_string(&path).unwrap();
874 let on_disk: Prefs = toml::from_str(&raw).unwrap();
875 assert_eq!(on_disk.version, 2);
876 assert_eq!(on_disk.name, "renamed");
877 assert!(on_disk.pinned);
878 }
879
880 #[test]
881 fn replace_also_uses_the_locked_path() {
882 let dir = tempdir().unwrap();
883 let path = dir.path().join("replace.toml");
884
885 let a: SettingsFile<Settings> = SettingsFile::load(path.clone(), Migrator::new()).unwrap();
886 let b: SettingsFile<Settings> = SettingsFile::load(path.clone(), Migrator::new()).unwrap();
887
888 a.mutate(|s| s.font_size = 5.0).unwrap();
889 b.replace(Settings {
890 version: 1,
891 font_size: 9.0,
892 theme: "replaced".into(),
893 })
894 .unwrap();
895
896 let raw = fs::read_to_string(&path).unwrap();
897 let on_disk: Settings = toml::from_str(&raw).unwrap();
898 assert_eq!(on_disk.font_size, 9.0);
899 assert_eq!(on_disk.theme, "replaced");
900 }
901
902 #[test]
903 fn flush_now_is_a_harmless_no_op() {
904 let dir = tempdir().unwrap();
905 let path = dir.path().join("flush.toml");
906 let file: SettingsFile<Settings> = SettingsFile::load(path, Migrator::new()).unwrap();
907
908 file.mutate(|s| s.font_size = 1.0).unwrap();
909 // Already durably written by `mutate`'s synchronous locked
910 // write; `flush_now` must not error even though nothing is ever
911 // pending — this type never registers with the shared
912 // debounced-write worker pool at all any more.
913 file.flush_now().unwrap();
914 }
915
916 /// F13 regression: `flush_now` must be a genuine, unconditional no-op
917 /// — never touching the shared debounced-write worker pool at all —
918 /// even while a *different* `SettingsFile` handle pointed at the same
919 /// path is concurrently `mutate`-ing (i.e. holding the file lock).
920 /// Before the fix, `flush_now` forwarded to a real
921 /// `DebouncedWriter::flush_now`, which round-trips through the shared
922 /// worker thread; that write path is entirely gone now, so this must
923 /// return `Ok(())` immediately regardless of what any other handle
924 /// (or the file lock) is doing.
925 #[test]
926 fn flush_now_never_touches_the_shared_worker_even_under_concurrent_mutate() {
927 let dir = tempdir().unwrap();
928 let path = dir.path().join("no_worker.toml");
929
930 let a: SettingsFile<Settings> = SettingsFile::load(path.clone(), Migrator::new()).unwrap();
931 let b: SettingsFile<Settings> = SettingsFile::load(path, Migrator::new()).unwrap();
932
933 a.mutate(|s| s.font_size = 5.0).unwrap();
934
935 // `b` never registered a writer with the shared pool (there is
936 // none any more), so `flush_now` on `b` has nothing to wait on
937 // and nothing to fail, no matter what `a` just did to the same
938 // file.
939 assert!(b.flush_now().is_ok());
940 assert!(a.flush_now().is_ok());
941 }
942
943 /// F13 regression: constructing and dropping a `SettingsFile` must be
944 /// cheap. Before the fix, `new_inner` registered a `DebouncedWriter`
945 /// with the shared worker pool, whose `Drop` blocks on a synchronous
946 /// ack round-trip through that thread — for a queue that was always
947 /// empty here. A tight loop of construct/drop would pay that
948 /// round-trip cost 1000 times. This is a coarse regression guard: a
949 /// generous wall-clock bound that the old behavior could plausibly
950 /// blow (thread-pool ack round-trips are not free) and the fix
951 /// trivially satisfies (no worker registration happens at all).
952 #[test]
953 fn construct_and_drop_is_cheap_in_a_tight_loop() {
954 let dir = tempdir().unwrap();
955 let path = dir.path().join("drop_timing.toml");
956
957 let start = std::time::Instant::now();
958 for _ in 0..1000 {
959 let file: SettingsFile<Settings> =
960 SettingsFile::load(path.clone(), Migrator::new()).unwrap();
961 drop(file);
962 }
963 let elapsed = start.elapsed();
964
965 assert!(
966 elapsed < std::time::Duration::from_secs(5),
967 "1000 construct/drop cycles took {elapsed:?}; \
968 this type must never register with the shared worker pool"
969 );
970 }
971
972 // -----------------------------------------------------------------
973 // Reloadable
974 // -----------------------------------------------------------------
975
976 #[test]
977 fn reload_from_disk_picks_up_a_peers_write() {
978 let dir = tempdir().unwrap();
979 let path = dir.path().join("reloadable.toml");
980
981 let a: SettingsFile<Settings> = SettingsFile::load(path.clone(), Migrator::new()).unwrap();
982 let b: SettingsFile<Settings> = SettingsFile::load(path.clone(), Migrator::new()).unwrap();
983
984 a.mutate(|s| s.theme = "peer-write".into()).unwrap();
985
986 assert!(Reloadable::reload_from_disk(&b).unwrap());
987 assert_eq!(b.snapshot().theme, "peer-write");
988 }
989
990 #[test]
991 fn reload_from_disk_returns_false_and_touches_nothing_when_content_is_unchanged() {
992 let dir = tempdir().unwrap();
993 let path = dir.path().join("unchanged.toml");
994
995 let a: SettingsFile<Settings> = SettingsFile::load(path.clone(), Migrator::new()).unwrap();
996
997 // Nothing changed at all: cheap stamp check short-circuits.
998 assert!(!Reloadable::reload_from_disk(&a).unwrap());
999
1000 // Write byte-identical content via a second handle (same value,
1001 // forces a real disk write and a new stamp) — the content
1002 // backstop must still report no change and must not touch `a`'s
1003 // in-memory value between the read and the comparison.
1004 let b: SettingsFile<Settings> = SettingsFile::load(path.clone(), Migrator::new()).unwrap();
1005 b.replace(a.snapshot()).unwrap();
1006
1007 assert!(!Reloadable::reload_from_disk(&a).unwrap());
1008 }
1009
1010 #[test]
1011 fn reload_from_disk_self_write_suppression_no_reparse_needed_after_own_mutate() {
1012 let dir = tempdir().unwrap();
1013 let path = dir.path().join("self_write.toml");
1014 let a: SettingsFile<Settings> = SettingsFile::load(path, Migrator::new()).unwrap();
1015
1016 a.mutate(|s| s.font_size = 3.0).unwrap();
1017 // The stamp was refreshed synchronously inside `mutate`'s locked
1018 // write, so a watcher calling reload_from_disk right after our
1019 // own write sees a matching stamp and bails via the cheap check.
1020 assert!(!Reloadable::reload_from_disk(&a).unwrap());
1021 assert_eq!(a.snapshot().font_size, 3.0);
1022 }
1023
1024 #[test]
1025 fn reload_from_disk_also_works_through_the_path_method() {
1026 let dir = tempdir().unwrap();
1027 let path = dir.path().join("path.toml");
1028 let a: SettingsFile<Settings> = SettingsFile::load(path.clone(), Migrator::new()).unwrap();
1029 assert_eq!(Reloadable::path(&a), path.as_path());
1030 }
1031}