Skip to main content

teksilo_settings/collection/
list.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! [`PersistedListModel<T>`] — bridge between a reactive
5//! [`ListModel<T>`](teksilo_data::ListModel) and a single TOML file,
6//! merging by **op**, not by whole-document snapshot.
7//!
8//! ## Why ops, not snapshots
9//!
10//! The previous design re-derived the *entire* `Vec<T>` from the live model
11//! on every mutation and scheduled a debounced write of that whole
12//! snapshot. That is last-write-wins by construction: if a peer process
13//! added an entry to the same file in the meantime, this process's next
14//! flush would overwrite the peer's row right off the disk — the exact
15//! "a newly-opened project vanishes from Recents" bug this crate exists to
16//! fix.
17//!
18//! Instead, every mutation records a small, **replayable** [`ListOp<T>`]
19//! and hands it to the shared debounced writer as a [`crate::flush::Patch`]:
20//! "given the file's current text, apply this one op to it." The patch is
21//! applied against the document read **fresh off disk, under a lock**, at
22//! flush time — so it replays cleanly on top of whatever a peer wrote in
23//! the meantime, key by key, instead of overwriting the whole thing.
24//!
25//! ## Identity
26//!
27//! Every item needs a stable identity to merge by — see [`Keyed`]. Ops are
28//! keyed, not indexed: `Remove` only needs to carry a key, never a value,
29//! which is exactly what a diff of "what's gone" can always produce even
30//! though the value itself is no longer available once removed.
31//!
32//! ## Mutating through this type, not through `.model()`
33//!
34//! `.model()` is for **reading** and for reactive binding (`ListView` /
35//! `Repeater`) — every UI observer wants live updates regardless of who
36//! mutates. Writing must go through [`upsert_front`](PersistedListModel::upsert_front),
37//! [`update_in_place`](PersistedListModel::update_in_place),
38//! [`remove`](PersistedListModel::remove) and
39//! [`clear`](PersistedListModel::clear): those are the only places that both
40//! mutate the live model *and* enqueue the matching op. Mutating the
41//! `ListModel` returned by `.model()` directly updates what's on screen but
42//! is never persisted — there is no observer bridging arbitrary model
43//! mutations to disk any more (that observer *was* the whole-snapshot
44//! overwrite bug).
45//!
46//! ## Example
47//!
48//! ```
49//! use teksilo_settings::{Keyed, Migrator, PersistedListModel};
50//! use serde::{Deserialize, Serialize};
51//! use std::time::Duration;
52//!
53//! #[derive(Serialize, Deserialize, Clone)]
54//! struct Tag { name: String }
55//!
56//! impl Keyed for Tag {
57//!     type Key = String;
58//!     fn key(&self) -> String { self.name.clone() }
59//! }
60//!
61//! let path = std::env::temp_dir().join("tags-list-doctest.toml");
62//! let plm: PersistedListModel<Tag> =
63//!     PersistedListModel::open(path, Duration::ZERO, Migrator::new())
64//!         .expect("open failed");
65//! plm.upsert_front(Tag { name: "rust".into() });
66//! plm.flush_now().expect("flush");
67//! ```
68
69use std::hash::Hash;
70use std::path::{Path, PathBuf};
71use std::time::{Duration, SystemTime};
72
73use serde::de::DeserializeOwned;
74use serde::{Deserialize, Serialize};
75use teksilo_data::ListModel;
76
77use crate::file::{SettingsFileError, disk_stamp, quarantine, read_toml_with_retry};
78use crate::flush::{DebouncedWriter, FlushError};
79use crate::lock::FileLock;
80use crate::migration::{Migrator, Versioned};
81use crate::reload::Reloadable;
82
83/// An item with a stable, owned identity — the merge key
84/// [`PersistedListModel`] dedupes and diffs by.
85///
86/// `Key` is owned (not borrowed, unlike the old `MruEntry::Key: ?Sized`
87/// shape) because it must be captured into a `Patch` (`crate::flush::Patch`)
88/// closure that crosses to the shared I/O worker thread — a borrow into
89/// `T` cannot outlive the mutation call that produced it.
90pub trait Keyed {
91    /// The key type. Typically `String` / `PathBuf` / a small `Copy` id.
92    type Key: Eq + Hash + Clone + Send + 'static;
93
94    /// This item's identity. Returned by value: cheap for the small key
95    /// types this is meant for (clone a `String`/`PathBuf`/id), and it
96    /// sidesteps borrow-lifetime issues entirely.
97    fn key(&self) -> Self::Key;
98}
99
100/// A replayable mutation of a [`PersistedListModel`]'s backing list,
101/// expressed **by key** so it can be applied to *any* starting `Vec<T>` —
102/// in particular, the fresh one read off disk at flush time, which may
103/// already include a peer process's concurrent changes.
104#[derive(Debug, Clone)]
105pub enum ListOp<T: Keyed> {
106    /// Remove any existing entry with this item's key, then insert `T` at
107    /// the front. This is the "most recently used" operation: re-running
108    /// it against any starting vector — including one a peer has already
109    /// mutated — reproduces the same dedupe-and-promote-to-front
110    /// invariant `MruList::add` relies on.
111    UpsertFront(T),
112    /// Replace the entry with this item's key **in place** (no
113    /// reordering). A no-op if the key is no longer present — e.g. a peer
114    /// concurrently removed it, in which case that removal wins.
115    UpdateInPlace(T),
116    /// Remove the entry with this key, if present. No-op otherwise.
117    Remove(T::Key),
118    /// Drop every entry.
119    Clear,
120}
121
122/// On-disk shape for a persisted list: a versioned wrapper around
123/// `Vec<T>`. Apps write migrations against this type, not the bare `Vec`.
124#[derive(Serialize, Deserialize, Debug, Clone)]
125pub struct ListFile<T> {
126    /// Schema version, matched against [`Versioned::CURRENT_VERSION`] on
127    /// load to run any registered migrations before deserialization.
128    #[serde(default = "default_version")]
129    pub version: u32,
130    /// The ordered list of items as stored on disk.
131    #[serde(default = "Vec::new")]
132    pub items: Vec<T>,
133}
134
135fn default_version() -> u32 {
136    1
137}
138
139impl<T> Default for ListFile<T> {
140    fn default() -> Self {
141        Self {
142            version: 1,
143            items: Vec::new(),
144        }
145    }
146}
147
148/// `T` doesn't carry a version itself; the wrapper does. This impl is
149/// parameterized on the version a particular app uses by way of the
150/// `Versioned for ListFile<T>` instance the app produces. We provide a
151/// default `CURRENT_VERSION = 1`; apps that bump the schema replace
152/// the impl via newtype.
153impl<T: 'static> Versioned for ListFile<T> {
154    const CURRENT_VERSION: u32 = 1;
155    fn version(&self) -> u32 {
156        self.version
157    }
158    fn set_version(&mut self, v: u32) {
159        self.version = v;
160    }
161}
162
163/// A reactive, [`Keyed`]-item list whose mutations persist to a single
164/// TOML file by merging **ops**, not by overwriting a whole-document
165/// snapshot.
166pub struct PersistedListModel<T>
167where
168    T: Keyed + Clone + Serialize + DeserializeOwned + Send + 'static,
169{
170    model: ListModel<T>,
171    writer: DebouncedWriter,
172    /// Retained for the handle's whole lifetime: every op-patch re-reads
173    /// and re-migrates the on-disk document fresh (a peer might still be
174    /// on an older schema), not just the one read at construction.
175    migrator: Migrator<ListFile<T>>,
176    /// `(mtime, len)` as of the last time this handle read or wrote the
177    /// file — the cheap staleness / self-write-suppression stamp behind
178    /// [`Reloadable::reload_from_disk`].
179    last_known_stamp: std::cell::Cell<(Option<SystemTime>, Option<u64>)>,
180}
181
182impl<T> PersistedListModel<T>
183where
184    T: Keyed + Clone + Serialize + DeserializeOwned + Send + 'static,
185{
186    /// Open the file at `path` (running `migrator`, under an exclusive
187    /// lock so a peer mid-write can't hand us a torn read), seed the model
188    /// from its contents, and retain everything needed to enqueue op
189    /// patches on every mutation.
190    ///
191    /// `delay` is the debounce window for writes — unlike
192    /// [`crate::SettingsFile`], this type's writes are expected to be
193    /// frequent (every `add`/`touch`/`remove` on a live MRU list), so the
194    /// debounce is real and load-bearing here, not vestigial.
195    pub fn open(
196        path: PathBuf,
197        delay: Duration,
198        migrator: Migrator<ListFile<T>>,
199    ) -> Result<Self, SettingsFileError> {
200        let lock = FileLock::acquire_exclusive(&path).map_err(SettingsFileError::Io)?;
201        let file = match read_list_or_default(&path, &migrator) {
202            Ok(f) => f,
203            Err(other) => {
204                quarantine(&path);
205                eprintln!(
206                    "teksilo-settings: load failed for {}: {}; falling back to an empty list",
207                    path.display(),
208                    other,
209                );
210                ListFile::default()
211            }
212        };
213        let stamp = disk_stamp(&path);
214        drop(lock);
215
216        let model = ListModel::from_vec(file.items);
217        let writer = DebouncedWriter::new(path, delay);
218
219        Ok(Self {
220            model,
221            writer,
222            migrator,
223            last_known_stamp: std::cell::Cell::new(stamp),
224        })
225    }
226
227    /// The underlying reactive list handle. Clone it to share with
228    /// `Repeater` / `ListView` widgets for **reading**. See the module
229    /// docs: mutating the returned handle directly does not persist —
230    /// use this type's own mutation methods instead.
231    pub fn model(&self) -> &ListModel<T> {
232        &self.model
233    }
234
235    /// Insert `item` at the front, deduping by `item.key()` (removing any
236    /// existing entry with the same key first). Updates the live model
237    /// immediately and enqueues the matching [`ListOp::UpsertFront`].
238    pub fn upsert_front(&self, item: T) {
239        if let Some(idx) = self.find_index(&item.key()) {
240            self.model.remove(idx);
241        }
242        self.model.insert(0, item.clone());
243        self.schedule_op(ListOp::UpsertFront(item));
244    }
245
246    /// Replace the entry with `item.key()` **in place** (no reordering).
247    /// Returns `false` (and does nothing) if no entry with that key
248    /// exists locally. Enqueues [`ListOp::UpdateInPlace`] on success.
249    pub fn update_in_place(&self, item: T) -> bool {
250        let Some(idx) = self.find_index(&item.key()) else {
251            return false;
252        };
253        self.model.set(idx, item.clone());
254        self.schedule_op(ListOp::UpdateInPlace(item));
255        true
256    }
257
258    /// Remove the entry with this key, if present locally. Returns
259    /// whether anything was removed. Enqueues [`ListOp::Remove`] on
260    /// success.
261    pub fn remove(&self, key: &T::Key) -> bool {
262        let Some(idx) = self.find_index(key) else {
263            return false;
264        };
265        self.model.remove(idx);
266        self.schedule_op(ListOp::Remove(key.clone()));
267        true
268    }
269
270    /// Drop every entry, locally and on disk.
271    pub fn clear(&self) {
272        self.model.clear();
273        self.schedule_op(ListOp::Clear);
274    }
275
276    /// Flush any pending op(s) to disk immediately, bypassing the
277    /// debounce window. Flushes the **op queue** — never a re-derived
278    /// snapshot of the in-memory list, which is exactly the mechanism
279    /// that used to let a cleanly-exiting process erase a peer's
280    /// newly-added entry.
281    pub fn flush_now(&self) -> Result<(), SettingsFileError> {
282        self.writer.flush_now().map_err(SettingsFileError::Flush)?;
283        self.last_known_stamp.set(disk_stamp(self.writer.path()));
284        Ok(())
285    }
286
287    /// The absolute path of the TOML file being written to.
288    pub fn path(&self) -> &Path {
289        self.writer.path()
290    }
291
292    fn find_index(&self, key: &T::Key) -> Option<usize> {
293        let model = &self.model;
294        (0..model.len()).find(|&i| model.with_item(i, |t| t.key() == *key).unwrap_or(false))
295    }
296
297    fn schedule_op(&self, op: ListOp<T>) {
298        let migrator = self.migrator.clone();
299        let patch: crate::flush::Patch = Box::new(move |current: Option<String>| {
300            let file = parse_list_file_text(current.as_deref(), &migrator)
301                .map_err(|e| FlushError::Merge(e.to_string()))?;
302            let mut items = file.items;
303            apply_list_op(&mut items, &op);
304            let new_file = ListFile {
305                version: <ListFile<T> as Versioned>::CURRENT_VERSION,
306                items,
307            };
308            toml::to_string_pretty(&new_file).map_err(|e| FlushError::Merge(e.to_string()))
309        });
310        self.writer.schedule(patch);
311    }
312}
313
314/// Apply a single [`ListOp`] to `items` (the freshly-read-from-disk
315/// vector), by key. This is the actual merge: it never looks at what this
316/// process's in-memory list looked like, only at `items` as given.
317fn apply_list_op<T: Keyed + Clone>(items: &mut Vec<T>, op: &ListOp<T>) {
318    match op {
319        ListOp::UpsertFront(item) => {
320            let key = item.key();
321            items.retain(|t| t.key() != key);
322            items.insert(0, item.clone());
323        }
324        ListOp::UpdateInPlace(item) => {
325            let key = item.key();
326            if let Some(slot) = items.iter_mut().find(|t| t.key() == key) {
327                *slot = item.clone();
328            }
329            // No-op if the key is gone — a peer's concurrent removal wins.
330        }
331        ListOp::Remove(key) => {
332            items.retain(|t| t.key() != *key);
333        }
334        ListOp::Clear => {
335            items.clear();
336        }
337    }
338}
339
340/// Read `path`'s TOML (retrying on transient parse failure) and run
341/// `migrator`, falling back to an empty [`ListFile`] if the file is
342/// absent.
343fn read_list_or_default<T>(
344    path: &Path,
345    migrator: &Migrator<ListFile<T>>,
346) -> Result<ListFile<T>, SettingsFileError>
347where
348    T: Clone + Serialize + DeserializeOwned + 'static,
349{
350    match read_toml_with_retry(path)? {
351        Some(raw) => {
352            let mut file = migrator.run(raw).map_err(SettingsFileError::Migrate)?;
353            file.version = <ListFile<T> as Versioned>::CURRENT_VERSION;
354            Ok(file)
355        }
356        None => Ok(ListFile::default()),
357    }
358}
359
360/// Like [`read_list_or_default`], but parses already-in-hand text (used
361/// from inside a [`crate::flush::Patch`] closure, which receives the
362/// current text directly rather than a path to re-read) instead of a
363/// missing file falling back on `NotFound` — `None` (no file yet) is
364/// handled the same way either way.
365fn parse_list_file_text<T>(
366    text: Option<&str>,
367    migrator: &Migrator<ListFile<T>>,
368) -> Result<ListFile<T>, SettingsFileError>
369where
370    T: Clone + Serialize + DeserializeOwned + 'static,
371{
372    match text {
373        Some(text) => {
374            let raw: toml::Value = toml::from_str(text).map_err(SettingsFileError::Parse)?;
375            let mut file = migrator.run(raw).map_err(SettingsFileError::Migrate)?;
376            file.version = <ListFile<T> as Versioned>::CURRENT_VERSION;
377            Ok(file)
378        }
379        None => Ok(ListFile::default()),
380    }
381}
382
383impl<T> Reloadable for PersistedListModel<T>
384where
385    T: Keyed + Clone + Serialize + DeserializeOwned + Send + PartialEq + 'static,
386{
387    fn path(&self) -> &Path {
388        PersistedListModel::path(self)
389    }
390
391    fn reload_from_disk(&self) -> Result<bool, SettingsFileError> {
392        // Flush OUR OWN pending queue before reading, so a peer's write
393        // landing mid-debounce can never make the model transiently drop
394        // a local, not-yet-flushed change (F14): without this, a fresh
395        // read here would reflect the peer's write but NOT our own
396        // still-queued op, and reconciling down to that snapshot would
397        // visibly revert the user's just-performed action until our own
398        // debounced write landed moments later on its own.
399        //
400        // Deliberately bypasses the public `flush_now()` wrapper: that
401        // wrapper also unconditionally restamps `last_known_stamp` to
402        // "disk state right now", which here would make the staleness
403        // check just below always pass and skip the read this function
404        // exists to perform — silently discarding the very peer write
405        // that triggered the reload. Calling `self.writer.flush_now()`
406        // directly flushes our queue without touching the stamp, so the
407        // existing comparison below runs against the OLD (pre-flush)
408        // stamp, correctly detects the change, and proceeds into a real
409        // read+reconcile that sees peer and ours already merged (the
410        // locked read-merge-write inside the op patch guarantees that).
411        if let Err(e) = self.writer.flush_now() {
412            eprintln!(
413                "teksilo-settings: pre-reload flush of {} failed: {e}; reloading anyway",
414                self.writer.path().display(),
415            );
416        }
417
418        let path = self.writer.path();
419        let current_stamp = disk_stamp(path);
420        if current_stamp == self.last_known_stamp.get() {
421            return Ok(false);
422        }
423
424        let file = read_list_or_default(path, &self.migrator)?;
425        self.last_known_stamp.set(current_stamp);
426
427        let current: Vec<T> = (0..self.model.len())
428            .filter_map(|i| self.model.with_item(i, |t| t.clone()))
429            .collect();
430        if current == file.items {
431            return Ok(false);
432        }
433
434        self.model.reconcile_by_key(file.items, |t| t.key());
435        Ok(true)
436    }
437}
438
439impl<T> std::fmt::Debug for PersistedListModel<T>
440where
441    T: Keyed + Clone + Serialize + DeserializeOwned + Send + 'static,
442{
443    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
444        f.debug_struct("PersistedListModel")
445            .field("path", &self.writer.path())
446            .field("len", &self.model.len())
447            .finish()
448    }
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454    use serde::{Deserialize, Serialize};
455    use std::collections::HashSet;
456    use std::fs;
457    use tempfile::tempdir;
458
459    #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
460    struct Item {
461        name: String,
462        count: i32,
463    }
464
465    impl Keyed for Item {
466        type Key = String;
467        fn key(&self) -> String {
468            self.name.clone()
469        }
470    }
471
472    fn item(name: &str, count: i32) -> Item {
473        Item {
474            name: name.into(),
475            count,
476        }
477    }
478
479    #[test]
480    fn fresh_file_starts_empty() {
481        let dir = tempdir().unwrap();
482        let path = dir.path().join("list.toml");
483        let plm: PersistedListModel<Item> =
484            PersistedListModel::open(path, Duration::ZERO, Migrator::new()).unwrap();
485        assert_eq!(plm.model().len(), 0);
486    }
487
488    #[test]
489    fn upsert_front_persists_and_reopens() {
490        let dir = tempdir().unwrap();
491        let path = dir.path().join("list.toml");
492
493        {
494            let plm: PersistedListModel<Item> =
495                PersistedListModel::open(path.clone(), Duration::ZERO, Migrator::new()).unwrap();
496            plm.upsert_front(item("a", 1));
497            plm.upsert_front(item("b", 2));
498            plm.flush_now().unwrap();
499        }
500
501        let plm: PersistedListModel<Item> =
502            PersistedListModel::open(path, Duration::ZERO, Migrator::new()).unwrap();
503        assert_eq!(plm.model().len(), 2);
504        assert_eq!(
505            plm.model().with_item(0, |x| x.clone()).unwrap(),
506            item("b", 2)
507        );
508        assert_eq!(
509            plm.model().with_item(1, |x| x.clone()).unwrap(),
510            item("a", 1)
511        );
512    }
513
514    #[test]
515    fn upsert_front_dedupes_by_key() {
516        let dir = tempdir().unwrap();
517        let path = dir.path().join("list.toml");
518        let plm: PersistedListModel<Item> =
519            PersistedListModel::open(path, Duration::ZERO, Migrator::new()).unwrap();
520
521        plm.upsert_front(item("a", 1));
522        plm.upsert_front(item("b", 2));
523        plm.upsert_front(item("a", 99));
524
525        assert_eq!(plm.model().len(), 2);
526        assert_eq!(
527            plm.model().with_item(0, |x| x.clone()).unwrap(),
528            item("a", 99)
529        );
530        assert_eq!(
531            plm.model().with_item(1, |x| x.clone()).unwrap(),
532            item("b", 2)
533        );
534    }
535
536    #[test]
537    fn update_in_place_does_not_reorder() {
538        let dir = tempdir().unwrap();
539        let path = dir.path().join("list.toml");
540        let plm: PersistedListModel<Item> =
541            PersistedListModel::open(path, Duration::ZERO, Migrator::new()).unwrap();
542
543        plm.upsert_front(item("a", 1));
544        plm.upsert_front(item("b", 2));
545        assert!(plm.update_in_place(item("a", 42)));
546
547        assert_eq!(
548            plm.model().with_item(0, |x| x.clone()).unwrap(),
549            item("b", 2)
550        );
551        assert_eq!(
552            plm.model().with_item(1, |x| x.clone()).unwrap(),
553            item("a", 42)
554        );
555    }
556
557    #[test]
558    fn update_in_place_returns_false_for_missing_key() {
559        let dir = tempdir().unwrap();
560        let path = dir.path().join("list.toml");
561        let plm: PersistedListModel<Item> =
562            PersistedListModel::open(path, Duration::ZERO, Migrator::new()).unwrap();
563        assert!(!plm.update_in_place(item("ghost", 0)));
564    }
565
566    #[test]
567    fn remove_drops_entry_and_persists() {
568        let dir = tempdir().unwrap();
569        let path = dir.path().join("list.toml");
570        let plm: PersistedListModel<Item> =
571            PersistedListModel::open(path.clone(), Duration::ZERO, Migrator::new()).unwrap();
572
573        plm.upsert_front(item("a", 1));
574        plm.upsert_front(item("b", 2));
575        assert!(plm.remove(&"a".to_string()));
576        plm.flush_now().unwrap();
577
578        let raw = fs::read_to_string(&path).unwrap();
579        let parsed: ListFile<Item> = toml::from_str(&raw).unwrap();
580        assert_eq!(parsed.items.len(), 1);
581        assert_eq!(parsed.items[0].name, "b");
582    }
583
584    #[test]
585    fn clear_empties_and_persists() {
586        let dir = tempdir().unwrap();
587        let path = dir.path().join("list.toml");
588        let plm: PersistedListModel<Item> =
589            PersistedListModel::open(path.clone(), Duration::ZERO, Migrator::new()).unwrap();
590        plm.upsert_front(item("a", 1));
591        plm.clear();
592        plm.flush_now().unwrap();
593
594        let raw = fs::read_to_string(&path).unwrap();
595        let parsed: ListFile<Item> = toml::from_str(&raw).unwrap();
596        assert!(parsed.items.is_empty());
597    }
598
599    // -----------------------------------------------------------------
600    // THE HEADLINE TEST — the merge, not the snapshot.
601    // -----------------------------------------------------------------
602
603    /// Two independent handles over the *same* file — standing in for two
604    /// Skribisto processes sharing `recents.toml` — each `upsert_front` a
605    /// *different* entry with no coordination between them. Because every
606    /// op merges against the document read fresh under the lock at flush
607    /// time, both entries must survive: neither handle's op can see, let
608    /// alone erase, the other's addition.
609    #[test]
610    fn two_concurrent_handles_each_adding_a_different_entry_both_survive() {
611        let dir = tempdir().unwrap();
612        let path = dir.path().join("shared_list.toml");
613
614        let a: PersistedListModel<Item> =
615            PersistedListModel::open(path.clone(), Duration::ZERO, Migrator::new()).unwrap();
616        let b: PersistedListModel<Item> =
617            PersistedListModel::open(path.clone(), Duration::ZERO, Migrator::new()).unwrap();
618
619        a.upsert_front(item("alpha", 1));
620        a.flush_now().unwrap();
621        b.upsert_front(item("beta", 2));
622        b.flush_now().unwrap();
623
624        // A third, fresh handle proves both are actually on disk together.
625        let c: PersistedListModel<Item> =
626            PersistedListModel::open(path, Duration::ZERO, Migrator::new()).unwrap();
627        let mut names: Vec<String> = (0..c.model().len())
628            .map(|i| c.model().with_item(i, |x| x.name.clone()).unwrap())
629            .collect();
630        names.sort();
631        assert_eq!(names, vec!["alpha".to_string(), "beta".to_string()]);
632    }
633
634    /// The bug this replaces: a whole-snapshot design would have `b`'s
635    /// flush re-serialize *its own* in-memory list (which never saw `a`'s
636    /// addition) and overwrite it on disk. With ops, `b`'s patch only ever
637    /// touches `beta`'s key.
638    #[test]
639    fn a_peers_addition_is_not_erased_by_a_later_unrelated_flush() {
640        let dir = tempdir().unwrap();
641        let path = dir.path().join("no_clobber.toml");
642
643        let a: PersistedListModel<Item> =
644            PersistedListModel::open(path.clone(), Duration::ZERO, Migrator::new()).unwrap();
645        let b: PersistedListModel<Item> =
646            PersistedListModel::open(path.clone(), Duration::ZERO, Migrator::new()).unwrap();
647
648        a.upsert_front(item("from-a", 1));
649        a.flush_now().unwrap();
650
651        // b never saw a's write (no reload) — its own mutation must
652        // still not clobber a's entry on disk.
653        b.upsert_front(item("from-b", 2));
654        b.flush_now().unwrap();
655
656        let raw = fs::read_to_string(&path).unwrap();
657        let parsed: ListFile<Item> = toml::from_str(&raw).unwrap();
658        let names: HashSet<String> = parsed.items.iter().map(|i| i.name.clone()).collect();
659        assert!(names.contains("from-a"), "a's entry must survive");
660        assert!(names.contains("from-b"), "b's entry must be present too");
661    }
662
663    #[test]
664    fn multiple_ops_in_one_debounce_window_all_land() {
665        let dir = tempdir().unwrap();
666        let path = dir.path().join("burst.toml");
667        let plm: PersistedListModel<Item> =
668            PersistedListModel::open(path, Duration::from_millis(200), Migrator::new()).unwrap();
669
670        for i in 0..5 {
671            plm.upsert_front(item(&format!("item{i}"), i));
672        }
673        plm.flush_now().unwrap();
674        assert_eq!(plm.model().len(), 5);
675    }
676
677    // -----------------------------------------------------------------
678    // Reloadable
679    // -----------------------------------------------------------------
680
681    #[test]
682    fn reload_from_disk_picks_up_a_peers_addition() {
683        let dir = tempdir().unwrap();
684        let path = dir.path().join("reload_list.toml");
685
686        let a: PersistedListModel<Item> =
687            PersistedListModel::open(path.clone(), Duration::ZERO, Migrator::new()).unwrap();
688        let b: PersistedListModel<Item> =
689            PersistedListModel::open(path, Duration::ZERO, Migrator::new()).unwrap();
690
691        a.upsert_front(item("peer-item", 1));
692        a.flush_now().unwrap();
693
694        assert!(Reloadable::reload_from_disk(&b).unwrap());
695        assert_eq!(b.model().len(), 1);
696        assert_eq!(
697            b.model().with_item(0, |x| x.name.clone()).unwrap(),
698            "peer-item"
699        );
700    }
701
702    #[test]
703    fn reload_from_disk_returns_false_when_unchanged() {
704        let dir = tempdir().unwrap();
705        let path = dir.path().join("reload_unchanged.toml");
706        let a: PersistedListModel<Item> =
707            PersistedListModel::open(path, Duration::ZERO, Migrator::new()).unwrap();
708        assert!(!Reloadable::reload_from_disk(&a).unwrap());
709    }
710
711    #[test]
712    fn reload_from_disk_preserves_positions_of_unrelated_items() {
713        // A peer adds a new item; this handle's existing items must not
714        // be reshuffled by the reconciliation (only the addition should
715        // cause a change).
716        let dir = tempdir().unwrap();
717        let path = dir.path().join("reload_stable.toml");
718
719        let a: PersistedListModel<Item> =
720            PersistedListModel::open(path.clone(), Duration::ZERO, Migrator::new()).unwrap();
721        a.upsert_front(item("first", 1));
722        a.upsert_front(item("second", 2));
723        a.flush_now().unwrap();
724
725        let b: PersistedListModel<Item> =
726            PersistedListModel::open(path.clone(), Duration::ZERO, Migrator::new()).unwrap();
727        assert_eq!(
728            b.model().with_item(0, |x| x.name.clone()).unwrap(),
729            "second"
730        );
731        assert_eq!(b.model().with_item(1, |x| x.name.clone()).unwrap(), "first");
732
733        a.upsert_front(item("third", 3));
734        a.flush_now().unwrap();
735
736        assert!(Reloadable::reload_from_disk(&b).unwrap());
737        assert_eq!(b.model().len(), 3);
738        assert_eq!(b.model().with_item(0, |x| x.name.clone()).unwrap(), "third");
739        assert_eq!(
740            b.model().with_item(1, |x| x.name.clone()).unwrap(),
741            "second"
742        );
743        assert_eq!(b.model().with_item(2, |x| x.name.clone()).unwrap(), "first");
744    }
745
746    /// F14 repro: a local, not-yet-flushed `upsert_front` must survive a
747    /// `reload_from_disk` triggered by a peer's concurrent write, instead
748    /// of being transiently reverted by reconciling down to a disk
749    /// snapshot that doesn't yet contain our own queued op. A non-zero
750    /// debounce window is required so the op is still pending (not
751    /// already auto-flushed) when `reload_from_disk` runs.
752    #[test]
753    fn reload_from_disk_does_not_revert_a_local_not_yet_flushed_change() {
754        let dir = tempdir().unwrap();
755        let path = dir.path().join("f14.toml");
756
757        // Seed the file with a peer's baseline entry and let both handles
758        // observe it, so `a`'s later reload has a real stamp to compare
759        // against.
760        let seed: PersistedListModel<Item> =
761            PersistedListModel::open(path.clone(), Duration::ZERO, Migrator::new()).unwrap();
762        seed.upsert_front(item("peer-baseline", 0));
763        seed.flush_now().unwrap();
764        drop(seed);
765
766        let a: PersistedListModel<Item> =
767            PersistedListModel::open(path.clone(), Duration::from_secs(3600), Migrator::new())
768                .unwrap();
769        assert_eq!(a.model().len(), 1);
770
771        // Local action: lands in memory immediately, but with an hour-long
772        // debounce the write to disk is still pending.
773        a.upsert_front(item("X", 1));
774        assert_eq!(
775            a.model().with_item(0, |x| x.name.clone()).unwrap(),
776            "X",
777            "X must be at the front in memory right away"
778        );
779
780        // A peer writes a DIFFERENT valid ListFile directly to the same
781        // path, bypassing `a` entirely — it does NOT contain X.
782        let peer_file = ListFile {
783            version: 1,
784            items: vec![item("peer-baseline", 0), item("peer-new", 2)],
785        };
786        fs::write(&path, toml::to_string_pretty(&peer_file).unwrap()).unwrap();
787
788        // Old (pre-F14) behavior: this reload would read the peer's
789        // snapshot (no X in it) and reconcile the live model down to
790        // exactly that, erasing X from the front of the list until a's own
791        // debounced write eventually landed on its own.
792        let changed = Reloadable::reload_from_disk(&a).unwrap();
793        assert!(changed, "the peer's write must be observed as a change");
794
795        let names_after: Vec<String> = (0..a.model().len())
796            .map(|i| a.model().with_item(i, |x| x.name.clone()).unwrap())
797            .collect();
798        assert!(
799            names_after.contains(&"X".to_string()),
800            "a's own not-yet-flushed change must survive reload_from_disk, got {names_after:?}"
801        );
802        assert!(
803            names_after.contains(&"peer-new".to_string()),
804            "the peer's concurrent addition must also be present, got {names_after:?}"
805        );
806
807        // And the merge must have actually reached disk too: both the
808        // peer's entry and our own pending op were flushed by the
809        // pre-reload flush inside `reload_from_disk`.
810        let raw = fs::read_to_string(&path).unwrap();
811        let parsed: ListFile<Item> = toml::from_str(&raw).unwrap();
812        let on_disk: HashSet<String> = parsed.items.iter().map(|i| i.name.clone()).collect();
813        assert!(on_disk.contains("X"), "X must have reached disk too");
814        assert!(
815            on_disk.contains("peer-new"),
816            "the peer's entry must still be on disk too"
817        );
818    }
819}