Skip to main content

teksilo_widgets/notification/
archive.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `NotificationArchiveModel` — the persistent list backing
5//! [`NotificationLog`](crate::notification::log::NotificationLog) and the bell-icon badge.
6//!
7//! Wraps a [`ListModel<NotificationEntry>`](teksilo_data::ListModel)
8//! with two extras:
9//! - bounded eviction (oldest entries drop when the configured
10//!   `limit` is exceeded);
11//! - a `Signal<usize>` `unread_count` that increments on push and
12//!   resets to zero on [`mark_all_read`](NotificationArchiveModel::mark_all_read).
13//!
14//! Two storage variants are supported via [`NotificationArchive`]:
15//! - `InMemory` — session-only.
16//! - `Persistent { path }` — file-backed via
17//!   [`PersistedListModel`].
18//!   Apps install via `ToastInstallOptions::archive = Some(
19//!   NotificationArchive::persistent(...))`; the registry's
20//!   `enqueue` push goes through the model and the on-disk file
21//!   gets re-serialized on the shared teksilo-settings I/O thread.
22
23use std::cell::Cell;
24use std::path::PathBuf;
25use std::time::Duration;
26
27use teksilo_core::signal::Signal;
28use teksilo_data::ListModel;
29use teksilo_settings::{AppPaths, Migrator, PersistedListModel, SettingsFileError};
30
31use crate::notification::NotificationEntry;
32
33/// Default per-archive entry cap. IntelliJ's notification log keeps
34/// hundreds of entries with no cap visible to the user; we pick a
35/// pragmatic limit so persistent files don't grow unbounded.
36pub const DEFAULT_ARCHIVE_LIMIT: usize = 200;
37
38/// File-name (without extension) used for the persistent archive.
39/// Resolved through [`AppPaths::config_file`] into
40/// `<config_dir>/<app>/notifications.toml`.
41pub const ARCHIVE_FILE_NAME: &str = "notifications";
42
43/// How many [`NotificationUpdate`](crate::notification::NotificationUpdate)
44/// records one row keeps. A progress notice that reports each step in its
45/// wording records one update per step, and an id an app reuses for every
46/// run of an operation gathers them from every run, so a row keeps the most
47/// recent ones and drops the oldest. The row itself always shows the notice
48/// as it now stands, whatever the history kept.
49pub const UPDATE_HISTORY_LIMIT: usize = 20;
50
51/// How an entry reaches [`NotificationArchiveModel`]: raised as a notice, or
52/// as an in-place update of a notice still on screen. The two differ only
53/// when the entry merges into an existing row.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55enum Arrival {
56    Raised,
57    LiveUpdate,
58}
59
60/// Storage mode for the notification archive. Passed inside
61/// `ToastInstallOptions::archive` to the install helper.
62#[derive(Debug, Clone)]
63pub enum NotificationArchive {
64    /// Session-only — entries live in a `ListModel` for the running
65    /// session. Cheap, no disk I/O. Default for apps that don't
66    /// install a `SettingsBundle`.
67    InMemory { limit: usize },
68    /// File-backed via `PersistedListModel`. The path is built at
69    /// install time from [`AppPaths::config_file`] using the configured
70    /// `file_name`.
71    Persistent { file_name: String, limit: usize },
72}
73
74impl NotificationArchive {
75    /// In-memory archive with the default 200-entry cap.
76    pub fn in_memory() -> Self {
77        Self::InMemory {
78            limit: DEFAULT_ARCHIVE_LIMIT,
79        }
80    }
81
82    /// In-memory archive with a custom cap.
83    pub fn in_memory_with_limit(limit: usize) -> Self {
84        Self::InMemory { limit }
85    }
86
87    /// File-backed archive resolved through `AppPaths::config_file`
88    /// at install time. The default file name (`"notifications"`)
89    /// yields `<config_dir>/<app>/notifications.toml`. Apps that
90    /// want a different name pass it here; tests pass an arbitrary
91    /// name and use `AppPaths::for_testing(tmpdir)`.
92    pub fn persistent(file_name: impl Into<String>) -> Self {
93        Self::Persistent {
94            file_name: file_name.into(),
95            limit: DEFAULT_ARCHIVE_LIMIT,
96        }
97    }
98
99    pub fn persistent_with_limit(file_name: impl Into<String>, limit: usize) -> Self {
100        Self::Persistent {
101            file_name: file_name.into(),
102            limit,
103        }
104    }
105
106    pub fn limit(&self) -> usize {
107        match self {
108            Self::InMemory { limit } | Self::Persistent { limit, .. } => *limit,
109        }
110    }
111}
112
113/// Errors during archive construction or persistence I/O.
114#[derive(Debug, thiserror::Error)]
115pub enum NotificationArchiveError {
116    /// Couldn't load / write the persistent-backing file. Maps to a
117    /// `SettingsFileError`. Apps usually surface this once at startup
118    /// and fall back to `InMemory` for the session.
119    #[error("notification archive file I/O failed: {0}")]
120    File(#[from] SettingsFileError),
121}
122
123/// Either an in-memory `ListModel` or a persistent one — exposed
124/// uniformly through `NotificationArchiveModel::entries()`. Internal
125/// detail; apps work with the model.
126///
127/// Every *mutation* goes through one of this type's own methods
128/// (`upsert_front` / `update_in_place` / `remove` / `clear`), never
129/// through `model()` directly: for the `Persistent` variant,
130/// `PersistedListModel::model()` is read/reactive-binding-only —
131/// mutating it directly would update the live `ListModel` but never
132/// touch disk. Each method updates both variants identically from the
133/// caller's point of view (id-keyed, matching
134/// [`NotificationEntry`]'s [`Keyed`](teksilo_settings::Keyed) impl), so
135/// `NotificationArchiveModel` never has to branch on which backend it
136/// holds.
137enum ArchiveBackend {
138    InMemory(ListModel<NotificationEntry>),
139    Persistent(PersistedListModel<NotificationEntry>),
140}
141
142impl ArchiveBackend {
143    fn model(&self) -> &ListModel<NotificationEntry> {
144        match self {
145            Self::InMemory(m) => m,
146            Self::Persistent(p) => p.model(),
147        }
148    }
149
150    /// Find the entry with `id` and its current index, via the
151    /// live reactive model (works identically for both variants: for
152    /// `Persistent`, the live model always mirrors on-disk content).
153    fn find_by_id(&self, id: u64) -> Option<(usize, NotificationEntry)> {
154        let model = self.model();
155        (0..model.len()).find_map(|i| {
156            model
157                .with_item(i, |e| e.clone())
158                .filter(|e| e.id == id)
159                .map(|e| (i, e))
160        })
161    }
162
163    /// Insert `entry` at the front, first removing the row that has the
164    /// same `id` if there is one, so a row raised again moves to the top
165    /// rather than appearing twice. A freshly stamped id matches no row, and
166    /// this is then a plain prepend.
167    fn upsert_front(&self, entry: NotificationEntry) {
168        match self {
169            Self::InMemory(m) => {
170                if let Some((idx, _)) = self.find_by_id(entry.id) {
171                    m.remove(idx);
172                }
173                m.insert(0, entry);
174            }
175            Self::Persistent(p) => p.upsert_front(entry),
176        }
177    }
178
179    /// Replace the entry with `entry.id` in place (no reordering).
180    /// Returns whether an entry with that id existed.
181    fn update_in_place(&self, entry: NotificationEntry) -> bool {
182        match self {
183            Self::InMemory(m) => match self.find_by_id(entry.id) {
184                Some((idx, _)) => {
185                    m.set(idx, entry);
186                    true
187                }
188                None => false,
189            },
190            Self::Persistent(p) => p.update_in_place(entry),
191        }
192    }
193
194    /// Remove the entry with `id`, if present. Returns whether
195    /// anything was removed.
196    fn remove(&self, id: u64) -> bool {
197        match self {
198            Self::InMemory(m) => match self.find_by_id(id) {
199                Some((idx, _)) => {
200                    m.remove(idx);
201                    true
202                }
203                None => false,
204            },
205            Self::Persistent(p) => p.remove(&id),
206        }
207    }
208
209    fn clear(&self) {
210        match self {
211            Self::InMemory(m) => m.clear(),
212            Self::Persistent(p) => p.clear(),
213        }
214    }
215
216    fn flush_now(&self) -> Result<(), SettingsFileError> {
217        match self {
218            Self::InMemory(_) => Ok(()),
219            Self::Persistent(p) => p.flush_now(),
220        }
221    }
222}
223
224/// Shared model — clones share state. Constructed by the install
225/// helper from `NotificationArchive` + `AppPaths`; apps reach it
226/// via `ctx.app_state::<Rc<NotificationArchiveModel>>()`.
227///
228/// `NotificationLog` and `NotificationCenterButton`
229/// consume this model directly.
230pub struct NotificationArchiveModel {
231    backend: ArchiveBackend,
232    limit: usize,
233    /// Stable monotonically-increasing per-archive id stamped onto
234    /// each new entry via `next_id.update(|n| n+1)`. Independent of
235    /// the runtime `entry_id` on `ToastHandle` (the toast IDs are
236    /// per-session; archive IDs persist across restarts).
237    next_id: Cell<u64>,
238    /// Live unread count. Increments on `push` of an unread entry,
239    /// resets to zero on `mark_all_read`. Drives the bell-button
240    /// badge.
241    unread_count: Signal<usize>,
242    /// Monotonic version bumped on every mutation (push, in-place
243    /// update, mark_all_read, clear, remove). The
244    /// [`NotificationLog`](super::log::NotificationLog) binds to
245    /// this at `BindingLevel::Rebuild` so any archive change
246    /// triggers a fresh log rebuild — needed for the day-bucket
247    /// header re-computation. Same shape as
248    /// [`OverlayManager::version`](teksilo_core::overlay::OverlayManager::version)
249    /// and [`ToastRegistry::version_signal`](crate::toast::ToastRegistry::version_signal).
250    version: Signal<u64>,
251}
252
253impl NotificationArchiveModel {
254    /// Construct from a [`NotificationArchive`] config. For
255    /// `Persistent` mode, resolves the path through `AppPaths`.
256    /// Tests use `AppPaths::for_testing(tmpdir)` + `Duration::ZERO`
257    /// debounce.
258    pub fn open(
259        archive: &NotificationArchive,
260        paths: &AppPaths,
261        debounce: Duration,
262    ) -> Result<Self, NotificationArchiveError> {
263        let limit = archive.limit();
264        let backend = match archive {
265            NotificationArchive::InMemory { .. } => ArchiveBackend::InMemory(ListModel::new()),
266            NotificationArchive::Persistent { file_name, .. } => {
267                let path: PathBuf = paths.config_file(file_name);
268                let plm: PersistedListModel<NotificationEntry> =
269                    PersistedListModel::open(path, debounce, Migrator::new())?;
270                ArchiveBackend::Persistent(plm)
271            }
272        };
273        // Initialize next_id past the largest existing id so persistent
274        // archives don't collide ids across restarts.
275        let model = backend.model();
276        let next_id_seed = (0..model.len())
277            .filter_map(|i| model.with_item(i, |e| e.id))
278            .max()
279            .map(|m| m + 1)
280            .unwrap_or(1);
281        // Initial unread_count reflects what's on disk.
282        let initial_unread = (0..model.len())
283            .filter_map(|i| model.with_item(i, |e| !e.read))
284            .filter(|x| *x)
285            .count();
286        Ok(Self {
287            backend,
288            limit,
289            next_id: Cell::new(next_id_seed),
290            unread_count: Signal::new(initial_unread),
291            version: Signal::new(0),
292        })
293    }
294
295    /// Convenience: construct an [`NotificationArchive::InMemory`]
296    /// archive with the default cap, without going through paths.
297    /// Mostly useful for tests and apps that explicitly want no
298    /// persistence.
299    pub fn in_memory() -> Self {
300        Self {
301            backend: ArchiveBackend::InMemory(ListModel::new()),
302            limit: DEFAULT_ARCHIVE_LIMIT,
303            next_id: Cell::new(1),
304            unread_count: Signal::new(0),
305            version: Signal::new(0),
306        }
307    }
308
309    /// Reactive handle on the entries. Bind to a `ListView` /
310    /// `Repeater` for live UI.
311    pub fn entries(&self) -> &ListModel<NotificationEntry> {
312        self.backend.model()
313    }
314
315    /// Signal of the unread count. Drives the bell-button badge.
316    pub fn unread_count(&self) -> &Signal<usize> {
317        &self.unread_count
318    }
319
320    /// Reactive handle on the archive's mutation version. Widgets
321    /// that render the archive (`NotificationLog`,
322    /// `NotificationCenterButton`) bind to this at
323    /// `BindingLevel::Rebuild`, in every window — one signal is enough
324    /// for N of them, see
325    /// [`ToastRegistry::version_signal`](crate::toast::ToastRegistry::version_signal)
326    /// for the history of why that had to be said out loud.
327    pub fn version_signal(&self) -> &Signal<u64> {
328        &self.version
329    }
330
331    pub fn limit(&self) -> usize {
332        self.limit
333    }
334
335    /// Bump the version every bell / log binds at
336    /// `BindingLevel::Rebuild` — see [`Self::version_signal`]. One
337    /// write reaches every window: each window's own `BindingRegistry`
338    /// tracks the generation it last reconciled, so none of them can
339    /// consume the notification out from under the others.
340    fn bump_version(&self) {
341        let v = self.version.get();
342        self.version.set(v.wrapping_add(1));
343    }
344
345    /// Force the persistent backing file to disk synchronously.
346    /// No-op for `InMemory`. Tests call this between mutations and
347    /// re-opening the file to verify persistence.
348    pub fn flush_now(&self) -> Result<(), SettingsFileError> {
349        self.backend.flush_now()
350    }
351
352    /// Push a notice that has just been raised. Inserts at index 0
353    /// (newest first), evicts the oldest if the resulting length exceeds
354    /// `limit`. Stamps the entry's `id` field from `next_id`. Bumps
355    /// `unread_count` when the entry is unread (which is the typical case
356    /// from a toast push).
357    ///
358    /// If `entry.dedup_id` matches an existing row, the notice is that row
359    /// raised again: an operation that failed this morning and fails again
360    /// now, under the same [`Toast::id`](crate::toast::Toast::id). No new row
361    /// is inserted. The row moves to the front, takes the entry's
362    /// `timestamp`, records a
363    /// [`NotificationUpdate`](crate::notification::NotificationUpdate) and
364    /// takes the entry's read state, which makes it unread again, even when
365    /// the notice says exactly what it said before: the log and the bell show
366    /// that it happened again. Every other field is merged as
367    /// [`push_update`](Self::push_update) describes.
368    pub fn push(&self, entry: NotificationEntry) {
369        self.arrive(entry, Arrival::Raised);
370    }
371
372    /// Push an in-place update of a notice that is still on screen: a
373    /// progress notice's next step, or its result. This is what the toast
374    /// registry calls when a `Toast::id` matches a live toast.
375    ///
376    /// If `entry.dedup_id` matches an existing row, that row is updated in
377    /// place: same position, and it keeps the time the notice was raised.
378    /// The row then shows the notice **as it now stands**, the way the live
379    /// toast it mirrors does:
380    ///
381    /// - `title`, `body`, `severity`, `priority`, `actions` and `route`
382    ///   are the update's. An update offering no actions leaves the row
383    ///   offering none: a progress notice's Cancel must not outlive the
384    ///   notice that said the work was cancelled, nor stand in for the
385    ///   Open and See report a finished one offers.
386    /// - `group` and `source` are the update's when it names them, and
387    ///   stay as they were when it does not.
388    /// - `id`, `timestamp` (when the notice was raised, which places the row
389    ///   in the log) and `dedup_id` stay.
390    /// - A [`NotificationUpdate`](crate::notification::NotificationUpdate)
391    ///   is appended when the update changes what the row shows (its
392    ///   wording, severity or actions), carrying the update's timestamp and
393    ///   whichever of title and body changed. A row keeps the most recent
394    ///   [`UPDATE_HISTORY_LIMIT`] of them. An update that repeats the row
395    ///   exactly records nothing and leaves its read state alone.
396    /// - A change gives the row the update's read state, which for a toast
397    ///   is unread. `unread_count` counts rows, so it grows only when the
398    ///   row had been read.
399    ///
400    /// With no matching row (the user cleared it, or it was evicted), the
401    /// update is pushed as a new row, like [`push`](Self::push).
402    pub fn push_update(&self, entry: NotificationEntry) {
403        self.arrive(entry, Arrival::LiveUpdate);
404    }
405
406    fn arrive(&self, mut entry: NotificationEntry, arrival: Arrival) {
407        self.bump_version();
408        let model = self.backend.model();
409
410        // Merge: scan for a matching `dedup_id`.
411        if let Some(ref new_dedup) = entry.dedup_id {
412            let merge_idx = (0..model.len()).find(|&i| {
413                model
414                    .with_item(i, |e| e.dedup_id.as_deref() == Some(new_dedup.as_str()))
415                    .unwrap_or(false)
416            });
417            if let Some(idx) = merge_idx
418                && let Some(existing) = model.with_item(idx, |e| e.clone())
419            {
420                self.merge(existing, entry, arrival);
421                return;
422            }
423        }
424
425        // New entry: stamp the id (always fresh, so `upsert_front` is
426        // a plain prepend — it never collides with an existing key),
427        // then evict overflow.
428        let next = self.next_id.get();
429        entry.id = next;
430        self.next_id.set(next.wrapping_add(1));
431        let is_unread = !entry.read;
432        self.backend.upsert_front(entry);
433        if model.len() > self.limit {
434            // Evict the oldest entry. The model has no `pop_back`;
435            // remove-by-id of the last row is the equivalent.
436            let last = model.len() - 1;
437            if let Some(evicted) = model.with_item(last, |e| e.clone()) {
438                // If the evicted entry was unread, decrement the
439                // unread count so the badge doesn't lie about how
440                // many sit on disk.
441                if !evicted.read {
442                    let n = self.unread_count.get();
443                    self.unread_count.set(n.saturating_sub(1));
444                }
445                self.backend.remove(evicted.id);
446            }
447        }
448        if is_unread {
449            self.bump_unread();
450        }
451    }
452
453    /// Bring `existing` up to `entry` and write it back: in place for a live
454    /// update, at the front for a notice raised again. See
455    /// [`push`](Self::push) and [`push_update`](Self::push_update) for what
456    /// each field becomes.
457    fn merge(&self, mut existing: NotificationEntry, entry: NotificationEntry, arrival: Arrival) {
458        let title_changed = existing.title != entry.title;
459        let body_changed = existing.body != entry.body;
460        let changed = title_changed
461            || body_changed
462            || existing.severity != entry.severity
463            || existing.actions != entry.actions;
464        let raised = arrival == Arrival::Raised;
465        let recorded = changed || raised;
466        if recorded {
467            existing
468                .updates
469                .push(crate::notification::NotificationUpdate {
470                    timestamp: entry.timestamp,
471                    title: title_changed.then(|| entry.title.clone()),
472                    body: if body_changed {
473                        entry.body.clone()
474                    } else {
475                        None
476                    },
477                    progress: None,
478                });
479            let excess = existing.updates.len().saturating_sub(UPDATE_HISTORY_LIMIT);
480            existing.updates.drain(..excess);
481        }
482        let was_unread = !existing.read;
483        existing.title = entry.title;
484        existing.body = entry.body;
485        existing.severity = entry.severity;
486        existing.priority = entry.priority;
487        existing.actions = entry.actions;
488        existing.route = entry.route;
489        if entry.group.is_some() {
490            existing.group = entry.group;
491        }
492        if entry.source.is_some() {
493            existing.source = entry.source;
494        }
495        if recorded {
496            existing.read = entry.read;
497        }
498        let is_unread = !existing.read;
499        if raised {
500            existing.timestamp = entry.timestamp;
501            self.backend.upsert_front(existing);
502        } else {
503            self.backend.update_in_place(existing);
504        }
505        match (was_unread, is_unread) {
506            (false, true) => self.bump_unread(),
507            (true, false) => {
508                let n = self.unread_count.get();
509                self.unread_count.set(n.saturating_sub(1));
510            }
511            _ => {}
512        }
513    }
514
515    fn bump_unread(&self) {
516        let n = self.unread_count.get();
517        self.unread_count.set(n.saturating_add(1));
518    }
519
520    /// Mark every UNREAD entry matching `predicate` as read,
521    /// decrementing `unread_count` by exactly how many were flipped.
522    /// This is the scoped counterpart of [`mark_all_read`](Self::mark_all_read):
523    /// a bell scoped to one window/audience must only mark ITS
524    /// entries read on close — calling the unscoped `mark_all_read`
525    /// from a scoped bell would incorrectly clear every OTHER
526    /// window's/audience's unread state too.
527    pub fn mark_read_where(&self, mut predicate: impl FnMut(&NotificationEntry) -> bool) {
528        let model = self.backend.model();
529        let ids: Vec<u64> = (0..model.len())
530            .filter_map(|i| {
531                model
532                    .with_item(i, |e| (!e.read && predicate(e)).then_some(e.id))
533                    .flatten()
534            })
535            .collect();
536        if ids.is_empty() {
537            return;
538        }
539        let mut mutated = false;
540        for id in ids {
541            if let Some((_, mut entry)) = self.backend.find_by_id(id) {
542                entry.read = true;
543                self.backend.update_in_place(entry);
544                mutated = true;
545                let n = self.unread_count.get();
546                self.unread_count.set(n.saturating_sub(1));
547            }
548        }
549        if mutated {
550            self.bump_version();
551        }
552    }
553
554    /// Mark every archived entry as read; reset `unread_count` to 0.
555    /// Called by `NotificationCenterButton` when its popover closes.
556    pub fn mark_all_read(&self) {
557        let model = self.backend.model();
558        // Collect the ids to flip first: mutating the persisted
559        // backend's live model mid-scan (`update_in_place` writes
560        // straight into `model`, same length, no reordering) is safe
561        // for this loop either way, but reading into an owned `Vec`
562        // up front keeps the read and the mutation cleanly separated.
563        let unread_ids: Vec<u64> = (0..model.len())
564            .filter_map(|i| model.with_item(i, |e| (!e.read).then_some(e.id)).flatten())
565            .collect();
566        let mut mutated = false;
567        for id in unread_ids {
568            if let Some((_, mut entry)) = self.backend.find_by_id(id) {
569                entry.read = true;
570                self.backend.update_in_place(entry);
571                mutated = true;
572            }
573        }
574        self.unread_count.set(0);
575        if mutated {
576            self.bump_version();
577        }
578    }
579
580    /// Clear the entire archive (resets `unread_count` to 0).
581    pub fn clear(&self) {
582        let was_empty = self.backend.model().is_empty();
583        self.backend.clear();
584        self.unread_count.set(0);
585        if !was_empty {
586            self.bump_version();
587        }
588    }
589
590    /// Remove every entry matching `predicate`, decrementing
591    /// `unread_count` for each removed entry that was unread. The
592    /// scoped counterpart of [`clear`](Self::clear): a bell scoped to
593    /// one window/audience must only clear ITS entries — the unscoped
594    /// `clear()` wipes the ENTIRE shared archive (every window's
595    /// history), which would be wrong for a scoped "Clear" button.
596    pub fn clear_where(&self, mut predicate: impl FnMut(&NotificationEntry) -> bool) {
597        let model = self.backend.model();
598        let matches: Vec<(u64, bool)> = (0..model.len())
599            .filter_map(|i| {
600                model
601                    .with_item(i, |e| predicate(e).then_some((e.id, !e.read)))
602                    .flatten()
603            })
604            .collect();
605        if matches.is_empty() {
606            return;
607        }
608        let mut removed_any = false;
609        for (id, was_unread) in matches {
610            if self.backend.remove(id) {
611                removed_any = true;
612                if was_unread {
613                    let n = self.unread_count.get();
614                    self.unread_count.set(n.saturating_sub(1));
615                }
616            }
617        }
618        if removed_any {
619            self.bump_version();
620        }
621    }
622
623    /// Remove the entry with the given **stable** id (see
624    /// [`NotificationEntry::id`] — "assigned by the archive on first
625    /// push; never reused"). Updates `unread_count` if the removed entry
626    /// was unread. No-op (no version bump) when no entry has that id.
627    ///
628    /// Deliberately id-based rather than index-based: an index is a
629    /// snapshot of the list's shape at the moment it was read, and is
630    /// meaningless once anything else — a concurrent peer-process reload
631    /// merged in via the live archive, another `push`, another `remove` —
632    /// has shifted rows out from under it. A caller that captured "the row
633    /// I want to dismiss" as an index earlier and replays it later against
634    /// a since-mutated list can silently remove the *wrong* entry; keying
635    /// off `id` instead re-resolves the row's current position at the
636    /// moment of removal, so it always removes the entry the caller meant.
637    pub fn remove_by_id(&self, id: u64) {
638        let Some((_, entry)) = self.backend.find_by_id(id) else {
639            return;
640        };
641        let was_unread = !entry.read;
642        self.backend.remove(id);
643        if was_unread {
644            let n = self.unread_count.get();
645            self.unread_count.set(n.saturating_sub(1));
646        }
647        self.bump_version();
648    }
649}
650
651impl std::fmt::Debug for NotificationArchiveModel {
652    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
653        f.debug_struct("NotificationArchiveModel")
654            .field("entries", &self.entries().len())
655            .field("limit", &self.limit)
656            .field("unread_count", &self.unread_count.get())
657            .field(
658                "backend",
659                &match &self.backend {
660                    ArchiveBackend::InMemory(_) => "InMemory",
661                    ArchiveBackend::Persistent(_) => "Persistent",
662                },
663            )
664            .finish()
665    }
666}
667
668// `NotificationArchiveModel` deliberately does NOT implement `Clone`.
669// The persistent backend's `PersistedListModel` observer captures a
670// strong reference to the inner `SettingsFile`; a naive Clone would
671// create divergent writers writing the same file. Apps share a
672// single archive through an `Rc<NotificationArchiveModel>` (or
673// `Rc<RefCell<…>>` if mutation through shared handles is needed) —
674// the install helper in `teksilo` puts the model in `app_state` as
675// `Rc<NotificationArchiveModel>`.
676
677#[cfg(test)]
678mod tests {
679    use super::*;
680    use crate::notification::{ArchivedAction, ArchivedActionStyle};
681    use crate::toast::ToastRoute;
682    use teksilo_core::styles::{BannerSeverity, ToastPriority};
683
684    fn entry(title: &str) -> NotificationEntry {
685        NotificationEntry {
686            id: 0, // overwritten by push()
687            severity: BannerSeverity::Info,
688            priority: ToastPriority::Normal,
689            title: title.to_string(),
690            body: None,
691            actions: Vec::new(),
692            timestamp: jiff::Timestamp::UNIX_EPOCH,
693            group: None,
694            source: None,
695            read: false,
696            dedup_id: None,
697            updates: Vec::new(),
698            route: ToastRoute::Broadcast,
699        }
700    }
701
702    #[test]
703    fn in_memory_starts_empty() {
704        let m = NotificationArchiveModel::in_memory();
705        assert_eq!(m.entries().len(), 0);
706        assert_eq!(m.unread_count().get(), 0);
707        assert_eq!(m.limit(), DEFAULT_ARCHIVE_LIMIT);
708    }
709
710    #[test]
711    fn push_inserts_newest_first_and_bumps_unread() {
712        let m = NotificationArchiveModel::in_memory();
713        m.push(entry("first"));
714        m.push(entry("second"));
715        m.push(entry("third"));
716        assert_eq!(m.entries().len(), 3);
717        assert_eq!(m.unread_count().get(), 3);
718        // Newest at index 0.
719        assert_eq!(
720            m.entries().with_item(0, |e| e.title.clone()),
721            Some("third".to_string())
722        );
723        assert_eq!(
724            m.entries().with_item(2, |e| e.title.clone()),
725            Some("first".to_string())
726        );
727    }
728
729    #[test]
730    fn push_stamps_distinct_increasing_ids() {
731        let m = NotificationArchiveModel::in_memory();
732        m.push(entry("a"));
733        m.push(entry("b"));
734        m.push(entry("c"));
735        let id0 = m.entries().with_item(0, |e| e.id).unwrap();
736        let id1 = m.entries().with_item(1, |e| e.id).unwrap();
737        let id2 = m.entries().with_item(2, |e| e.id).unwrap();
738        // Newest = index 0 has the highest id.
739        assert!(id0 > id1);
740        assert!(id1 > id2);
741    }
742
743    #[test]
744    fn bounded_eviction_drops_oldest() {
745        let m = NotificationArchiveModel {
746            backend: ArchiveBackend::InMemory(ListModel::new()),
747            limit: 3,
748            next_id: Cell::new(1),
749            unread_count: Signal::new(0),
750            version: Signal::new(0),
751        };
752        for i in 0..5 {
753            m.push(entry(&format!("t{i}")));
754        }
755        assert_eq!(m.entries().len(), 3, "bounded to limit");
756        assert_eq!(
757            m.unread_count().get(),
758            3,
759            "unread count tracks live entries"
760        );
761        // Newest preserved (t4, t3, t2).
762        assert_eq!(
763            m.entries().with_item(0, |e| e.title.clone()),
764            Some("t4".into())
765        );
766        assert_eq!(
767            m.entries().with_item(1, |e| e.title.clone()),
768            Some("t3".into())
769        );
770        assert_eq!(
771            m.entries().with_item(2, |e| e.title.clone()),
772            Some("t2".into())
773        );
774    }
775
776    #[test]
777    fn mark_all_read_zeros_count_and_flips_entries() {
778        let m = NotificationArchiveModel::in_memory();
779        m.push(entry("a"));
780        m.push(entry("b"));
781        assert_eq!(m.unread_count().get(), 2);
782
783        m.mark_all_read();
784        assert_eq!(m.unread_count().get(), 0);
785        assert!(m.entries().with_item(0, |e| e.read).unwrap());
786        assert!(m.entries().with_item(1, |e| e.read).unwrap());
787    }
788
789    #[test]
790    fn clear_empties_and_zeros_count() {
791        let m = NotificationArchiveModel::in_memory();
792        m.push(entry("a"));
793        m.push(entry("b"));
794        m.clear();
795        assert_eq!(m.entries().len(), 0);
796        assert_eq!(m.unread_count().get(), 0);
797    }
798
799    #[test]
800    fn remove_by_id_unread_decrements_count() {
801        let m = NotificationArchiveModel::in_memory();
802        m.push(entry("a"));
803        m.push(entry("b"));
804        assert_eq!(m.unread_count().get(), 2);
805
806        let b_id = m.entries().with_item(0, |e| e.id).unwrap(); // "b" is newest
807        m.remove_by_id(b_id);
808        assert_eq!(m.entries().len(), 1);
809        assert_eq!(m.unread_count().get(), 1);
810        assert_eq!(
811            m.entries().with_item(0, |e| e.title.clone()),
812            Some("a".to_string())
813        );
814    }
815
816    #[test]
817    fn remove_by_id_read_does_not_change_count() {
818        let m = NotificationArchiveModel::in_memory();
819        m.push(entry("a"));
820        m.mark_all_read();
821        assert_eq!(m.unread_count().get(), 0);
822        let a_id = m.entries().with_item(0, |e| e.id).unwrap();
823        m.remove_by_id(a_id);
824        assert_eq!(m.unread_count().get(), 0);
825        assert!(m.entries().is_empty());
826    }
827
828    #[test]
829    fn remove_by_id_unknown_id_is_a_noop() {
830        let m = NotificationArchiveModel::in_memory();
831        m.push(entry("a"));
832        let v_before = m.version_signal().get();
833        m.remove_by_id(999_999);
834        assert_eq!(m.entries().len(), 1, "nothing removed");
835        assert_eq!(
836            v_before,
837            m.version_signal().get(),
838            "no version bump for a no-op"
839        );
840    }
841
842    #[test]
843    fn remove_by_id_removes_the_right_entry_after_a_concurrent_insert_shifts_indices() {
844        // Bug repro for the index-based API this replaces: a caller reads
845        // "the row to dismiss" as an index, but before it acts, a
846        // concurrent insert (a peer process's reload merged into the live
847        // archive, or just another `push`) shifts every row after it down
848        // by one. An index-based `remove(stale_index)` would then delete
849        // whatever row happens to occupy that index NOW — not the one the
850        // caller meant. `remove_by_id` re-resolves the row's position at
851        // the moment of removal, so it is immune to this.
852        let m = NotificationArchiveModel::in_memory();
853        m.push(entry("a")); // index 1 after "b" below
854        m.push(entry("b")); // index 0
855        assert_eq!(
856            m.entries().with_item(1, |e| e.title.clone()),
857            Some("a".to_string()),
858            "precondition: a is at index 1"
859        );
860        // Caller observes "a" at index 1 and remembers its id to dismiss
861        // it later.
862        let a_id = m
863            .entries()
864            .with_item(1, |e| e.id)
865            .expect("a's id at index 1");
866
867        // Concurrent insert (simulating a peer's write landing directly in
868        // the live model) shifts "a" from index 1 to index 2.
869        m.entries().insert(0, entry("peer-inserted"));
870        assert_eq!(
871            m.entries().with_item(2, |e| e.title.clone()),
872            Some("a".to_string()),
873            "precondition: the insert shifted a to index 2"
874        );
875
876        // A stale `remove(1)` would now delete "b", not "a". `remove_by_id`
877        // must remove "a" regardless of where it ended up.
878        m.remove_by_id(a_id);
879
880        assert_eq!(m.entries().len(), 2, "exactly one entry removed");
881        let remaining: Vec<String> = (0..m.entries().len())
882            .map(|i| m.entries().with_item(i, |e| e.title.clone()).unwrap())
883            .collect();
884        assert!(
885            remaining.contains(&"b".to_string()),
886            "b survives: {remaining:?}"
887        );
888        assert!(
889            remaining.contains(&"peer-inserted".to_string()),
890            "peer-inserted survives: {remaining:?}"
891        );
892        assert!(
893            !remaining.contains(&"a".to_string()),
894            "a — the one actually targeted by id — is gone: {remaining:?}"
895        );
896    }
897
898    #[test]
899    fn update_in_place_merges_by_dedup_id() {
900        let m = NotificationArchiveModel::in_memory();
901        let mut first = entry("Uploading 1 of 7");
902        first.dedup_id = Some("upload".to_string());
903        m.push(first);
904        assert_eq!(m.entries().len(), 1);
905        assert_eq!(m.unread_count().get(), 1);
906
907        // Mark read so the update bumps unread back up.
908        m.mark_all_read();
909        assert_eq!(m.unread_count().get(), 0);
910
911        let mut second = entry("Uploading 4 of 7");
912        second.dedup_id = Some("upload".to_string());
913        m.push_update(second);
914        assert_eq!(m.entries().len(), 1, "update merges into existing row");
915        assert_eq!(
916            m.unread_count().get(),
917            1,
918            "in-place update is also new info"
919        );
920        let merged = m.entries().with_item(0, |e| e.clone()).unwrap();
921        assert_eq!(merged.title, "Uploading 4 of 7");
922        assert_eq!(merged.updates.len(), 1);
923        assert_eq!(merged.updates[0].title.as_deref(), Some("Uploading 4 of 7"));
924        assert!(!merged.read, "in-place update resets read state");
925    }
926
927    #[test]
928    fn update_in_place_only_merges_on_dedup_match() {
929        let m = NotificationArchiveModel::in_memory();
930        let mut a = entry("first");
931        a.dedup_id = Some("x".to_string());
932        m.push(a);
933        let mut b = entry("second");
934        b.dedup_id = Some("y".to_string());
935        m.push(b);
936        // Different dedup_ids — both rows alive.
937        assert_eq!(m.entries().len(), 2);
938        // Third entry with NO dedup_id never merges.
939        m.push(entry("third"));
940        assert_eq!(m.entries().len(), 3);
941    }
942
943    fn action(label: &str, intent: Option<&str>, style: ArchivedActionStyle) -> ArchivedAction {
944        ArchivedAction {
945            label: label.to_string(),
946            intent_name: intent.map(str::to_string),
947            style,
948            closes_on_invoke: true,
949        }
950    }
951
952    fn notice(
953        title: &str,
954        severity: BannerSeverity,
955        actions: Vec<ArchivedAction>,
956    ) -> NotificationEntry {
957        let mut e = entry(title);
958        e.dedup_id = Some("import".to_string());
959        e.severity = severity;
960        e.actions = actions;
961        e
962    }
963
964    /// A notice updated in place is archived as it now stands: the latest
965    /// update's actions, severity, priority and audience, not the first
966    /// notice's. A progress notice offering Cancel that became a result
967    /// offering Open now and See report used to stay a Cancel row in the
968    /// log, which could then never open the report.
969    #[test]
970    fn a_merged_row_carries_the_latest_notice() {
971        let m = NotificationArchiveModel::in_memory();
972        let mut progress = notice(
973            "Importing",
974            BannerSeverity::Info,
975            vec![action("Cancel", None, ArchivedActionStyle::Destructive)],
976        );
977        progress.priority = ToastPriority::Normal;
978        m.push(progress);
979        let first = m.entries().with_item(0, |e| e.clone()).unwrap();
980
981        let mut result = notice(
982            "Imported",
983            BannerSeverity::Success,
984            vec![
985                action("Open now", None, ArchivedActionStyle::PrimaryButton),
986                action(
987                    "See report",
988                    Some("app.import.report"),
989                    ArchivedActionStyle::SecondaryButton,
990                ),
991            ],
992        );
993        result.priority = ToastPriority::High;
994        result.route = ToastRoute::Audience(crate::toast::ToastAudience::new(3));
995        result.timestamp = jiff::Timestamp::from_second(60).unwrap();
996        m.push_update(result.clone());
997
998        assert_eq!(m.entries().len(), 1, "an update stays one row");
999        let row = m.entries().with_item(0, |e| e.clone()).unwrap();
1000        assert_eq!(row.actions, result.actions, "the result's actions");
1001        assert_eq!(row.severity, BannerSeverity::Success);
1002        assert_eq!(row.priority, ToastPriority::High);
1003        assert_eq!(row.route, result.route, "the audience it was retargeted to");
1004        assert_eq!(row.title, "Imported");
1005        // What identifies the row and places it in the log does not move.
1006        assert_eq!(row.id, first.id);
1007        assert_eq!(row.timestamp, first.timestamp, "first raised at");
1008        assert_eq!(
1009            row.updates.last().map(|u| u.timestamp),
1010            Some(result.timestamp),
1011            "the update records when it came"
1012        );
1013    }
1014
1015    /// An update that offers no actions leaves the row offering none, as the
1016    /// live notice it mirrors does: a Cancel kept from a notice that has since
1017    /// said the import was cancelled would offer to stop something already
1018    /// stopped.
1019    #[test]
1020    fn an_update_without_actions_leaves_the_row_without_them() {
1021        let m = NotificationArchiveModel::in_memory();
1022        m.push(notice(
1023            "Importing",
1024            BannerSeverity::Info,
1025            vec![action("Cancel", None, ArchivedActionStyle::Destructive)],
1026        ));
1027        m.push_update(notice("Import cancelled", BannerSeverity::Info, Vec::new()));
1028        let row = m.entries().with_item(0, |e| e.clone()).unwrap();
1029        assert!(row.actions.is_empty(), "kept {:?}", row.actions);
1030    }
1031
1032    /// An update names its group and source only when it has one: a row
1033    /// stays where it was filed rather than being moved out of it by an
1034    /// update that does not say.
1035    #[test]
1036    fn an_update_keeps_the_group_and_source_it_does_not_name() {
1037        let m = NotificationArchiveModel::in_memory();
1038        let mut first = notice("Importing", BannerSeverity::Info, Vec::new());
1039        first.group = Some("imports".to_string());
1040        first.source = Some("scrivener".to_string());
1041        m.push(first);
1042        m.push_update(notice("Imported", BannerSeverity::Success, Vec::new()));
1043        let row = m.entries().with_item(0, |e| e.clone()).unwrap();
1044        assert_eq!(row.group.as_deref(), Some("imports"));
1045        assert_eq!(row.source.as_deref(), Some("scrivener"));
1046
1047        let mut moved = notice("Imported", BannerSeverity::Success, Vec::new());
1048        moved.group = Some("done".to_string());
1049        m.push_update(moved);
1050        let row = m.entries().with_item(0, |e| e.clone()).unwrap();
1051        assert_eq!(
1052            row.group.as_deref(),
1053            Some("done"),
1054            "one that does, moves it"
1055        );
1056    }
1057
1058    /// The unread count counts rows. Updating a row nobody has read yet adds
1059    /// nothing to it: a progress notice ticked fifty times is one unread row,
1060    /// and marking it read must bring the badge back to zero.
1061    #[test]
1062    fn updating_an_unread_row_counts_it_once() {
1063        let m = NotificationArchiveModel::in_memory();
1064        m.push(notice("Importing 1%", BannerSeverity::Info, Vec::new()));
1065        for percent in 2..=50 {
1066            m.push_update(notice(
1067                &format!("Importing {percent}%"),
1068                BannerSeverity::Info,
1069                Vec::new(),
1070            ));
1071        }
1072        assert_eq!(m.unread_count().get(), 1, "one unread row");
1073        m.mark_read_where(|_| true);
1074        assert_eq!(m.unread_count().get(), 0, "read, the badge is clear");
1075
1076        // Read, then updated: unread again, once.
1077        m.push_update(notice("Imported", BannerSeverity::Success, Vec::new()));
1078        assert_eq!(m.unread_count().get(), 1);
1079    }
1080
1081    /// An update that changes nothing the row shows is not recorded: a
1082    /// progress notice re-raised with the same words on every tick would
1083    /// otherwise grow its row, and the archive file, by one record a tick.
1084    #[test]
1085    fn a_repeated_notice_records_no_update() {
1086        let m = NotificationArchiveModel::in_memory();
1087        let cancel = vec![action("Cancel", None, ArchivedActionStyle::Destructive)];
1088        m.push(notice("Importing", BannerSeverity::Info, cancel.clone()));
1089        for _ in 0..20 {
1090            m.push_update(notice("Importing", BannerSeverity::Info, cancel.clone()));
1091        }
1092        let row = m.entries().with_item(0, |e| e.clone()).unwrap();
1093        assert!(row.updates.is_empty(), "{} records", row.updates.len());
1094
1095        // A change of severity alone is a change, recorded without wording.
1096        m.push_update(notice("Importing", BannerSeverity::Warning, cancel));
1097        let row = m.entries().with_item(0, |e| e.clone()).unwrap();
1098        assert_eq!(row.updates.len(), 1);
1099        assert_eq!(row.updates[0].title, None);
1100        assert_eq!(row.updates[0].body, None);
1101    }
1102
1103    /// The same notice raised again after the first had left the screen is a
1104    /// second occurrence: the row comes back to the top, unread, dated when
1105    /// it came back, with a record of it, even though nothing it says has
1106    /// changed. Treated as a live tick, it stayed read, under its first date,
1107    /// below every notice raised since.
1108    #[test]
1109    fn a_notice_raised_again_comes_back_unread_at_the_top() {
1110        let m = NotificationArchiveModel::in_memory();
1111        let mut first = notice("Sync failed", BannerSeverity::Error, Vec::new());
1112        first.timestamp = jiff::Timestamp::from_second(9 * 3600).unwrap();
1113        m.push(first);
1114        let mut later = entry("Saved");
1115        later.timestamp = jiff::Timestamp::from_second(10 * 3600).unwrap();
1116        m.push(later);
1117        m.mark_all_read();
1118        let before = m.entries().with_item(1, |e| e.clone()).unwrap();
1119
1120        let mut again = notice("Sync failed", BannerSeverity::Error, Vec::new());
1121        again.timestamp = jiff::Timestamp::from_second(15 * 3600).unwrap();
1122        m.push(again.clone());
1123
1124        assert_eq!(m.entries().len(), 2, "still one row for the id");
1125        let row = m.entries().with_item(0, |e| e.clone()).unwrap();
1126        assert_eq!(row.id, before.id, "the same row, at the top");
1127        assert!(!row.read, "unread again");
1128        assert_eq!(m.unread_count().get(), 1);
1129        assert_eq!(row.timestamp, again.timestamp, "dated when it came back");
1130        assert_eq!(row.updates.len(), 1, "the second occurrence is recorded");
1131        assert_eq!(row.updates[0].timestamp, again.timestamp);
1132        assert_eq!(row.updates[0].title, None, "the wording did not change");
1133
1134        // Raised again while still unread: recorded, but counted once.
1135        m.push(notice("Sync failed", BannerSeverity::Error, Vec::new()));
1136        assert_eq!(m.unread_count().get(), 1);
1137        let row = m.entries().with_item(0, |e| e.clone()).unwrap();
1138        assert_eq!(row.updates.len(), 2);
1139    }
1140
1141    /// An entry raised as already read merges as read, and leaves the unread
1142    /// count where the row's own state puts it.
1143    #[test]
1144    fn a_notice_raised_again_takes_its_read_state() {
1145        let m = NotificationArchiveModel::in_memory();
1146        m.push(notice("Sync failed", BannerSeverity::Error, Vec::new()));
1147        assert_eq!(m.unread_count().get(), 1);
1148        let mut seen = notice("Sync failed", BannerSeverity::Error, Vec::new());
1149        seen.read = true;
1150        m.push(seen);
1151        let row = m.entries().with_item(0, |e| e.clone()).unwrap();
1152        assert!(row.read);
1153        assert_eq!(m.unread_count().get(), 0);
1154    }
1155
1156    /// A row keeps a bounded history: a progress notice that words each step
1157    /// differently records one update a step, and an id reused for every run
1158    /// of an operation gathers them from every run.
1159    #[test]
1160    fn a_rows_update_history_is_bounded() {
1161        let m = NotificationArchiveModel::in_memory();
1162        m.push(notice("Importing 0%", BannerSeverity::Info, Vec::new()));
1163        for percent in 1..=100 {
1164            m.push_update(notice(
1165                &format!("Importing {percent}%"),
1166                BannerSeverity::Info,
1167                Vec::new(),
1168            ));
1169        }
1170        let row = m.entries().with_item(0, |e| e.clone()).unwrap();
1171        assert_eq!(row.updates.len(), UPDATE_HISTORY_LIMIT);
1172        assert_eq!(
1173            row.updates.last().and_then(|u| u.title.as_deref()),
1174            Some("Importing 100%"),
1175            "the newest records are the ones kept"
1176        );
1177        assert_eq!(
1178            row.updates.first().and_then(|u| u.title.as_deref()),
1179            Some(format!("Importing {}%", 101 - UPDATE_HISTORY_LIMIT).as_str())
1180        );
1181
1182        // A notice raised again under the same id is bounded the same way.
1183        for _ in 0..5 {
1184            m.push(notice("Importing 0%", BannerSeverity::Info, Vec::new()));
1185        }
1186        let row = m.entries().with_item(0, |e| e.clone()).unwrap();
1187        assert_eq!(row.updates.len(), UPDATE_HISTORY_LIMIT);
1188    }
1189
1190    /// An update whose row is gone (cleared by the user, or evicted) is not
1191    /// lost: it becomes a row of its own.
1192    #[test]
1193    fn an_update_with_no_row_is_pushed_as_one() {
1194        let m = NotificationArchiveModel::in_memory();
1195        m.push(notice("Importing", BannerSeverity::Info, Vec::new()));
1196        m.clear();
1197        m.push_update(notice("Imported", BannerSeverity::Success, Vec::new()));
1198        assert_eq!(m.entries().len(), 1);
1199        let row = m.entries().with_item(0, |e| e.clone()).unwrap();
1200        assert_eq!(row.title, "Imported");
1201        assert!(!row.read);
1202        assert_eq!(m.unread_count().get(), 1);
1203    }
1204
1205    /// The row a notice was raised again into reaches disk at the top, under
1206    /// its new date: the persistent backend moves it like the in-memory one.
1207    #[test]
1208    fn a_notice_raised_again_persists_at_the_top() {
1209        use tempfile::tempdir;
1210        let dir = tempdir().unwrap();
1211        let paths = AppPaths::for_testing(dir.path());
1212        let archive = NotificationArchive::persistent("raised_again_test");
1213        let again = jiff::Timestamp::from_second(15 * 3600).unwrap();
1214        {
1215            let m = NotificationArchiveModel::open(&archive, &paths, Duration::ZERO).unwrap();
1216            m.push(notice("Sync failed", BannerSeverity::Error, Vec::new()));
1217            m.push(entry("Saved"));
1218            m.mark_all_read();
1219            let mut raised = notice("Sync failed", BannerSeverity::Error, Vec::new());
1220            raised.timestamp = again;
1221            m.push(raised);
1222            m.flush_now().unwrap();
1223        }
1224        let reopened = NotificationArchiveModel::open(&archive, &paths, Duration::ZERO).unwrap();
1225        assert_eq!(reopened.entries().len(), 2);
1226        let row = reopened.entries().with_item(0, |e| e.clone()).unwrap();
1227        assert_eq!(row.dedup_id.as_deref(), Some("import"));
1228        assert_eq!(row.timestamp, again);
1229        assert!(!row.read);
1230        assert_eq!(reopened.unread_count().get(), 1);
1231    }
1232
1233    #[test]
1234    fn persistent_round_trip() {
1235        use tempfile::tempdir;
1236        let dir = tempdir().unwrap();
1237        let paths = AppPaths::for_testing(dir.path());
1238        let archive = NotificationArchive::persistent("notifications_test");
1239
1240        // First open: push two entries, flush.
1241        {
1242            let m = NotificationArchiveModel::open(&archive, &paths, Duration::ZERO).unwrap();
1243            m.push(entry("first"));
1244            m.push(entry("second"));
1245            m.flush_now().unwrap();
1246            assert_eq!(m.entries().len(), 2);
1247        }
1248
1249        // Re-open: same entries, ids stamped previously survive.
1250        let m = NotificationArchiveModel::open(&archive, &paths, Duration::ZERO).unwrap();
1251        assert_eq!(m.entries().len(), 2);
1252        // Newest first.
1253        assert_eq!(
1254            m.entries().with_item(0, |e| e.title.clone()),
1255            Some("second".into())
1256        );
1257        // unread_count reseeded from the file (entries had read=false).
1258        assert_eq!(m.unread_count().get(), 2);
1259        // Next push gets id past the highest persisted id.
1260        m.push(entry("third"));
1261        let third_id = m.entries().with_item(0, |e| e.id).unwrap();
1262        let second_id = m.entries().with_item(1, |e| e.id).unwrap();
1263        assert!(
1264            third_id > second_id,
1265            "ids continue increasing across restarts (third {third_id} > second {second_id})"
1266        );
1267    }
1268
1269    /// Bug-repro for the raw-`ListModel`-mutation hazard flagged when
1270    /// `PersistedListModel::model()` became read/reactive-binding-only:
1271    /// every mutating `NotificationArchiveModel` method must persist
1272    /// through the backend's `upsert_front` / `update_in_place` /
1273    /// `remove` / `clear`, never by mutating `backend.model()`
1274    /// directly (which would update the live in-memory `ListModel` but
1275    /// silently never reach disk). Exercises each one and reopens a
1276    /// fresh handle over the same file to prove the effect actually
1277    /// landed, not just that the live model looks right.
1278    #[test]
1279    fn mark_all_read_persists_across_reopen() {
1280        use tempfile::tempdir;
1281        let dir = tempdir().unwrap();
1282        let paths = AppPaths::for_testing(dir.path());
1283        let archive = NotificationArchive::persistent("mark_read_test");
1284
1285        {
1286            let m = NotificationArchiveModel::open(&archive, &paths, Duration::ZERO).unwrap();
1287            m.push(entry("a"));
1288            m.push(entry("b"));
1289            m.mark_all_read();
1290            m.flush_now().unwrap();
1291        }
1292
1293        let reopened = NotificationArchiveModel::open(&archive, &paths, Duration::ZERO).unwrap();
1294        assert_eq!(
1295            reopened.unread_count().get(),
1296            0,
1297            "read state must have been persisted, not just live-mutated"
1298        );
1299        assert!(reopened.entries().with_item(0, |e| e.read).unwrap());
1300        assert!(reopened.entries().with_item(1, |e| e.read).unwrap());
1301    }
1302
1303    #[test]
1304    fn remove_by_id_persists_across_reopen() {
1305        use tempfile::tempdir;
1306        let dir = tempdir().unwrap();
1307        let paths = AppPaths::for_testing(dir.path());
1308        let archive = NotificationArchive::persistent("remove_test");
1309
1310        let removed_title;
1311        {
1312            let m = NotificationArchiveModel::open(&archive, &paths, Duration::ZERO).unwrap();
1313            m.push(entry("a"));
1314            m.push(entry("b"));
1315            let b_id = m.entries().with_item(0, |e| e.id).unwrap();
1316            removed_title = m.entries().with_item(0, |e| e.title.clone()).unwrap();
1317            m.remove_by_id(b_id);
1318            m.flush_now().unwrap();
1319            assert_eq!(m.entries().len(), 1);
1320        }
1321
1322        let reopened = NotificationArchiveModel::open(&archive, &paths, Duration::ZERO).unwrap();
1323        assert_eq!(
1324            reopened.entries().len(),
1325            1,
1326            "the removal must have reached disk, not just the live model"
1327        );
1328        assert_eq!(
1329            reopened.entries().with_item(0, |e| e.title.clone()),
1330            Some("a".to_string())
1331        );
1332        assert_ne!(
1333            reopened.entries().with_item(0, |e| e.title.clone()),
1334            Some(removed_title)
1335        );
1336    }
1337
1338    #[test]
1339    fn dedup_merge_update_in_place_persists_across_reopen() {
1340        use tempfile::tempdir;
1341        let dir = tempdir().unwrap();
1342        let paths = AppPaths::for_testing(dir.path());
1343        let archive = NotificationArchive::persistent("dedup_test");
1344
1345        {
1346            let m = NotificationArchiveModel::open(&archive, &paths, Duration::ZERO).unwrap();
1347            let mut first = entry("Uploading 1 of 7");
1348            first.dedup_id = Some("upload".to_string());
1349            m.push(first);
1350            let mut second = entry("Uploading 4 of 7");
1351            second.dedup_id = Some("upload".to_string());
1352            m.push_update(second);
1353            m.flush_now().unwrap();
1354            assert_eq!(m.entries().len(), 1, "merged into one row");
1355        }
1356
1357        let reopened = NotificationArchiveModel::open(&archive, &paths, Duration::ZERO).unwrap();
1358        assert_eq!(reopened.entries().len(), 1, "still one row after reopen");
1359        let merged = reopened.entries().with_item(0, |e| e.clone()).unwrap();
1360        assert_eq!(
1361            merged.title, "Uploading 4 of 7",
1362            "the in-place update's title must have persisted, not the original"
1363        );
1364        assert_eq!(
1365            merged.updates.len(),
1366            1,
1367            "the appended NotificationUpdate must have persisted"
1368        );
1369    }
1370
1371    /// The row a notice was updated into is the row that reaches disk: its
1372    /// actions, replay names included, and its severity survive a restart.
1373    #[test]
1374    fn a_merged_rows_actions_persist_across_reopen() {
1375        use tempfile::tempdir;
1376        let dir = tempdir().unwrap();
1377        let paths = AppPaths::for_testing(dir.path());
1378        let archive = NotificationArchive::persistent("merged_actions_test");
1379        let report = vec![action(
1380            "See report",
1381            Some("app.import.report"),
1382            ArchivedActionStyle::Link,
1383        )];
1384        {
1385            let m = NotificationArchiveModel::open(&archive, &paths, Duration::ZERO).unwrap();
1386            m.push(notice(
1387                "Importing",
1388                BannerSeverity::Info,
1389                vec![action("Cancel", None, ArchivedActionStyle::Destructive)],
1390            ));
1391            m.push_update(notice("Imported", BannerSeverity::Success, report.clone()));
1392            m.flush_now().unwrap();
1393        }
1394        let reopened = NotificationArchiveModel::open(&archive, &paths, Duration::ZERO).unwrap();
1395        let row = reopened.entries().with_item(0, |e| e.clone()).unwrap();
1396        assert_eq!(row.actions, report);
1397        assert_eq!(row.severity, BannerSeverity::Success);
1398    }
1399
1400    #[test]
1401    fn clear_persists_across_reopen() {
1402        use tempfile::tempdir;
1403        let dir = tempdir().unwrap();
1404        let paths = AppPaths::for_testing(dir.path());
1405        let archive = NotificationArchive::persistent("clear_test");
1406
1407        {
1408            let m = NotificationArchiveModel::open(&archive, &paths, Duration::ZERO).unwrap();
1409            m.push(entry("a"));
1410            m.push(entry("b"));
1411            m.clear();
1412            m.flush_now().unwrap();
1413            assert_eq!(m.entries().len(), 0);
1414        }
1415
1416        let reopened = NotificationArchiveModel::open(&archive, &paths, Duration::ZERO).unwrap();
1417        assert_eq!(
1418            reopened.entries().len(),
1419            0,
1420            "the clear must have reached disk, not just the live model"
1421        );
1422    }
1423
1424    #[test]
1425    fn bounded_eviction_persists_across_reopen() {
1426        use tempfile::tempdir;
1427        let dir = tempdir().unwrap();
1428        let paths = AppPaths::for_testing(dir.path());
1429        let archive = NotificationArchive::persistent_with_limit("eviction_test", 2);
1430
1431        {
1432            let m = NotificationArchiveModel::open(&archive, &paths, Duration::ZERO).unwrap();
1433            m.push(entry("t0"));
1434            m.push(entry("t1"));
1435            m.push(entry("t2")); // evicts t0
1436            m.flush_now().unwrap();
1437            assert_eq!(m.entries().len(), 2);
1438        }
1439
1440        let reopened = NotificationArchiveModel::open(&archive, &paths, Duration::ZERO).unwrap();
1441        assert_eq!(
1442            reopened.entries().len(),
1443            2,
1444            "the eviction must have reached disk, not just the live model"
1445        );
1446        let titles: Vec<String> = (0..reopened.entries().len())
1447            .map(|i| {
1448                reopened
1449                    .entries()
1450                    .with_item(i, |e| e.title.clone())
1451                    .unwrap()
1452            })
1453            .collect();
1454        assert!(
1455            !titles.contains(&"t0".to_string()),
1456            "t0 was evicted: {titles:?}"
1457        );
1458        assert!(titles.contains(&"t1".to_string()));
1459        assert!(titles.contains(&"t2".to_string()));
1460    }
1461
1462    #[test]
1463    fn version_signal_bumps_on_push_mark_clear_remove() {
1464        let m = NotificationArchiveModel::in_memory();
1465        let v0 = m.version_signal().get();
1466        m.push(entry("a"));
1467        let v1 = m.version_signal().get();
1468        assert_ne!(v0, v1, "push bumps version");
1469
1470        m.push(entry("b"));
1471        m.mark_all_read();
1472        let v2 = m.version_signal().get();
1473        assert_ne!(v1, v2, "mark_all_read bumps version");
1474
1475        let id0 = m.entries().with_item(0, |e| e.id).unwrap();
1476        m.remove_by_id(id0);
1477        let v3 = m.version_signal().get();
1478        assert_ne!(v2, v3, "remove bumps version");
1479
1480        m.clear();
1481        let v4 = m.version_signal().get();
1482        assert_ne!(v3, v4, "clear bumps version");
1483    }
1484
1485    #[test]
1486    fn version_signal_does_not_bump_for_noops() {
1487        let m = NotificationArchiveModel::in_memory();
1488        m.push(entry("a"));
1489        let v_before = m.version_signal().get();
1490        // mark_all_read on a fully-read archive — no mutation, no bump.
1491        m.mark_all_read();
1492        let v_after_mark1 = m.version_signal().get();
1493        m.mark_all_read();
1494        let v_after_mark2 = m.version_signal().get();
1495        assert_eq!(
1496            v_after_mark1, v_after_mark2,
1497            "second mark_all_read with nothing to flip is a no-op (no version bump)"
1498        );
1499
1500        // clear on already-empty archive — no bump.
1501        m.clear();
1502        let v_after_clear1 = m.version_signal().get();
1503        m.clear();
1504        let v_after_clear2 = m.version_signal().get();
1505        assert_eq!(v_after_clear1, v_after_clear2, "clear on empty is a no-op");
1506        let _ = v_before;
1507    }
1508
1509    #[test]
1510    fn mark_read_where_only_flips_matching_unread_entries() {
1511        use crate::toast::ToastAudience;
1512        let m = NotificationArchiveModel::in_memory();
1513        let mut a = entry("audience a");
1514        a.route = ToastRoute::Audience(ToastAudience::new(1));
1515        m.push(a);
1516        let mut b = entry("audience b");
1517        b.route = ToastRoute::Audience(ToastAudience::new(2));
1518        m.push(b);
1519        assert_eq!(m.unread_count().get(), 2);
1520
1521        // Scoped mark-read for audience 1 only.
1522        m.mark_read_where(|e| e.route == ToastRoute::Audience(ToastAudience::new(1)));
1523        assert_eq!(
1524            m.unread_count().get(),
1525            1,
1526            "only audience 1's entry was marked read"
1527        );
1528        let a_read = m
1529            .entries()
1530            .with_item(1, |e| e.read)
1531            .expect("audience a is the oldest, at index 1");
1532        let b_read = m
1533            .entries()
1534            .with_item(0, |e| e.read)
1535            .expect("audience b is newest, at index 0");
1536        assert!(a_read, "audience a's entry is now read");
1537        assert!(!b_read, "audience b's entry is untouched");
1538    }
1539
1540    #[test]
1541    fn clear_where_only_removes_matching_entries() {
1542        use crate::toast::ToastAudience;
1543        let m = NotificationArchiveModel::in_memory();
1544        let mut a = entry("audience a");
1545        a.route = ToastRoute::Audience(ToastAudience::new(1));
1546        m.push(a);
1547        let mut b = entry("audience b");
1548        b.route = ToastRoute::Audience(ToastAudience::new(2));
1549        m.push(b);
1550        assert_eq!(m.entries().len(), 2);
1551        assert_eq!(m.unread_count().get(), 2);
1552
1553        m.clear_where(|e| e.route == ToastRoute::Audience(ToastAudience::new(1)));
1554        assert_eq!(m.entries().len(), 1, "only audience 1's entry is removed");
1555        assert_eq!(
1556            m.unread_count().get(),
1557            1,
1558            "unread_count decrements for the removed unread entry"
1559        );
1560        assert_eq!(
1561            m.entries().with_item(0, |e| e.title.clone()),
1562            Some("audience b".to_string()),
1563            "audience b's entry survives"
1564        );
1565    }
1566
1567    #[test]
1568    fn entry_serde_round_trip() {
1569        // NotificationEntry must be round-trippable through TOML
1570        // (PersistedListModel's serialization format).
1571        let original = NotificationEntry {
1572            id: 42,
1573            severity: BannerSeverity::Warning,
1574            priority: ToastPriority::High,
1575            title: "Heads up".into(),
1576            body: Some("Details here".into()),
1577            actions: vec![crate::notification::ArchivedAction {
1578                label: "Open".into(),
1579                intent_name: Some("app.open".into()),
1580                style: ArchivedActionStyle::PrimaryButton,
1581                closes_on_invoke: true,
1582            }],
1583            timestamp: jiff::Timestamp::UNIX_EPOCH,
1584            group: Some("build".into()),
1585            source: Some("build.success".into()),
1586            read: false,
1587            dedup_id: Some("build-1".into()),
1588            updates: vec![],
1589            route: ToastRoute::Audience(crate::toast::ToastAudience::new(7)),
1590        };
1591        // Wrap in a Vec because TOML doesn't allow a top-level
1592        // non-table value, and our ListFile is `{ version, items }`.
1593        let wrapper = teksilo_settings::ListFile {
1594            version: 1,
1595            items: vec![original.clone()],
1596        };
1597        let serialized = toml::to_string(&wrapper).expect("serialize");
1598        let parsed: teksilo_settings::ListFile<NotificationEntry> =
1599            toml::from_str(&serialized).expect("deserialize");
1600        assert_eq!(parsed.items.len(), 1);
1601        assert_eq!(parsed.items[0], original);
1602    }
1603
1604    // ----- multi-window rebuild notification -----
1605
1606    /// What the deleted `window_versions` map used to buy, now a
1607    /// property of the shared signal itself: N windows' bells / logs
1608    /// each bind the SAME `version_signal` at `Rebuild`, and every one
1609    /// of them registers the mutation.
1610    ///
1611    /// Three independent `BindingRegistry`s stand in for three windows,
1612    /// bound the way `NotificationCenterButton::build` and
1613    /// `NotificationLog::build` bind. The reconcile-order half of the
1614    /// property — that one window's flush does not consume another's —
1615    /// needs real trees, and is covered by
1616    /// `center_button::tests::two_windowless_trees_both_rebuild_on_one_archive_push`.
1617    #[test]
1618    fn every_windows_binding_sees_an_archive_mutation() {
1619        use teksilo_core::binding::{BindingLevel, BindingRegistry};
1620        use teksilo_core::widget_id::WidgetId;
1621
1622        let m = NotificationArchiveModel::in_memory();
1623        let bell: WidgetId = slotmap::KeyData::from_ffi(1).into();
1624        let windows: Vec<BindingRegistry> = (0..3).map(|_| BindingRegistry::new()).collect();
1625        for reg in &windows {
1626            m.version_signal().bind_to(bell, reg, BindingLevel::Rebuild);
1627        }
1628        for reg in &windows {
1629            assert!(!reg.any_dirty(), "a fresh binding starts clean");
1630        }
1631
1632        m.push(entry("first"));
1633
1634        for (i, reg) in windows.iter().enumerate() {
1635            assert!(
1636                reg.any_dirty(),
1637                "window {i} missed the archive mutation — asking window 0 \
1638                 must not have consumed it"
1639            );
1640        }
1641    }
1642}