1use 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
37pub 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 {
85 let mut log_guard = log.0.write().await;
86 log_guard.push_with_broadcast(notification.clone(), channel)?;
87 }
88
89 let is_terminal_for_os =
92 transfer.state == TransferState::Done || transfer.state == TransferState::Failed;
93
94 if is_terminal_for_os {
95 let _ = os_notifier.maybe_send(¬ification, true).await;
97 }
98
99 Ok(())
100}
101
102fn 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 _ => {
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
160fn build_resource_uri(bucket: &BucketId, key: &str) -> String {
166 format!("s3://{}/{}", bucket.as_str(), key)
167}
168
169#[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 #[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, ¬ifier)
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, ¬ifier)
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, ¬ifier)
311 .await
312 .expect("must succeed");
313
314 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, ¬ifier)
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 #[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, ¬ifier)
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 #[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}