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