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 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}