Skip to main content

brows3r_lib/notifications/
mod.rs

1//! In-app notification log.
2//!
3//! # Structs
4//!
5//! - [`Notification`]       — immutable notification value.
6//! - [`NotificationLog`]    — in-memory ring buffer (default capacity 500).
7//! - [`NotificationLogHandle`] — `Arc<RwLock<NotificationLog>>` Tauri managed state.
8//!
9//! # OCP contract
10//!
11//! Capacity is settable via [`NotificationLog::with_capacity`].
12//! `NotificationLog::push_with_broadcast` accepts any `EventEmitter` impl, so
13//! tests can pass a `MockChannel` and production code passes the `AppHandle`.
14//! Adding a new `Severity` or `NotificationCategory` variant requires only a
15//! new enum arm — no other match arms change.
16
17use std::sync::Arc;
18
19use serde::{Deserialize, Serialize};
20use tokio::sync::RwLock;
21
22use crate::{
23    error::AppError,
24    events::{emit, EventEmitter, EventKind},
25};
26
27// ---------------------------------------------------------------------------
28// Severity
29// ---------------------------------------------------------------------------
30
31/// Severity level of a notification.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "camelCase")]
34pub enum Severity {
35    Info,
36    Warning,
37    Error,
38    Success,
39}
40
41// ---------------------------------------------------------------------------
42// NotificationCategory
43// ---------------------------------------------------------------------------
44
45/// Classification that drives frontend placement policy (panel-only vs
46/// panel+toast vs panel+inline — consumed in task 22).
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "camelCase")]
49pub enum NotificationCategory {
50    /// Notification originated from a direct user action.
51    UserInitiated,
52    /// Notification originated from a background operation.
53    Background,
54}
55
56// ---------------------------------------------------------------------------
57// Notification
58// ---------------------------------------------------------------------------
59
60/// A single immutable notification entry stored in the log.
61///
62/// All fields are `Clone` so callers can copy the value for broadcast payloads
63/// without needing a reference into the log.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65#[serde(rename_all = "camelCase")]
66pub struct Notification {
67    /// UUID v4 string that uniquely identifies this notification.
68    pub id: String,
69    pub severity: Severity,
70    pub category: NotificationCategory,
71    pub title: String,
72    pub message: String,
73    /// S3 resource URI (`s3://bucket/key`) or other resource identifier.
74    pub resource: Option<String>,
75    /// Name of the operation that produced this notification (e.g. `"upload"`).
76    pub operation: Option<String>,
77    /// Unix timestamp in milliseconds.
78    pub timestamp: i64,
79    /// Copyable structured details (free-form JSON).
80    pub details: Option<serde_json::Value>,
81}
82
83// ---------------------------------------------------------------------------
84// NotificationLog
85// ---------------------------------------------------------------------------
86
87/// In-memory ring buffer for in-app notifications.
88///
89/// Entries are stored in insertion order.  When the buffer is full the oldest
90/// entry is evicted before the new one is appended (ring semantics).
91///
92/// All public methods are `&mut self`; callers must hold the `RwLock` write
93/// guard from `NotificationLogHandle` when mutating.
94pub struct NotificationLog {
95    capacity: usize,
96    /// Entries stored in insertion order (oldest first).
97    entries: std::collections::VecDeque<Notification>,
98}
99
100impl NotificationLog {
101    /// Create a log with the default capacity of 500 entries.
102    pub fn new() -> Self {
103        Self::with_capacity(500)
104    }
105
106    /// Create a log with a custom capacity.  A capacity of 0 is clamped to 1.
107    pub fn with_capacity(capacity: usize) -> Self {
108        let cap = capacity.max(1);
109        Self {
110            capacity: cap,
111            entries: std::collections::VecDeque::with_capacity(cap),
112        }
113    }
114
115    /// Append a notification.  If the buffer is at capacity the oldest entry
116    /// is dropped first.
117    pub fn push(&mut self, notification: Notification) {
118        if self.entries.len() == self.capacity {
119            self.entries.pop_front();
120        }
121        self.entries.push_back(notification);
122    }
123
124    /// Append a notification **and** emit a `NotificationNew` event via
125    /// `channel`.  Errors from the emit are returned but the notification is
126    /// always stored (emit failure is non-fatal in the log itself).
127    pub fn push_with_broadcast<E: EventEmitter>(
128        &mut self,
129        notification: Notification,
130        channel: &E,
131    ) -> Result<(), AppError> {
132        let notif_clone = notification.clone();
133        self.push(notification);
134        emit(channel, EventKind::NotificationNew, &notif_clone)
135    }
136
137    /// Return all notifications with `timestamp >= since_ms`, or all entries
138    /// when `since_ms` is `None`.
139    pub fn list(&self, since: Option<i64>) -> Vec<Notification> {
140        match since {
141            None => self.entries.iter().cloned().collect(),
142            Some(since_ms) => self
143                .entries
144                .iter()
145                .filter(|n| n.timestamp >= since_ms)
146                .cloned()
147                .collect(),
148        }
149    }
150
151    /// Dismiss a notification by id.  Returns `true` when found and removed,
152    /// `false` otherwise.
153    pub fn dismiss(&mut self, id: &str) -> bool {
154        if let Some(pos) = self.entries.iter().position(|n| n.id == id) {
155            self.entries.remove(pos);
156            true
157        } else {
158            false
159        }
160    }
161
162    /// Remove all notifications from the log.
163    pub fn clear_all(&mut self) {
164        self.entries.clear();
165    }
166}
167
168impl Default for NotificationLog {
169    fn default() -> Self {
170        Self::new()
171    }
172}
173
174// ---------------------------------------------------------------------------
175// NotificationLogHandle
176// ---------------------------------------------------------------------------
177
178/// Newtype around `Arc<RwLock<NotificationLog>>` used as Tauri managed state.
179///
180/// Commands receive `tauri::State<NotificationLogHandle>` and acquire the
181/// inner lock for the duration of the read or write.
182#[derive(Clone)]
183pub struct NotificationLogHandle(pub Arc<RwLock<NotificationLog>>);
184
185impl NotificationLogHandle {
186    pub fn new(log: NotificationLog) -> Self {
187        Self(Arc::new(RwLock::new(log)))
188    }
189}
190
191impl Default for NotificationLogHandle {
192    fn default() -> Self {
193        Self::new(NotificationLog::new())
194    }
195}
196
197// ---------------------------------------------------------------------------
198// pub re-export for os module
199// ---------------------------------------------------------------------------
200
201pub mod os;
202
203// ---------------------------------------------------------------------------
204// Tests
205// ---------------------------------------------------------------------------
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use crate::events::MockChannel;
211
212    fn make_notif(id: &str, ts: i64) -> Notification {
213        Notification {
214            id: id.to_string(),
215            severity: Severity::Info,
216            category: NotificationCategory::Background,
217            title: "Test".to_string(),
218            message: "msg".to_string(),
219            resource: None,
220            operation: None,
221            timestamp: ts,
222            details: None,
223        }
224    }
225
226    // --- ring buffer tests ---
227
228    #[test]
229    fn push_501_evicts_first() {
230        let mut log = NotificationLog::with_capacity(500);
231        for i in 0..501u32 {
232            log.push(make_notif(&i.to_string(), i as i64));
233        }
234        let entries = log.list(None);
235        assert_eq!(entries.len(), 500, "should hold exactly 500 entries");
236        // First entry should be id "1" (id "0" was evicted)
237        assert_eq!(
238            entries[0].id, "1",
239            "oldest entry after eviction should be id=1"
240        );
241        assert_eq!(entries[499].id, "500", "newest entry should be id=500");
242    }
243
244    #[test]
245    fn push_then_dismiss_omits_from_list() {
246        let mut log = NotificationLog::new();
247        log.push(make_notif("a", 1000));
248        log.push(make_notif("b", 2000));
249        let removed = log.dismiss("a");
250        assert!(removed, "dismiss should return true");
251        let entries = log.list(None);
252        assert_eq!(entries.len(), 1);
253        assert_eq!(entries[0].id, "b");
254    }
255
256    #[test]
257    fn dismiss_missing_returns_false() {
258        let mut log = NotificationLog::new();
259        assert!(!log.dismiss("no-such-id"));
260    }
261
262    #[test]
263    fn list_with_since_filter() {
264        let mut log = NotificationLog::new();
265        log.push(make_notif("old", 1000));
266        log.push(make_notif("mid", 5000));
267        log.push(make_notif("new", 9000));
268
269        let after = log.list(Some(5000));
270        assert_eq!(after.len(), 2, "should include mid and new (>=5000)");
271        assert_eq!(after[0].id, "mid");
272        assert_eq!(after[1].id, "new");
273    }
274
275    #[test]
276    fn clear_all_empties_log() {
277        let mut log = NotificationLog::new();
278        log.push(make_notif("x", 0));
279        log.clear_all();
280        assert!(log.list(None).is_empty());
281    }
282
283    // --- push_with_broadcast ---
284
285    #[test]
286    fn push_with_broadcast_emits_notification_new() {
287        let mut log = NotificationLog::new();
288        let channel = MockChannel::default();
289        let notif = make_notif("broadcast-id", 12345);
290
291        log.push_with_broadcast(notif, &channel)
292            .expect("broadcast should succeed");
293
294        let emitted = channel.emitted();
295        assert_eq!(emitted.len(), 1);
296        assert_eq!(emitted[0].0, EventKind::NotificationNew);
297        let payload: Notification =
298            serde_json::from_value(emitted[0].1.clone()).expect("payload should be Notification");
299        assert_eq!(payload.id, "broadcast-id");
300    }
301}