1use 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
33pub const DEFAULT_ARCHIVE_LIMIT: usize = 200;
37
38pub const ARCHIVE_FILE_NAME: &str = "notifications";
42
43#[derive(Debug, Clone)]
46pub enum NotificationArchive {
47 InMemory { limit: usize },
51 Persistent { file_name: String, limit: usize },
55}
56
57impl NotificationArchive {
58 pub fn in_memory() -> Self {
60 Self::InMemory {
61 limit: DEFAULT_ARCHIVE_LIMIT,
62 }
63 }
64
65 pub fn in_memory_with_limit(limit: usize) -> Self {
67 Self::InMemory { limit }
68 }
69
70 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#[derive(Debug, thiserror::Error)]
98pub enum NotificationArchiveError {
99 #[error("notification archive file I/O failed: {0}")]
103 File(#[from] SettingsFileError),
104}
105
106enum 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 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 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 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 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
201pub struct NotificationArchiveModel {
208 backend: ArchiveBackend,
209 limit: usize,
210 next_id: Cell<u64>,
215 unread_count: Signal<usize>,
219 version: Signal<u64>,
228}
229
230impl NotificationArchiveModel {
231 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 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 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 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 pub fn entries(&self) -> &ListModel<NotificationEntry> {
289 self.backend.model()
290 }
291
292 pub fn unread_count(&self) -> &Signal<usize> {
294 &self.unread_count
295 }
296
297 pub fn version_signal(&self) -> &Signal<u64> {
305 &self.version
306 }
307
308 pub fn limit(&self) -> usize {
309 self.limit
310 }
311
312 fn bump_version(&self) {
318 let v = self.version.get();
319 self.version.set(v.wrapping_add(1));
320 }
321
322 pub fn flush_now(&self) -> Result<(), SettingsFileError> {
326 self.backend.flush_now()
327 }
328
329 pub fn push(&self, mut entry: NotificationEntry) {
341 self.bump_version();
342 let model = self.backend.model();
343
344 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 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 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 let last = model.len() - 1;
401 if let Some(evicted) = model.with_item(last, |e| e.clone()) {
402 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 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 pub fn mark_all_read(&self) {
459 let model = self.backend.model();
460 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 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 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 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#[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, 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 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 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 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(); 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 let m = NotificationArchiveModel::in_memory();
755 m.push(entry("a")); m.push(entry("b")); 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 let a_id = m
765 .entries()
766 .with_item(1, |e| e.id)
767 .expect("a's id at index 1");
768
769 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 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 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 assert_eq!(m.entries().len(), 2);
840 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 {
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 let m = NotificationArchiveModel::open(&archive, &paths, Duration::ZERO).unwrap();
863 assert_eq!(m.entries().len(), 2);
864 assert_eq!(
866 m.entries().with_item(0, |e| e.title.clone()),
867 Some("second".into())
868 );
869 assert_eq!(m.unread_count().get(), 2);
871 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 #[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")); 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 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 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 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 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 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 #[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}