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/// Storage mode for the notification archive. Passed inside
44/// `ToastInstallOptions::archive` to the install helper.
45#[derive(Debug, Clone)]
46pub enum NotificationArchive {
47    /// Session-only — entries live in a `ListModel` for the running
48    /// session. Cheap, no disk I/O. Default for apps that don't
49    /// install a `SettingsBundle`.
50    InMemory { limit: usize },
51    /// File-backed via `PersistedListModel`. The path is built at
52    /// install time from [`AppPaths::config_file`] using the configured
53    /// `file_name`.
54    Persistent { file_name: String, limit: usize },
55}
56
57impl NotificationArchive {
58    /// In-memory archive with the default 200-entry cap.
59    pub fn in_memory() -> Self {
60        Self::InMemory {
61            limit: DEFAULT_ARCHIVE_LIMIT,
62        }
63    }
64
65    /// In-memory archive with a custom cap.
66    pub fn in_memory_with_limit(limit: usize) -> Self {
67        Self::InMemory { limit }
68    }
69
70    /// File-backed archive resolved through `AppPaths::config_file`
71    /// at install time. The default file name (`"notifications"`)
72    /// yields `<config_dir>/<app>/notifications.toml`. Apps that
73    /// want a different name pass it here; tests pass an arbitrary
74    /// name and use `AppPaths::for_testing(tmpdir)`.
75    pub fn persistent(file_name: impl Into<String>) -> Self {
76        Self::Persistent {
77            file_name: file_name.into(),
78            limit: DEFAULT_ARCHIVE_LIMIT,
79        }
80    }
81
82    pub fn persistent_with_limit(file_name: impl Into<String>, limit: usize) -> Self {
83        Self::Persistent {
84            file_name: file_name.into(),
85            limit,
86        }
87    }
88
89    pub fn limit(&self) -> usize {
90        match self {
91            Self::InMemory { limit } | Self::Persistent { limit, .. } => *limit,
92        }
93    }
94}
95
96/// Errors during archive construction or persistence I/O.
97#[derive(Debug, thiserror::Error)]
98pub enum NotificationArchiveError {
99    /// Couldn't load / write the persistent-backing file. Maps to a
100    /// `SettingsFileError`. Apps usually surface this once at startup
101    /// and fall back to `InMemory` for the session.
102    #[error("notification archive file I/O failed: {0}")]
103    File(#[from] SettingsFileError),
104}
105
106/// Either an in-memory `ListModel` or a persistent one — exposed
107/// uniformly through `NotificationArchiveModel::entries()`. Internal
108/// detail; apps work with the model.
109///
110/// Every *mutation* goes through one of this type's own methods
111/// (`upsert_front` / `update_in_place` / `remove` / `clear`), never
112/// through `model()` directly: for the `Persistent` variant,
113/// `PersistedListModel::model()` is read/reactive-binding-only —
114/// mutating it directly would update the live `ListModel` but never
115/// touch disk. Each method updates both variants identically from the
116/// caller's point of view (id-keyed, matching
117/// [`NotificationEntry`]'s [`Keyed`](teksilo_settings::Keyed) impl), so
118/// `NotificationArchiveModel` never has to branch on which backend it
119/// holds.
120enum ArchiveBackend {
121    InMemory(ListModel<NotificationEntry>),
122    Persistent(PersistedListModel<NotificationEntry>),
123}
124
125impl ArchiveBackend {
126    fn model(&self) -> &ListModel<NotificationEntry> {
127        match self {
128            Self::InMemory(m) => m,
129            Self::Persistent(p) => p.model(),
130        }
131    }
132
133    /// Find the entry with `id` and its current index, via the
134    /// live reactive model (works identically for both variants: for
135    /// `Persistent`, the live model always mirrors on-disk content).
136    fn find_by_id(&self, id: u64) -> Option<(usize, NotificationEntry)> {
137        let model = self.model();
138        (0..model.len()).find_map(|i| {
139            model
140                .with_item(i, |e| e.clone())
141                .filter(|e| e.id == id)
142                .map(|e| (i, e))
143        })
144    }
145
146    /// Insert `entry` at the front. `entry.id` is always freshly
147    /// stamped and therefore unique, so this never actually collides
148    /// with (and removes) an existing row — it's a plain prepend.
149    fn upsert_front(&self, entry: NotificationEntry) {
150        match self {
151            Self::InMemory(m) => m.insert(0, entry),
152            Self::Persistent(p) => p.upsert_front(entry),
153        }
154    }
155
156    /// Replace the entry with `entry.id` in place (no reordering).
157    /// Returns whether an entry with that id existed.
158    fn update_in_place(&self, entry: NotificationEntry) -> bool {
159        match self {
160            Self::InMemory(m) => match self.find_by_id(entry.id) {
161                Some((idx, _)) => {
162                    m.set(idx, entry);
163                    true
164                }
165                None => false,
166            },
167            Self::Persistent(p) => p.update_in_place(entry),
168        }
169    }
170
171    /// Remove the entry with `id`, if present. Returns whether
172    /// anything was removed.
173    fn remove(&self, id: u64) -> bool {
174        match self {
175            Self::InMemory(m) => match self.find_by_id(id) {
176                Some((idx, _)) => {
177                    m.remove(idx);
178                    true
179                }
180                None => false,
181            },
182            Self::Persistent(p) => p.remove(&id),
183        }
184    }
185
186    fn clear(&self) {
187        match self {
188            Self::InMemory(m) => m.clear(),
189            Self::Persistent(p) => p.clear(),
190        }
191    }
192
193    fn flush_now(&self) -> Result<(), SettingsFileError> {
194        match self {
195            Self::InMemory(_) => Ok(()),
196            Self::Persistent(p) => p.flush_now(),
197        }
198    }
199}
200
201/// Shared model — clones share state. Constructed by the install
202/// helper from `NotificationArchive` + `AppPaths`; apps reach it
203/// via `ctx.app_state::<Rc<RefCell<NotificationArchiveModel>>>()`.
204///
205/// `NotificationLog` and `NotificationCenterButton`
206/// consume this model directly.
207pub struct NotificationArchiveModel {
208    backend: ArchiveBackend,
209    limit: usize,
210    /// Stable monotonically-increasing per-archive id stamped onto
211    /// each new entry via `next_id.update(|n| n+1)`. Independent of
212    /// the runtime `entry_id` on `ToastHandle` (the toast IDs are
213    /// per-session; archive IDs persist across restarts).
214    next_id: Cell<u64>,
215    /// Live unread count. Increments on `push` of an unread entry,
216    /// resets to zero on `mark_all_read`. Drives the bell-button
217    /// badge.
218    unread_count: Signal<usize>,
219    /// Monotonic version bumped on every mutation (push, in-place
220    /// update, mark_all_read, clear, remove). The
221    /// [`NotificationLog`](super::log::NotificationLog) binds to
222    /// this at `BindingLevel::Rebuild` so any archive change
223    /// triggers a fresh log rebuild — needed for the day-bucket
224    /// header re-computation. Same shape as
225    /// [`OverlayManager::version`](teksilo_core::overlay::OverlayManager::version)
226    /// and [`ToastRegistry::version_signal`](crate::toast::ToastRegistry::version_signal).
227    version: Signal<u64>,
228}
229
230impl NotificationArchiveModel {
231    /// Construct from a [`NotificationArchive`] config. For
232    /// `Persistent` mode, resolves the path through `AppPaths`.
233    /// Tests use `AppPaths::for_testing(tmpdir)` + `Duration::ZERO`
234    /// debounce.
235    pub fn open(
236        archive: &NotificationArchive,
237        paths: &AppPaths,
238        debounce: Duration,
239    ) -> Result<Self, NotificationArchiveError> {
240        let limit = archive.limit();
241        let backend = match archive {
242            NotificationArchive::InMemory { .. } => ArchiveBackend::InMemory(ListModel::new()),
243            NotificationArchive::Persistent { file_name, .. } => {
244                let path: PathBuf = paths.config_file(file_name);
245                let plm: PersistedListModel<NotificationEntry> =
246                    PersistedListModel::open(path, debounce, Migrator::new())?;
247                ArchiveBackend::Persistent(plm)
248            }
249        };
250        // Initialize next_id past the largest existing id so persistent
251        // archives don't collide ids across restarts.
252        let model = backend.model();
253        let next_id_seed = (0..model.len())
254            .filter_map(|i| model.with_item(i, |e| e.id))
255            .max()
256            .map(|m| m + 1)
257            .unwrap_or(1);
258        // Initial unread_count reflects what's on disk.
259        let initial_unread = (0..model.len())
260            .filter_map(|i| model.with_item(i, |e| !e.read))
261            .filter(|x| *x)
262            .count();
263        Ok(Self {
264            backend,
265            limit,
266            next_id: Cell::new(next_id_seed),
267            unread_count: Signal::new(initial_unread),
268            version: Signal::new(0),
269        })
270    }
271
272    /// Convenience: construct an [`NotificationArchive::InMemory`]
273    /// archive with the default cap, without going through paths.
274    /// Mostly useful for tests and apps that explicitly want no
275    /// persistence.
276    pub fn in_memory() -> Self {
277        Self {
278            backend: ArchiveBackend::InMemory(ListModel::new()),
279            limit: DEFAULT_ARCHIVE_LIMIT,
280            next_id: Cell::new(1),
281            unread_count: Signal::new(0),
282            version: Signal::new(0),
283        }
284    }
285
286    /// Reactive handle on the entries. Bind to a `ListView` /
287    /// `Repeater` for live UI.
288    pub fn entries(&self) -> &ListModel<NotificationEntry> {
289        self.backend.model()
290    }
291
292    /// Signal of the unread count. Drives the bell-button badge.
293    pub fn unread_count(&self) -> &Signal<usize> {
294        &self.unread_count
295    }
296
297    /// Reactive handle on the archive's mutation version. Widgets
298    /// that render the archive (`NotificationLog`,
299    /// `NotificationCenterButton`) bind to this at
300    /// `BindingLevel::Rebuild`, in every window — one signal is enough
301    /// for N of them, see
302    /// [`ToastRegistry::version_signal`](crate::toast::ToastRegistry::version_signal)
303    /// for the history of why that had to be said out loud.
304    pub fn version_signal(&self) -> &Signal<u64> {
305        &self.version
306    }
307
308    pub fn limit(&self) -> usize {
309        self.limit
310    }
311
312    /// Bump the version every bell / log binds at
313    /// `BindingLevel::Rebuild` — see [`Self::version_signal`]. One
314    /// write reaches every window: each window's own `BindingRegistry`
315    /// tracks the generation it last reconciled, so none of them can
316    /// consume the notification out from under the others.
317    fn bump_version(&self) {
318        let v = self.version.get();
319        self.version.set(v.wrapping_add(1));
320    }
321
322    /// Force the persistent backing file to disk synchronously.
323    /// No-op for `InMemory`. Tests call this between mutations and
324    /// re-opening the file to verify persistence.
325    pub fn flush_now(&self) -> Result<(), SettingsFileError> {
326        self.backend.flush_now()
327    }
328
329    /// Push a new entry. Inserts at index 0 (newest first), evicts
330    /// the oldest if the resulting length exceeds `limit`. Stamps
331    /// the entry's `id` field from `next_id`. Bumps `unread_count`
332    /// when the entry is unread (which is the typical case from a
333    /// toast push).
334    ///
335    /// If `entry.dedup_id` matches an existing entry, the existing
336    /// entry is updated in place (title / body / progress collapsed
337    /// into a `NotificationUpdate` appended to `updates`) and no
338    /// new row is inserted. Unread count increments either way (an
339    /// in-place update IS new information for the user).
340    pub fn push(&self, mut entry: NotificationEntry) {
341        self.bump_version();
342        let model = self.backend.model();
343
344        // Update-in-place merge: scan for a matching `dedup_id`.
345        if let Some(ref new_dedup) = entry.dedup_id {
346            let merge_idx = (0..model.len()).find(|&i| {
347                model
348                    .with_item(i, |e| e.dedup_id.as_deref() == Some(new_dedup.as_str()))
349                    .unwrap_or(false)
350            });
351            if let Some(idx) = merge_idx {
352                // Read the existing entry, append an update, and write
353                // it back **in place** (same id, no reordering) — this
354                // is exactly `PersistedListModel::update_in_place`'s
355                // contract, and matches it identically for the
356                // in-memory backend too. We preserve the original `id`
357                // + `timestamp` and append a `NotificationUpdate`
358                // describing the mutation.
359                if let Some(mut existing) = model.with_item(idx, |e| e.clone()) {
360                    let now = entry.timestamp;
361                    let title_changed = existing.title != entry.title;
362                    let body_changed = existing.body != entry.body;
363                    existing
364                        .updates
365                        .push(crate::notification::NotificationUpdate {
366                            timestamp: now,
367                            title: if title_changed {
368                                Some(entry.title.clone())
369                            } else {
370                                None
371                            },
372                            body: if body_changed {
373                                entry.body.clone()
374                            } else {
375                                None
376                            },
377                            progress: None,
378                        });
379                    existing.title = entry.title;
380                    existing.body = entry.body;
381                    existing.read = false;
382                    self.backend.update_in_place(existing);
383                    self.bump_unread();
384                    return;
385                }
386            }
387        }
388
389        // New entry: stamp the id (always fresh, so `upsert_front` is
390        // a plain prepend — it never collides with an existing key),
391        // then evict overflow.
392        let next = self.next_id.get();
393        entry.id = next;
394        self.next_id.set(next.wrapping_add(1));
395        let is_unread = !entry.read;
396        self.backend.upsert_front(entry);
397        if model.len() > self.limit {
398            // Evict the oldest entry. The model has no `pop_back`;
399            // remove-by-id of the last row is the equivalent.
400            let last = model.len() - 1;
401            if let Some(evicted) = model.with_item(last, |e| e.clone()) {
402                // If the evicted entry was unread, decrement the
403                // unread count so the badge doesn't lie about how
404                // many sit on disk.
405                if !evicted.read {
406                    let n = self.unread_count.get();
407                    self.unread_count.set(n.saturating_sub(1));
408                }
409                self.backend.remove(evicted.id);
410            }
411        }
412        if is_unread {
413            self.bump_unread();
414        }
415    }
416
417    fn bump_unread(&self) {
418        let n = self.unread_count.get();
419        self.unread_count.set(n.saturating_add(1));
420    }
421
422    /// Mark every UNREAD entry matching `predicate` as read,
423    /// decrementing `unread_count` by exactly how many were flipped.
424    /// This is the scoped counterpart of [`mark_all_read`](Self::mark_all_read):
425    /// a bell scoped to one window/audience must only mark ITS
426    /// entries read on close — calling the unscoped `mark_all_read`
427    /// from a scoped bell would incorrectly clear every OTHER
428    /// window's/audience's unread state too.
429    pub fn mark_read_where(&self, mut predicate: impl FnMut(&NotificationEntry) -> bool) {
430        let model = self.backend.model();
431        let ids: Vec<u64> = (0..model.len())
432            .filter_map(|i| {
433                model
434                    .with_item(i, |e| (!e.read && predicate(e)).then_some(e.id))
435                    .flatten()
436            })
437            .collect();
438        if ids.is_empty() {
439            return;
440        }
441        let mut mutated = false;
442        for id in ids {
443            if let Some((_, mut entry)) = self.backend.find_by_id(id) {
444                entry.read = true;
445                self.backend.update_in_place(entry);
446                mutated = true;
447                let n = self.unread_count.get();
448                self.unread_count.set(n.saturating_sub(1));
449            }
450        }
451        if mutated {
452            self.bump_version();
453        }
454    }
455
456    /// Mark every archived entry as read; reset `unread_count` to 0.
457    /// Called by `NotificationCenterButton` when its popover opens.
458    pub fn mark_all_read(&self) {
459        let model = self.backend.model();
460        // Collect the ids to flip first: mutating the persisted
461        // backend's live model mid-scan (`update_in_place` writes
462        // straight into `model`, same length, no reordering) is safe
463        // for this loop either way, but reading into an owned `Vec`
464        // up front keeps the read and the mutation cleanly separated.
465        let unread_ids: Vec<u64> = (0..model.len())
466            .filter_map(|i| model.with_item(i, |e| (!e.read).then_some(e.id)).flatten())
467            .collect();
468        let mut mutated = false;
469        for id in unread_ids {
470            if let Some((_, mut entry)) = self.backend.find_by_id(id) {
471                entry.read = true;
472                self.backend.update_in_place(entry);
473                mutated = true;
474            }
475        }
476        self.unread_count.set(0);
477        if mutated {
478            self.bump_version();
479        }
480    }
481
482    /// Clear the entire archive (resets `unread_count` to 0).
483    pub fn clear(&self) {
484        let was_empty = self.backend.model().is_empty();
485        self.backend.clear();
486        self.unread_count.set(0);
487        if !was_empty {
488            self.bump_version();
489        }
490    }
491
492    /// Remove every entry matching `predicate`, decrementing
493    /// `unread_count` for each removed entry that was unread. The
494    /// scoped counterpart of [`clear`](Self::clear): a bell scoped to
495    /// one window/audience must only clear ITS entries — the unscoped
496    /// `clear()` wipes the ENTIRE shared archive (every window's
497    /// history), which would be wrong for a scoped "Clear" button.
498    pub fn clear_where(&self, mut predicate: impl FnMut(&NotificationEntry) -> bool) {
499        let model = self.backend.model();
500        let matches: Vec<(u64, bool)> = (0..model.len())
501            .filter_map(|i| {
502                model
503                    .with_item(i, |e| predicate(e).then_some((e.id, !e.read)))
504                    .flatten()
505            })
506            .collect();
507        if matches.is_empty() {
508            return;
509        }
510        let mut removed_any = false;
511        for (id, was_unread) in matches {
512            if self.backend.remove(id) {
513                removed_any = true;
514                if was_unread {
515                    let n = self.unread_count.get();
516                    self.unread_count.set(n.saturating_sub(1));
517                }
518            }
519        }
520        if removed_any {
521            self.bump_version();
522        }
523    }
524
525    /// Remove the entry with the given **stable** id (see
526    /// [`NotificationEntry::id`] — "assigned by the archive on first
527    /// push; never reused"). Updates `unread_count` if the removed entry
528    /// was unread. No-op (no version bump) when no entry has that id.
529    ///
530    /// Deliberately id-based rather than index-based: an index is a
531    /// snapshot of the list's shape at the moment it was read, and is
532    /// meaningless once anything else — a concurrent peer-process reload
533    /// merged in via the live archive, another `push`, another `remove` —
534    /// has shifted rows out from under it. A caller that captured "the row
535    /// I want to dismiss" as an index earlier and replays it later against
536    /// a since-mutated list can silently remove the *wrong* entry; keying
537    /// off `id` instead re-resolves the row's current position at the
538    /// moment of removal, so it always removes the entry the caller meant.
539    pub fn remove_by_id(&self, id: u64) {
540        let Some((_, entry)) = self.backend.find_by_id(id) else {
541            return;
542        };
543        let was_unread = !entry.read;
544        self.backend.remove(id);
545        if was_unread {
546            let n = self.unread_count.get();
547            self.unread_count.set(n.saturating_sub(1));
548        }
549        self.bump_version();
550    }
551}
552
553impl std::fmt::Debug for NotificationArchiveModel {
554    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
555        f.debug_struct("NotificationArchiveModel")
556            .field("entries", &self.entries().len())
557            .field("limit", &self.limit)
558            .field("unread_count", &self.unread_count.get())
559            .field(
560                "backend",
561                &match &self.backend {
562                    ArchiveBackend::InMemory(_) => "InMemory",
563                    ArchiveBackend::Persistent(_) => "Persistent",
564                },
565            )
566            .finish()
567    }
568}
569
570// `NotificationArchiveModel` deliberately does NOT implement `Clone`.
571// The persistent backend's `PersistedListModel` observer captures a
572// strong reference to the inner `SettingsFile`; a naive Clone would
573// create divergent writers writing the same file. Apps share a
574// single archive through an `Rc<NotificationArchiveModel>` (or
575// `Rc<RefCell<…>>` if mutation through shared handles is needed) —
576// the install helper in `teksilo` puts the model in `app_state` as
577// `Rc<NotificationArchiveModel>`.
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582    use crate::notification::ArchivedActionStyle;
583    use crate::toast::ToastRoute;
584    use teksilo_core::styles::{BannerSeverity, ToastPriority};
585
586    fn entry(title: &str) -> NotificationEntry {
587        NotificationEntry {
588            id: 0, // overwritten by push()
589            severity: BannerSeverity::Info,
590            priority: ToastPriority::Normal,
591            title: title.to_string(),
592            body: None,
593            actions: Vec::new(),
594            timestamp: jiff::Timestamp::UNIX_EPOCH,
595            group: None,
596            source: None,
597            read: false,
598            dedup_id: None,
599            updates: Vec::new(),
600            route: ToastRoute::Broadcast,
601        }
602    }
603
604    #[test]
605    fn in_memory_starts_empty() {
606        let m = NotificationArchiveModel::in_memory();
607        assert_eq!(m.entries().len(), 0);
608        assert_eq!(m.unread_count().get(), 0);
609        assert_eq!(m.limit(), DEFAULT_ARCHIVE_LIMIT);
610    }
611
612    #[test]
613    fn push_inserts_newest_first_and_bumps_unread() {
614        let m = NotificationArchiveModel::in_memory();
615        m.push(entry("first"));
616        m.push(entry("second"));
617        m.push(entry("third"));
618        assert_eq!(m.entries().len(), 3);
619        assert_eq!(m.unread_count().get(), 3);
620        // Newest at index 0.
621        assert_eq!(
622            m.entries().with_item(0, |e| e.title.clone()),
623            Some("third".to_string())
624        );
625        assert_eq!(
626            m.entries().with_item(2, |e| e.title.clone()),
627            Some("first".to_string())
628        );
629    }
630
631    #[test]
632    fn push_stamps_distinct_increasing_ids() {
633        let m = NotificationArchiveModel::in_memory();
634        m.push(entry("a"));
635        m.push(entry("b"));
636        m.push(entry("c"));
637        let id0 = m.entries().with_item(0, |e| e.id).unwrap();
638        let id1 = m.entries().with_item(1, |e| e.id).unwrap();
639        let id2 = m.entries().with_item(2, |e| e.id).unwrap();
640        // Newest = index 0 has the highest id.
641        assert!(id0 > id1);
642        assert!(id1 > id2);
643    }
644
645    #[test]
646    fn bounded_eviction_drops_oldest() {
647        let m = NotificationArchiveModel {
648            backend: ArchiveBackend::InMemory(ListModel::new()),
649            limit: 3,
650            next_id: Cell::new(1),
651            unread_count: Signal::new(0),
652            version: Signal::new(0),
653        };
654        for i in 0..5 {
655            m.push(entry(&format!("t{i}")));
656        }
657        assert_eq!(m.entries().len(), 3, "bounded to limit");
658        assert_eq!(
659            m.unread_count().get(),
660            3,
661            "unread count tracks live entries"
662        );
663        // Newest preserved (t4, t3, t2).
664        assert_eq!(
665            m.entries().with_item(0, |e| e.title.clone()),
666            Some("t4".into())
667        );
668        assert_eq!(
669            m.entries().with_item(1, |e| e.title.clone()),
670            Some("t3".into())
671        );
672        assert_eq!(
673            m.entries().with_item(2, |e| e.title.clone()),
674            Some("t2".into())
675        );
676    }
677
678    #[test]
679    fn mark_all_read_zeros_count_and_flips_entries() {
680        let m = NotificationArchiveModel::in_memory();
681        m.push(entry("a"));
682        m.push(entry("b"));
683        assert_eq!(m.unread_count().get(), 2);
684
685        m.mark_all_read();
686        assert_eq!(m.unread_count().get(), 0);
687        assert!(m.entries().with_item(0, |e| e.read).unwrap());
688        assert!(m.entries().with_item(1, |e| e.read).unwrap());
689    }
690
691    #[test]
692    fn clear_empties_and_zeros_count() {
693        let m = NotificationArchiveModel::in_memory();
694        m.push(entry("a"));
695        m.push(entry("b"));
696        m.clear();
697        assert_eq!(m.entries().len(), 0);
698        assert_eq!(m.unread_count().get(), 0);
699    }
700
701    #[test]
702    fn remove_by_id_unread_decrements_count() {
703        let m = NotificationArchiveModel::in_memory();
704        m.push(entry("a"));
705        m.push(entry("b"));
706        assert_eq!(m.unread_count().get(), 2);
707
708        let b_id = m.entries().with_item(0, |e| e.id).unwrap(); // "b" is newest
709        m.remove_by_id(b_id);
710        assert_eq!(m.entries().len(), 1);
711        assert_eq!(m.unread_count().get(), 1);
712        assert_eq!(
713            m.entries().with_item(0, |e| e.title.clone()),
714            Some("a".to_string())
715        );
716    }
717
718    #[test]
719    fn remove_by_id_read_does_not_change_count() {
720        let m = NotificationArchiveModel::in_memory();
721        m.push(entry("a"));
722        m.mark_all_read();
723        assert_eq!(m.unread_count().get(), 0);
724        let a_id = m.entries().with_item(0, |e| e.id).unwrap();
725        m.remove_by_id(a_id);
726        assert_eq!(m.unread_count().get(), 0);
727        assert!(m.entries().is_empty());
728    }
729
730    #[test]
731    fn remove_by_id_unknown_id_is_a_noop() {
732        let m = NotificationArchiveModel::in_memory();
733        m.push(entry("a"));
734        let v_before = m.version_signal().get();
735        m.remove_by_id(999_999);
736        assert_eq!(m.entries().len(), 1, "nothing removed");
737        assert_eq!(
738            v_before,
739            m.version_signal().get(),
740            "no version bump for a no-op"
741        );
742    }
743
744    #[test]
745    fn remove_by_id_removes_the_right_entry_after_a_concurrent_insert_shifts_indices() {
746        // Bug repro for the index-based API this replaces: a caller reads
747        // "the row to dismiss" as an index, but before it acts, a
748        // concurrent insert (a peer process's reload merged into the live
749        // archive, or just another `push`) shifts every row after it down
750        // by one. An index-based `remove(stale_index)` would then delete
751        // whatever row happens to occupy that index NOW — not the one the
752        // caller meant. `remove_by_id` re-resolves the row's position at
753        // the moment of removal, so it is immune to this.
754        let m = NotificationArchiveModel::in_memory();
755        m.push(entry("a")); // index 1 after "b" below
756        m.push(entry("b")); // index 0
757        assert_eq!(
758            m.entries().with_item(1, |e| e.title.clone()),
759            Some("a".to_string()),
760            "precondition: a is at index 1"
761        );
762        // Caller observes "a" at index 1 and remembers its id to dismiss
763        // it later.
764        let a_id = m
765            .entries()
766            .with_item(1, |e| e.id)
767            .expect("a's id at index 1");
768
769        // Concurrent insert (simulating a peer's write landing directly in
770        // the live model) shifts "a" from index 1 to index 2.
771        m.entries().insert(0, entry("peer-inserted"));
772        assert_eq!(
773            m.entries().with_item(2, |e| e.title.clone()),
774            Some("a".to_string()),
775            "precondition: the insert shifted a to index 2"
776        );
777
778        // A stale `remove(1)` would now delete "b", not "a". `remove_by_id`
779        // must remove "a" regardless of where it ended up.
780        m.remove_by_id(a_id);
781
782        assert_eq!(m.entries().len(), 2, "exactly one entry removed");
783        let remaining: Vec<String> = (0..m.entries().len())
784            .map(|i| m.entries().with_item(i, |e| e.title.clone()).unwrap())
785            .collect();
786        assert!(
787            remaining.contains(&"b".to_string()),
788            "b survives: {remaining:?}"
789        );
790        assert!(
791            remaining.contains(&"peer-inserted".to_string()),
792            "peer-inserted survives: {remaining:?}"
793        );
794        assert!(
795            !remaining.contains(&"a".to_string()),
796            "a — the one actually targeted by id — is gone: {remaining:?}"
797        );
798    }
799
800    #[test]
801    fn update_in_place_merges_by_dedup_id() {
802        let m = NotificationArchiveModel::in_memory();
803        let mut first = entry("Uploading 1 of 7");
804        first.dedup_id = Some("upload".to_string());
805        m.push(first);
806        assert_eq!(m.entries().len(), 1);
807        assert_eq!(m.unread_count().get(), 1);
808
809        // Mark read so the update bumps unread back up.
810        m.mark_all_read();
811        assert_eq!(m.unread_count().get(), 0);
812
813        let mut second = entry("Uploading 4 of 7");
814        second.dedup_id = Some("upload".to_string());
815        m.push(second);
816        assert_eq!(m.entries().len(), 1, "update merges into existing row");
817        assert_eq!(
818            m.unread_count().get(),
819            1,
820            "in-place update is also new info"
821        );
822        let merged = m.entries().with_item(0, |e| e.clone()).unwrap();
823        assert_eq!(merged.title, "Uploading 4 of 7");
824        assert_eq!(merged.updates.len(), 1);
825        assert_eq!(merged.updates[0].title.as_deref(), Some("Uploading 4 of 7"));
826        assert!(!merged.read, "in-place update resets read state");
827    }
828
829    #[test]
830    fn update_in_place_only_merges_on_dedup_match() {
831        let m = NotificationArchiveModel::in_memory();
832        let mut a = entry("first");
833        a.dedup_id = Some("x".to_string());
834        m.push(a);
835        let mut b = entry("second");
836        b.dedup_id = Some("y".to_string());
837        m.push(b);
838        // Different dedup_ids — both rows alive.
839        assert_eq!(m.entries().len(), 2);
840        // Third entry with NO dedup_id never merges.
841        m.push(entry("third"));
842        assert_eq!(m.entries().len(), 3);
843    }
844
845    #[test]
846    fn persistent_round_trip() {
847        use tempfile::tempdir;
848        let dir = tempdir().unwrap();
849        let paths = AppPaths::for_testing(dir.path());
850        let archive = NotificationArchive::persistent("notifications_test");
851
852        // First open: push two entries, flush.
853        {
854            let m = NotificationArchiveModel::open(&archive, &paths, Duration::ZERO).unwrap();
855            m.push(entry("first"));
856            m.push(entry("second"));
857            m.flush_now().unwrap();
858            assert_eq!(m.entries().len(), 2);
859        }
860
861        // Re-open: same entries, ids stamped previously survive.
862        let m = NotificationArchiveModel::open(&archive, &paths, Duration::ZERO).unwrap();
863        assert_eq!(m.entries().len(), 2);
864        // Newest first.
865        assert_eq!(
866            m.entries().with_item(0, |e| e.title.clone()),
867            Some("second".into())
868        );
869        // unread_count reseeded from the file (entries had read=false).
870        assert_eq!(m.unread_count().get(), 2);
871        // Next push gets id past the highest persisted id.
872        m.push(entry("third"));
873        let third_id = m.entries().with_item(0, |e| e.id).unwrap();
874        let second_id = m.entries().with_item(1, |e| e.id).unwrap();
875        assert!(
876            third_id > second_id,
877            "ids continue increasing across restarts (third {third_id} > second {second_id})"
878        );
879    }
880
881    /// Bug-repro for the raw-`ListModel`-mutation hazard flagged when
882    /// `PersistedListModel::model()` became read/reactive-binding-only:
883    /// every mutating `NotificationArchiveModel` method must persist
884    /// through the backend's `upsert_front` / `update_in_place` /
885    /// `remove` / `clear`, never by mutating `backend.model()`
886    /// directly (which would update the live in-memory `ListModel` but
887    /// silently never reach disk). Exercises each one and reopens a
888    /// fresh handle over the same file to prove the effect actually
889    /// landed, not just that the live model looks right.
890    #[test]
891    fn mark_all_read_persists_across_reopen() {
892        use tempfile::tempdir;
893        let dir = tempdir().unwrap();
894        let paths = AppPaths::for_testing(dir.path());
895        let archive = NotificationArchive::persistent("mark_read_test");
896
897        {
898            let m = NotificationArchiveModel::open(&archive, &paths, Duration::ZERO).unwrap();
899            m.push(entry("a"));
900            m.push(entry("b"));
901            m.mark_all_read();
902            m.flush_now().unwrap();
903        }
904
905        let reopened = NotificationArchiveModel::open(&archive, &paths, Duration::ZERO).unwrap();
906        assert_eq!(
907            reopened.unread_count().get(),
908            0,
909            "read state must have been persisted, not just live-mutated"
910        );
911        assert!(reopened.entries().with_item(0, |e| e.read).unwrap());
912        assert!(reopened.entries().with_item(1, |e| e.read).unwrap());
913    }
914
915    #[test]
916    fn remove_by_id_persists_across_reopen() {
917        use tempfile::tempdir;
918        let dir = tempdir().unwrap();
919        let paths = AppPaths::for_testing(dir.path());
920        let archive = NotificationArchive::persistent("remove_test");
921
922        let removed_title;
923        {
924            let m = NotificationArchiveModel::open(&archive, &paths, Duration::ZERO).unwrap();
925            m.push(entry("a"));
926            m.push(entry("b"));
927            let b_id = m.entries().with_item(0, |e| e.id).unwrap();
928            removed_title = m.entries().with_item(0, |e| e.title.clone()).unwrap();
929            m.remove_by_id(b_id);
930            m.flush_now().unwrap();
931            assert_eq!(m.entries().len(), 1);
932        }
933
934        let reopened = NotificationArchiveModel::open(&archive, &paths, Duration::ZERO).unwrap();
935        assert_eq!(
936            reopened.entries().len(),
937            1,
938            "the removal must have reached disk, not just the live model"
939        );
940        assert_eq!(
941            reopened.entries().with_item(0, |e| e.title.clone()),
942            Some("a".to_string())
943        );
944        assert_ne!(
945            reopened.entries().with_item(0, |e| e.title.clone()),
946            Some(removed_title)
947        );
948    }
949
950    #[test]
951    fn dedup_merge_update_in_place_persists_across_reopen() {
952        use tempfile::tempdir;
953        let dir = tempdir().unwrap();
954        let paths = AppPaths::for_testing(dir.path());
955        let archive = NotificationArchive::persistent("dedup_test");
956
957        {
958            let m = NotificationArchiveModel::open(&archive, &paths, Duration::ZERO).unwrap();
959            let mut first = entry("Uploading 1 of 7");
960            first.dedup_id = Some("upload".to_string());
961            m.push(first);
962            let mut second = entry("Uploading 4 of 7");
963            second.dedup_id = Some("upload".to_string());
964            m.push(second);
965            m.flush_now().unwrap();
966            assert_eq!(m.entries().len(), 1, "merged into one row");
967        }
968
969        let reopened = NotificationArchiveModel::open(&archive, &paths, Duration::ZERO).unwrap();
970        assert_eq!(reopened.entries().len(), 1, "still one row after reopen");
971        let merged = reopened.entries().with_item(0, |e| e.clone()).unwrap();
972        assert_eq!(
973            merged.title, "Uploading 4 of 7",
974            "the in-place update's title must have persisted, not the original"
975        );
976        assert_eq!(
977            merged.updates.len(),
978            1,
979            "the appended NotificationUpdate must have persisted"
980        );
981    }
982
983    #[test]
984    fn clear_persists_across_reopen() {
985        use tempfile::tempdir;
986        let dir = tempdir().unwrap();
987        let paths = AppPaths::for_testing(dir.path());
988        let archive = NotificationArchive::persistent("clear_test");
989
990        {
991            let m = NotificationArchiveModel::open(&archive, &paths, Duration::ZERO).unwrap();
992            m.push(entry("a"));
993            m.push(entry("b"));
994            m.clear();
995            m.flush_now().unwrap();
996            assert_eq!(m.entries().len(), 0);
997        }
998
999        let reopened = NotificationArchiveModel::open(&archive, &paths, Duration::ZERO).unwrap();
1000        assert_eq!(
1001            reopened.entries().len(),
1002            0,
1003            "the clear must have reached disk, not just the live model"
1004        );
1005    }
1006
1007    #[test]
1008    fn bounded_eviction_persists_across_reopen() {
1009        use tempfile::tempdir;
1010        let dir = tempdir().unwrap();
1011        let paths = AppPaths::for_testing(dir.path());
1012        let archive = NotificationArchive::persistent_with_limit("eviction_test", 2);
1013
1014        {
1015            let m = NotificationArchiveModel::open(&archive, &paths, Duration::ZERO).unwrap();
1016            m.push(entry("t0"));
1017            m.push(entry("t1"));
1018            m.push(entry("t2")); // evicts t0
1019            m.flush_now().unwrap();
1020            assert_eq!(m.entries().len(), 2);
1021        }
1022
1023        let reopened = NotificationArchiveModel::open(&archive, &paths, Duration::ZERO).unwrap();
1024        assert_eq!(
1025            reopened.entries().len(),
1026            2,
1027            "the eviction must have reached disk, not just the live model"
1028        );
1029        let titles: Vec<String> = (0..reopened.entries().len())
1030            .map(|i| {
1031                reopened
1032                    .entries()
1033                    .with_item(i, |e| e.title.clone())
1034                    .unwrap()
1035            })
1036            .collect();
1037        assert!(
1038            !titles.contains(&"t0".to_string()),
1039            "t0 was evicted: {titles:?}"
1040        );
1041        assert!(titles.contains(&"t1".to_string()));
1042        assert!(titles.contains(&"t2".to_string()));
1043    }
1044
1045    #[test]
1046    fn version_signal_bumps_on_push_mark_clear_remove() {
1047        let m = NotificationArchiveModel::in_memory();
1048        let v0 = m.version_signal().get();
1049        m.push(entry("a"));
1050        let v1 = m.version_signal().get();
1051        assert_ne!(v0, v1, "push bumps version");
1052
1053        m.push(entry("b"));
1054        m.mark_all_read();
1055        let v2 = m.version_signal().get();
1056        assert_ne!(v1, v2, "mark_all_read bumps version");
1057
1058        let id0 = m.entries().with_item(0, |e| e.id).unwrap();
1059        m.remove_by_id(id0);
1060        let v3 = m.version_signal().get();
1061        assert_ne!(v2, v3, "remove bumps version");
1062
1063        m.clear();
1064        let v4 = m.version_signal().get();
1065        assert_ne!(v3, v4, "clear bumps version");
1066    }
1067
1068    #[test]
1069    fn version_signal_does_not_bump_for_noops() {
1070        let m = NotificationArchiveModel::in_memory();
1071        m.push(entry("a"));
1072        let v_before = m.version_signal().get();
1073        // mark_all_read on a fully-read archive — no mutation, no bump.
1074        m.mark_all_read();
1075        let v_after_mark1 = m.version_signal().get();
1076        m.mark_all_read();
1077        let v_after_mark2 = m.version_signal().get();
1078        assert_eq!(
1079            v_after_mark1, v_after_mark2,
1080            "second mark_all_read with nothing to flip is a no-op (no version bump)"
1081        );
1082
1083        // clear on already-empty archive — no bump.
1084        m.clear();
1085        let v_after_clear1 = m.version_signal().get();
1086        m.clear();
1087        let v_after_clear2 = m.version_signal().get();
1088        assert_eq!(v_after_clear1, v_after_clear2, "clear on empty is a no-op");
1089        let _ = v_before;
1090    }
1091
1092    #[test]
1093    fn mark_read_where_only_flips_matching_unread_entries() {
1094        use crate::toast::ToastAudience;
1095        let m = NotificationArchiveModel::in_memory();
1096        let mut a = entry("audience a");
1097        a.route = ToastRoute::Audience(ToastAudience::new(1));
1098        m.push(a);
1099        let mut b = entry("audience b");
1100        b.route = ToastRoute::Audience(ToastAudience::new(2));
1101        m.push(b);
1102        assert_eq!(m.unread_count().get(), 2);
1103
1104        // Scoped mark-read for audience 1 only.
1105        m.mark_read_where(|e| e.route == ToastRoute::Audience(ToastAudience::new(1)));
1106        assert_eq!(
1107            m.unread_count().get(),
1108            1,
1109            "only audience 1's entry was marked read"
1110        );
1111        let a_read = m
1112            .entries()
1113            .with_item(1, |e| e.read)
1114            .expect("audience a is the oldest, at index 1");
1115        let b_read = m
1116            .entries()
1117            .with_item(0, |e| e.read)
1118            .expect("audience b is newest, at index 0");
1119        assert!(a_read, "audience a's entry is now read");
1120        assert!(!b_read, "audience b's entry is untouched");
1121    }
1122
1123    #[test]
1124    fn clear_where_only_removes_matching_entries() {
1125        use crate::toast::ToastAudience;
1126        let m = NotificationArchiveModel::in_memory();
1127        let mut a = entry("audience a");
1128        a.route = ToastRoute::Audience(ToastAudience::new(1));
1129        m.push(a);
1130        let mut b = entry("audience b");
1131        b.route = ToastRoute::Audience(ToastAudience::new(2));
1132        m.push(b);
1133        assert_eq!(m.entries().len(), 2);
1134        assert_eq!(m.unread_count().get(), 2);
1135
1136        m.clear_where(|e| e.route == ToastRoute::Audience(ToastAudience::new(1)));
1137        assert_eq!(m.entries().len(), 1, "only audience 1's entry is removed");
1138        assert_eq!(
1139            m.unread_count().get(),
1140            1,
1141            "unread_count decrements for the removed unread entry"
1142        );
1143        assert_eq!(
1144            m.entries().with_item(0, |e| e.title.clone()),
1145            Some("audience b".to_string()),
1146            "audience b's entry survives"
1147        );
1148    }
1149
1150    #[test]
1151    fn entry_serde_round_trip() {
1152        // NotificationEntry must be round-trippable through TOML
1153        // (PersistedListModel's serialization format).
1154        let original = NotificationEntry {
1155            id: 42,
1156            severity: BannerSeverity::Warning,
1157            priority: ToastPriority::High,
1158            title: "Heads up".into(),
1159            body: Some("Details here".into()),
1160            actions: vec![crate::notification::ArchivedAction {
1161                label: "Open".into(),
1162                intent_name: Some("app.open".into()),
1163                style: ArchivedActionStyle::PrimaryButton,
1164                closes_on_invoke: true,
1165            }],
1166            timestamp: jiff::Timestamp::UNIX_EPOCH,
1167            group: Some("build".into()),
1168            source: Some("build.success".into()),
1169            read: false,
1170            dedup_id: Some("build-1".into()),
1171            updates: vec![],
1172            route: ToastRoute::Audience(crate::toast::ToastAudience::new(7)),
1173        };
1174        // Wrap in a Vec because TOML doesn't allow a top-level
1175        // non-table value, and our ListFile is `{ version, items }`.
1176        let wrapper = teksilo_settings::ListFile {
1177            version: 1,
1178            items: vec![original.clone()],
1179        };
1180        let serialized = toml::to_string(&wrapper).expect("serialize");
1181        let parsed: teksilo_settings::ListFile<NotificationEntry> =
1182            toml::from_str(&serialized).expect("deserialize");
1183        assert_eq!(parsed.items.len(), 1);
1184        assert_eq!(parsed.items[0], original);
1185    }
1186
1187    // ----- multi-window rebuild notification -----
1188
1189    /// What the deleted `window_versions` map used to buy, now a
1190    /// property of the shared signal itself: N windows' bells / logs
1191    /// each bind the SAME `version_signal` at `Rebuild`, and every one
1192    /// of them registers the mutation.
1193    ///
1194    /// Three independent `BindingRegistry`s stand in for three windows,
1195    /// bound the way `NotificationCenterButton::build` and
1196    /// `NotificationLog::build` bind. The reconcile-order half of the
1197    /// property — that one window's flush does not consume another's —
1198    /// needs real trees, and is covered by
1199    /// `center_button::tests::two_windows_bells_both_rebuild_on_one_archive_push`.
1200    #[test]
1201    fn every_windows_binding_sees_an_archive_mutation() {
1202        use teksilo_core::binding::{BindingLevel, BindingRegistry};
1203        use teksilo_core::widget_id::WidgetId;
1204
1205        let m = NotificationArchiveModel::in_memory();
1206        let bell: WidgetId = slotmap::KeyData::from_ffi(1).into();
1207        let windows: Vec<BindingRegistry> = (0..3).map(|_| BindingRegistry::new()).collect();
1208        for reg in &windows {
1209            m.version_signal().bind_to(bell, reg, BindingLevel::Rebuild);
1210        }
1211        for reg in &windows {
1212            assert!(!reg.any_dirty(), "a fresh binding starts clean");
1213        }
1214
1215        m.push(entry("first"));
1216
1217        for (i, reg) in windows.iter().enumerate() {
1218            assert!(
1219                reg.any_dirty(),
1220                "window {i} missed the archive mutation — asking window 0 \
1221                 must not have consumed it"
1222            );
1223        }
1224    }
1225}