Skip to main content

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}