Skip to main content

brows3r_lib/transfers/
notify.rs

1//! Terminal-state notification helper for transfers.
2//!
3//! # Single notification surface
4//!
5//! `notify_terminal` is the **only** place in the codebase that pushes an
6//! in-app notification and conditionally fires an OS notification for a
7//! transfer that has reached a terminal state.
8//!
9//! Adding new notification rules (e.g. "errors only", "grouped by bucket") is
10//! one branch here — no other call site changes.
11//!
12//! # Gating (round-1 finding #4)
13//!
14//! OS notifications fire only when:
15//!   1. `settings.notifications.os_enabled == true`
16//!   2. `transfer.state` is `Done` or `Failed` (not `Canceled` — user-initiated
17//!      cancellations are silent at the OS level).
18//!   3. The `OsNotifier::maybe_send` internal gates also pass (non-Info severity
19//!      + `terminal = true`).
20//!
21//! # OCP
22//!
23//! - `notify_terminal` is generic over any `EventEmitter` and any
24//!   `OsNotifyChannel`, so it works identically in tests and production.
25
26use std::time::{SystemTime, UNIX_EPOCH};
27
28use crate::{
29    error::AppError,
30    events::EventEmitter,
31    ids::BucketId,
32    notifications::os::{OsNotifier, OsNotifyChannel},
33    notifications::{Notification, NotificationCategory, NotificationLogHandle, Severity},
34    transfers::{Transfer, TransferState},
35};
36
37/// Push an in-app notification for a terminal-state transfer, and optionally
38/// fire an OS notification based on the current settings.
39///
40/// # Parameters
41///
42/// - `transfer`    — The transfer that reached a terminal state.
43/// - `channel`     — Event emitter used to broadcast `notification:new`.
44/// - `log`         — In-app notification log (shared Tauri state).
45/// - `os_notifier` — OS notification bridge (settings-gated).
46///
47/// # Return
48///
49/// Returns `Ok(())` on success.  Errors from the in-app log broadcast are
50/// returned; OS notification errors are silently logged (non-fatal for the
51/// transfer outcome).
52pub async fn notify_terminal<E, C>(
53    transfer: &Transfer,
54    channel: &E,
55    log: &NotificationLogHandle,
56    os_notifier: &OsNotifier<C>,
57) -> Result<(), AppError>
58where
59    E: EventEmitter,
60    C: OsNotifyChannel,
61{
62    let (severity, title, message) = notification_content(transfer);
63
64    let now_ms = SystemTime::now()
65        .duration_since(UNIX_EPOCH)
66        .map(|d| d.as_millis() as i64)
67        .unwrap_or(0);
68
69    let resource = build_resource_uri(&transfer.bucket, &transfer.key);
70
71    let notification = Notification {
72        id: uuid::Uuid::new_v4().to_string(),
73        severity: severity.clone(),
74        category: NotificationCategory::Background,
75        title: title.clone(),
76        message: message.clone(),
77        resource: Some(resource),
78        operation: Some(transfer_operation(transfer)),
79        timestamp: now_ms,
80        details: None,
81    };
82
83    // Push to in-app log + broadcast `notification:new`.
84    {
85        let mut log_guard = log.0.write().await;
86        log_guard.push_with_broadcast(notification.clone(), channel)?;
87    }
88
89    // Conditionally fire OS notification.
90    // Only for Done/Failed — Canceled is user-initiated so we stay silent.
91    let is_terminal_for_os =
92        transfer.state == TransferState::Done || transfer.state == TransferState::Failed;
93
94    if is_terminal_for_os {
95        // Non-fatal: OS notification errors do not fail the transfer.
96        let _ = os_notifier.maybe_send(&notification, true).await;
97    }
98
99    Ok(())
100}
101
102// ---------------------------------------------------------------------------
103// Helpers
104// ---------------------------------------------------------------------------
105
106fn notification_content(transfer: &Transfer) -> (Severity, String, String) {
107    match &transfer.state {
108        TransferState::Done => {
109            let op = if transfer.source_path.is_some() {
110                "Upload"
111            } else {
112                "Download"
113            };
114            let title = format!("{op} complete");
115            let message = format!("{} → complete", transfer.key);
116            (Severity::Success, title, message)
117        }
118        TransferState::Failed => {
119            let op = if transfer.source_path.is_some() {
120                "Upload"
121            } else {
122                "Download"
123            };
124            let title = format!("{op} failed");
125            let message = transfer
126                .error
127                .as_ref()
128                .map(|e| e.message())
129                .unwrap_or_else(|| format!("{} failed", transfer.key));
130            (Severity::Error, title, message)
131        }
132        TransferState::Canceled => {
133            let op = if transfer.source_path.is_some() {
134                "Upload"
135            } else {
136                "Download"
137            };
138            let title = format!("{op} cancelled");
139            let message = format!("{} cancelled by user", transfer.key);
140            (Severity::Info, title, message)
141        }
142        // Non-terminal states should not be passed to notify_terminal, but
143        // we return a generic entry rather than panicking.
144        _ => {
145            let title = "Transfer update".to_string();
146            let message = format!("{} — state changed", transfer.key);
147            (Severity::Info, title, message)
148        }
149    }
150}
151
152fn transfer_operation(transfer: &Transfer) -> String {
153    if transfer.source_path.is_some() {
154        "upload".to_string()
155    } else {
156        "download".to_string()
157    }
158}
159
160/// Build the user-facing S3 resource URI shown in notifications.
161///
162/// Profile is intentionally omitted: the s3:// URI is the format users
163/// expect to see and copy into other S3 tooling (aws-cli, etc.). Profile
164/// disambiguation lives in the notification's profile badge instead.
165fn build_resource_uri(bucket: &BucketId, key: &str) -> String {
166    format!("s3://{}/{}", bucket.as_str(), key)
167}
168
169// ---------------------------------------------------------------------------
170// Tests
171// ---------------------------------------------------------------------------
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use crate::{
177        events::MockChannel,
178        ids::{BucketId, ProfileId},
179        notifications::os::MockOsChannel,
180        settings::{NotificationSettings, Settings, SettingsHandle},
181        transfers::{Transfer, TransferKind, TransferState},
182    };
183    use std::{path::PathBuf, sync::Arc};
184    use tokio::sync::Mutex;
185
186    fn make_settings(os_enabled: bool) -> SettingsHandle {
187        let settings = Settings {
188            notifications: NotificationSettings {
189                in_app: true,
190                os_enabled,
191                sound: false,
192            },
193            ..Settings::default()
194        };
195        SettingsHandle {
196            inner: Arc::new(Mutex::new(settings)),
197            path: PathBuf::from("/dev/null"),
198        }
199    }
200
201    fn make_transfer_done() -> Transfer {
202        Transfer {
203            id: "xfer-done".to_string(),
204            kind: TransferKind::Download,
205            profile_id: ProfileId::new("p1"),
206            bucket: BucketId::new("my-bucket"),
207            key: "data/file.txt".to_string(),
208            source_path: None,
209            dest_path: Some(PathBuf::from("/tmp/file.txt")),
210            total_bytes: Some(1024),
211            transferred_bytes: 1024,
212            parts_done: 0,
213            parts_total: 0,
214            state: TransferState::Done,
215            started_at: 1_000_000,
216            finished_at: Some(1_001_000),
217            error: None,
218        }
219    }
220
221    fn make_transfer_failed() -> Transfer {
222        Transfer {
223            id: "xfer-fail".to_string(),
224            kind: TransferKind::Upload,
225            profile_id: ProfileId::new("p1"),
226            bucket: BucketId::new("my-bucket"),
227            key: "data/upload.txt".to_string(),
228            source_path: Some(PathBuf::from("/local/upload.txt")),
229            dest_path: None,
230            total_bytes: Some(2048),
231            transferred_bytes: 1024,
232            parts_done: 0,
233            parts_total: 0,
234            state: TransferState::Failed,
235            started_at: 1_000_000,
236            finished_at: Some(1_002_000),
237            error: Some(AppError::Network {
238                source: "connection refused".to_string(),
239            }),
240        }
241    }
242
243    fn make_transfer_canceled() -> Transfer {
244        Transfer {
245            id: "xfer-cancel".to_string(),
246            kind: TransferKind::Download,
247            profile_id: ProfileId::new("p1"),
248            bucket: BucketId::new("my-bucket"),
249            key: "data/cancel.txt".to_string(),
250            source_path: None,
251            dest_path: Some(PathBuf::from("/tmp/cancel.txt")),
252            total_bytes: None,
253            transferred_bytes: 0,
254            parts_done: 0,
255            parts_total: 0,
256            state: TransferState::Canceled,
257            started_at: 1_000_000,
258            finished_at: Some(1_001_000),
259            error: None,
260        }
261    }
262
263    // -----------------------------------------------------------------------
264    // OS notification fires only for Done/Failed when os_enabled=true
265    // -----------------------------------------------------------------------
266
267    #[tokio::test]
268    async fn os_notification_fires_for_done_when_enabled() {
269        let channel = MockChannel::default();
270        let log = NotificationLogHandle::default();
271        let os_channel = MockOsChannel::new();
272        let notifier = OsNotifier::new(os_channel, make_settings(true));
273
274        notify_terminal(&make_transfer_done(), &channel, &log, &notifier)
275            .await
276            .expect("must succeed");
277
278        assert_eq!(
279            notifier.inner_channel().call_count(),
280            1,
281            "OS notification must fire for Done",
282        );
283    }
284
285    #[tokio::test]
286    async fn os_notification_fires_for_failed_when_enabled() {
287        let channel = MockChannel::default();
288        let log = NotificationLogHandle::default();
289        let os_channel = MockOsChannel::new();
290        let notifier = OsNotifier::new(os_channel, make_settings(true));
291
292        notify_terminal(&make_transfer_failed(), &channel, &log, &notifier)
293            .await
294            .expect("must succeed");
295
296        assert_eq!(
297            notifier.inner_channel().call_count(),
298            1,
299            "OS notification must fire for Failed",
300        );
301    }
302
303    #[tokio::test]
304    async fn os_notification_silent_for_canceled() {
305        let channel = MockChannel::default();
306        let log = NotificationLogHandle::default();
307        let os_channel = MockOsChannel::new();
308        let notifier = OsNotifier::new(os_channel, make_settings(true));
309
310        notify_terminal(&make_transfer_canceled(), &channel, &log, &notifier)
311            .await
312            .expect("must succeed");
313
314        // Canceled → Info severity → OsNotifier Gate 2 blocks it.
315        assert_eq!(
316            notifier.inner_channel().call_count(),
317            0,
318            "OS notification must be silent for Canceled state",
319        );
320    }
321
322    #[tokio::test]
323    async fn os_notification_silent_when_os_disabled() {
324        let channel = MockChannel::default();
325        let log = NotificationLogHandle::default();
326        let os_channel = MockOsChannel::new();
327        let notifier = OsNotifier::new(os_channel, make_settings(false));
328
329        notify_terminal(&make_transfer_done(), &channel, &log, &notifier)
330            .await
331            .expect("must succeed");
332
333        assert_eq!(
334            notifier.inner_channel().call_count(),
335            0,
336            "OS notification must be silent when os_enabled=false",
337        );
338    }
339
340    // -----------------------------------------------------------------------
341    // In-app notification is always pushed (regardless of OS setting)
342    // -----------------------------------------------------------------------
343
344    #[tokio::test]
345    async fn in_app_notification_always_pushed() {
346        let channel = MockChannel::default();
347        let log = NotificationLogHandle::default();
348        let os_channel = MockOsChannel::new();
349        let notifier = OsNotifier::new(os_channel, make_settings(false));
350
351        notify_terminal(&make_transfer_done(), &channel, &log, &notifier)
352            .await
353            .expect("must succeed");
354
355        let entries = log.0.read().await.list(None);
356        assert_eq!(entries.len(), 1, "one in-app notification must be logged");
357        assert_eq!(entries[0].severity, Severity::Success);
358    }
359
360    // -----------------------------------------------------------------------
361    // Severity mapping
362    // -----------------------------------------------------------------------
363
364    #[test]
365    fn done_maps_to_success_severity() {
366        let t = make_transfer_done();
367        let (severity, _, _) = notification_content(&t);
368        assert_eq!(severity, Severity::Success);
369    }
370
371    #[test]
372    fn failed_maps_to_error_severity() {
373        let t = make_transfer_failed();
374        let (severity, _, _) = notification_content(&t);
375        assert_eq!(severity, Severity::Error);
376    }
377
378    #[test]
379    fn canceled_maps_to_info_severity() {
380        let t = make_transfer_canceled();
381        let (severity, _, _) = notification_content(&t);
382        assert_eq!(severity, Severity::Info);
383    }
384}