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
43pub const UPDATE_HISTORY_LIMIT: usize = 20;
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55enum Arrival {
56 Raised,
57 LiveUpdate,
58}
59
60#[derive(Debug, Clone)]
63pub enum NotificationArchive {
64 InMemory { limit: usize },
68 Persistent { file_name: String, limit: usize },
72}
73
74impl NotificationArchive {
75 pub fn in_memory() -> Self {
77 Self::InMemory {
78 limit: DEFAULT_ARCHIVE_LIMIT,
79 }
80 }
81
82 pub fn in_memory_with_limit(limit: usize) -> Self {
84 Self::InMemory { limit }
85 }
86
87 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#[derive(Debug, thiserror::Error)]
115pub enum NotificationArchiveError {
116 #[error("notification archive file I/O failed: {0}")]
120 File(#[from] SettingsFileError),
121}
122
123enum 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 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 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 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 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
224pub struct NotificationArchiveModel {
231 backend: ArchiveBackend,
232 limit: usize,
233 next_id: Cell<u64>,
238 unread_count: Signal<usize>,
242 version: Signal<u64>,
251}
252
253impl NotificationArchiveModel {
254 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 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 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 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 pub fn entries(&self) -> &ListModel<NotificationEntry> {
312 self.backend.model()
313 }
314
315 pub fn unread_count(&self) -> &Signal<usize> {
317 &self.unread_count
318 }
319
320 pub fn version_signal(&self) -> &Signal<u64> {
328 &self.version
329 }
330
331 pub fn limit(&self) -> usize {
332 self.limit
333 }
334
335 fn bump_version(&self) {
341 let v = self.version.get();
342 self.version.set(v.wrapping_add(1));
343 }
344
345 pub fn flush_now(&self) -> Result<(), SettingsFileError> {
349 self.backend.flush_now()
350 }
351
352 pub fn push(&self, entry: NotificationEntry) {
369 self.arrive(entry, Arrival::Raised);
370 }
371
372 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 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 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 let last = model.len() - 1;
437 if let Some(evicted) = model.with_item(last, |e| e.clone()) {
438 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 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 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 pub fn mark_all_read(&self) {
557 let model = self.backend.model();
558 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 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 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 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#[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, 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 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 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 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(); 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 let m = NotificationArchiveModel::in_memory();
853 m.push(entry("a")); m.push(entry("b")); 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 let a_id = m
863 .entries()
864 .with_item(1, |e| e.id)
865 .expect("a's id at index 1");
866
867 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 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 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 assert_eq!(m.entries().len(), 2);
938 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 #[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 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 #[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 #[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 #[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 m.push_update(notice("Imported", BannerSeverity::Success, Vec::new()));
1078 assert_eq!(m.unread_count().get(), 1);
1079 }
1080
1081 #[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 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 #[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 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 #[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 #[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 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 #[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 #[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 {
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 let m = NotificationArchiveModel::open(&archive, &paths, Duration::ZERO).unwrap();
1251 assert_eq!(m.entries().len(), 2);
1252 assert_eq!(
1254 m.entries().with_item(0, |e| e.title.clone()),
1255 Some("second".into())
1256 );
1257 assert_eq!(m.unread_count().get(), 2);
1259 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 #[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 #[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")); 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 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 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 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 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 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 #[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}