Skip to main content

brows3r_lib/notifications/
os.rs

1//! OS notification bridge.
2//!
3//! `OsNotifier` wraps a `tauri::AppHandle` (or any `OsNotifyChannel` impl in
4//! tests) and gates OS-level notifications behind the user's
5//! `Settings::notifications.os_enabled` preference.
6//!
7//! # Gating rules
8//!
9//! An OS notification fires **only** when all three conditions hold:
10//!   1. `Settings::notifications.os_enabled == true`
11//!   2. `notification.severity` is `Success`, `Warning`, or `Error`
12//!      (i.e. not `Info`).
13//!   3. `terminal == true` — the caller signals that the transfer/operation
14//!      has reached a final state (`done` or `failed`).
15//!
16//! When any gate fails the method returns `Ok(())` (silent skip).
17//! When the underlying plugin call fails the method returns
18//! `Err(AppError::Network { .. })`.
19//!
20//! # OCP contract
21//!
22//! The `OsNotifyChannel` trait is the extension point: adding a new channel
23//! (e.g. Slack webhook, webhook-as-plugin) means implementing the one-method
24//! trait.  `OsNotifier` never hard-codes the plugin type — it accepts any
25//! channel implementation through the trait.
26
27use crate::{error::AppError, notifications::Severity, settings::SettingsHandle};
28
29// ---------------------------------------------------------------------------
30// OsNotifyChannel trait
31// ---------------------------------------------------------------------------
32
33/// Abstraction over any channel that can send an OS-style notification.
34///
35/// `AppHandleChannel` wraps `tauri::AppHandle` for production.
36/// `MockOsChannel` is provided for tests.
37pub trait OsNotifyChannel {
38    fn send(&self, title: &str, body: &str) -> Result<(), AppError>;
39}
40
41// ---------------------------------------------------------------------------
42// AppHandleChannel — real Tauri plugin bridge
43// ---------------------------------------------------------------------------
44
45/// Production `OsNotifyChannel` backed by `tauri-plugin-notification`.
46pub struct AppHandleChannel {
47    pub app: tauri::AppHandle,
48}
49
50impl OsNotifyChannel for AppHandleChannel {
51    fn send(&self, title: &str, body: &str) -> Result<(), AppError> {
52        use tauri_plugin_notification::NotificationExt;
53        self.app
54            .notification()
55            .builder()
56            .title(title)
57            .body(body)
58            .show()
59            .map_err(|e| AppError::Network {
60                source: format!("os notification plugin error: {e}"),
61            })
62    }
63}
64
65// ---------------------------------------------------------------------------
66// OsNotifier
67// ---------------------------------------------------------------------------
68
69/// Sends OS notifications when gating rules pass.
70pub struct OsNotifier<C: OsNotifyChannel> {
71    channel: C,
72    settings: SettingsHandle,
73}
74
75impl<C: OsNotifyChannel> OsNotifier<C> {
76    pub fn new(channel: C, settings: SettingsHandle) -> Self {
77        Self { channel, settings }
78    }
79}
80
81impl OsNotifier<NoopOsChannel> {
82    /// Create a no-op `OsNotifier` suitable for integration tests and CLI
83    /// contexts where no `AppHandle` is available.
84    ///
85    /// OS notifications are silently discarded.  The gating logic still runs
86    /// (it reads settings), but with a default `SettingsHandle` that has
87    /// `notifications.os_enabled = false`, so the channel is never called.
88    pub fn noop() -> Self {
89        use crate::settings::SettingsHandle;
90        use std::path::PathBuf;
91        let settings = SettingsHandle::new(crate::settings::Settings::default(), PathBuf::new());
92        Self {
93            channel: NoopOsChannel,
94            settings,
95        }
96    }
97}
98
99impl<C: OsNotifyChannel> OsNotifier<C> {
100    /// Conditionally send an OS notification.
101    ///
102    /// Returns `Ok(())` when any gate fails (silent skip).
103    /// Returns `Err(AppError::Network { .. })` only if the plugin call itself
104    /// errors.
105    pub async fn maybe_send(
106        &self,
107        notification: &crate::notifications::Notification,
108        terminal: bool,
109    ) -> Result<(), AppError> {
110        // Gate 1: OS notifications must be enabled in settings.
111        let os_enabled = {
112            let settings = self.settings.inner.lock().await;
113            settings.notifications.os_enabled
114        };
115        if !os_enabled {
116            return Ok(());
117        }
118
119        // Gate 2: severity must be non-Info.
120        if notification.severity == Severity::Info {
121            return Ok(());
122        }
123
124        // Gate 3: operation must be terminal.
125        if !terminal {
126            return Ok(());
127        }
128
129        self.channel
130            .send(&notification.title, &notification.message)
131    }
132}
133
134// ---------------------------------------------------------------------------
135// OsNotifier test helpers
136// ---------------------------------------------------------------------------
137
138#[cfg(test)]
139impl<C: OsNotifyChannel> OsNotifier<C> {
140    /// Number of OS notification calls sent through the channel.
141    ///
142    /// Only available in tests.  The `channel` field must have a `call_count`
143    /// method (i.e. this is only useful with `MockOsChannel`).
144    pub fn inner_channel(&self) -> &C {
145        &self.channel
146    }
147}
148
149// ---------------------------------------------------------------------------
150// MockOsChannel — for tests only
151// ---------------------------------------------------------------------------
152
153#[cfg(test)]
154pub struct MockOsChannel {
155    pub calls: std::sync::Mutex<Vec<(String, String)>>,
156    /// When `true` the `send` call returns an error (simulates plugin failure).
157    pub should_fail: bool,
158}
159
160#[cfg(test)]
161impl Default for MockOsChannel {
162    fn default() -> Self {
163        Self::new()
164    }
165}
166
167#[cfg(test)]
168impl MockOsChannel {
169    pub fn new() -> Self {
170        Self {
171            calls: std::sync::Mutex::new(Vec::new()),
172            should_fail: false,
173        }
174    }
175
176    pub fn failing() -> Self {
177        Self {
178            calls: std::sync::Mutex::new(Vec::new()),
179            should_fail: true,
180        }
181    }
182
183    pub fn call_count(&self) -> usize {
184        self.calls.lock().expect("lock poisoned").len()
185    }
186}
187
188#[cfg(test)]
189impl OsNotifyChannel for MockOsChannel {
190    fn send(&self, title: &str, body: &str) -> Result<(), AppError> {
191        if self.should_fail {
192            return Err(AppError::Network {
193                source: "mock plugin failure".to_string(),
194            });
195        }
196        self.calls
197            .lock()
198            .expect("lock poisoned")
199            .push((title.to_string(), body.to_string()));
200        Ok(())
201    }
202}
203
204// ---------------------------------------------------------------------------
205// NoopOsChannel — always-Ok no-op, usable in integration tests
206// ---------------------------------------------------------------------------
207
208/// An `OsNotifyChannel` that silently discards all notifications.
209///
210/// Intended for use in integration tests and CLI contexts where no Tauri
211/// `AppHandle` is available.  All `send` calls return `Ok(())`.
212pub struct NoopOsChannel;
213
214impl OsNotifyChannel for NoopOsChannel {
215    fn send(&self, _title: &str, _body: &str) -> Result<(), crate::error::AppError> {
216        Ok(())
217    }
218}
219
220// ---------------------------------------------------------------------------
221// Tests
222// ---------------------------------------------------------------------------
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use crate::{
228        notifications::{Notification, NotificationCategory, Severity},
229        settings::{NotificationSettings, Settings, SettingsHandle},
230    };
231    use std::path::PathBuf;
232    use tokio::sync::Mutex;
233
234    fn make_settings(os_enabled: bool) -> SettingsHandle {
235        let settings = Settings {
236            notifications: NotificationSettings {
237                in_app: true,
238                os_enabled,
239                sound: false,
240            },
241            ..Settings::default()
242        };
243        SettingsHandle {
244            inner: std::sync::Arc::new(Mutex::new(settings)),
245            path: PathBuf::from("/dev/null"),
246        }
247    }
248
249    fn make_notification(severity: Severity) -> Notification {
250        Notification {
251            id: "test-id".to_string(),
252            severity,
253            category: NotificationCategory::Background,
254            title: "Upload complete".to_string(),
255            message: "my-file.txt uploaded".to_string(),
256            resource: Some("s3://bucket/my-file.txt".to_string()),
257            operation: Some("upload".to_string()),
258            timestamp: 1_000_000,
259            details: None,
260        }
261    }
262
263    // --- Gate 1: os_enabled=false → no call ---
264
265    #[tokio::test]
266    async fn no_call_when_os_disabled() {
267        let channel = MockOsChannel::new();
268        let notifier = OsNotifier::new(channel, make_settings(false));
269        let notif = make_notification(Severity::Success);
270        notifier
271            .maybe_send(&notif, true)
272            .await
273            .expect("should be ok");
274        assert_eq!(
275            notifier.channel.call_count(),
276            0,
277            "no OS notification when os_enabled=false"
278        );
279    }
280
281    // --- Gate 2: severity=Info → no call ---
282
283    #[tokio::test]
284    async fn no_call_for_info_severity() {
285        let channel = MockOsChannel::new();
286        let notifier = OsNotifier::new(channel, make_settings(true));
287        let notif = make_notification(Severity::Info);
288        notifier
289            .maybe_send(&notif, true)
290            .await
291            .expect("should be ok");
292        assert_eq!(
293            notifier.channel.call_count(),
294            0,
295            "no OS notification for Info severity"
296        );
297    }
298
299    // --- Gate 3: terminal=false → no call ---
300
301    #[tokio::test]
302    async fn no_call_when_not_terminal() {
303        let channel = MockOsChannel::new();
304        let notifier = OsNotifier::new(channel, make_settings(true));
305        let notif = make_notification(Severity::Success);
306        notifier
307            .maybe_send(&notif, false)
308            .await
309            .expect("should be ok");
310        assert_eq!(
311            notifier.channel.call_count(),
312            0,
313            "no OS notification when terminal=false"
314        );
315    }
316
317    // --- All gates pass: os_enabled=true + non-Info + terminal → call fires ---
318
319    #[tokio::test]
320    async fn sends_when_all_gates_pass_success() {
321        let channel = MockOsChannel::new();
322        let notifier = OsNotifier::new(channel, make_settings(true));
323        let notif = make_notification(Severity::Success);
324        notifier
325            .maybe_send(&notif, true)
326            .await
327            .expect("should be ok");
328        assert_eq!(
329            notifier.channel.call_count(),
330            1,
331            "OS notification should fire for Success+terminal+enabled"
332        );
333    }
334
335    #[tokio::test]
336    async fn sends_for_warning_severity() {
337        let channel = MockOsChannel::new();
338        let notifier = OsNotifier::new(channel, make_settings(true));
339        let notif = make_notification(Severity::Warning);
340        notifier
341            .maybe_send(&notif, true)
342            .await
343            .expect("should be ok");
344        assert_eq!(notifier.channel.call_count(), 1);
345    }
346
347    #[tokio::test]
348    async fn sends_for_error_severity() {
349        let channel = MockOsChannel::new();
350        let notifier = OsNotifier::new(channel, make_settings(true));
351        let notif = make_notification(Severity::Error);
352        notifier
353            .maybe_send(&notif, true)
354            .await
355            .expect("should be ok");
356        assert_eq!(notifier.channel.call_count(), 1);
357    }
358
359    // --- Plugin failure → Err returned ---
360
361    #[tokio::test]
362    async fn plugin_failure_returns_err() {
363        let channel = MockOsChannel::failing();
364        let notifier = OsNotifier::new(channel, make_settings(true));
365        let notif = make_notification(Severity::Error);
366        let result = notifier.maybe_send(&notif, true).await;
367        assert!(
368            matches!(result, Err(AppError::Network { .. })),
369            "plugin failure should propagate as AppError::Network"
370        );
371    }
372}