1mod config;
20mod display;
21mod sound;
22
23pub use config::{NotificationConfig, NotificationOverrides, build_notification_config};
24pub use sound::play_completion_sound;
25
26use display::{
27 NotificationDisplayRequest, show_failure_notification, show_loop_notification,
28 show_task_notification, show_watch_notification,
29};
30
31#[cfg(all(feature = "notifications", target_os = "macos"))]
32const MACOS_NOTIFICATION_BUNDLE_ID: &str = "com.mitchfultz.ralph";
33pub(crate) const UI_ACTIVE_ENV_KEY: &str = "RALPH_UI_ACTIVE";
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum NotificationType {
38 TaskComplete,
40 TaskFailed,
42 LoopComplete {
44 tasks_total: usize,
45 tasks_succeeded: usize,
46 tasks_failed: usize,
47 },
48 WatchNewTasks,
50}
51
52pub fn send_notification(
62 notification_type: NotificationType,
63 task_id: &str,
64 task_title: &str,
65 config: &NotificationConfig,
66 ui_active: bool,
67) {
68 let request = match notification_type {
69 NotificationType::WatchNewTasks => {
70 log::debug!("Watch new-task notifications must use notify_watch_new_task");
71 return;
72 }
73 NotificationType::TaskComplete => NotificationDisplayRequest::Task {
74 kind: notification_type,
75 task_id,
76 task_title,
77 },
78 NotificationType::TaskFailed => NotificationDisplayRequest::Task {
79 kind: notification_type,
80 task_id,
81 task_title,
82 },
83 NotificationType::LoopComplete {
84 tasks_total,
85 tasks_succeeded,
86 tasks_failed,
87 } => NotificationDisplayRequest::Loop {
88 tasks_total,
89 tasks_succeeded,
90 tasks_failed,
91 },
92 };
93 dispatch_notification(notification_type, request, config, ui_active);
94}
95
96pub fn notify_task_complete(task_id: &str, task_title: &str, config: &NotificationConfig) {
99 send_notification(
100 NotificationType::TaskComplete,
101 task_id,
102 task_title,
103 config,
104 false,
105 );
106}
107
108pub fn notify_task_complete_with_context(
111 task_id: &str,
112 task_title: &str,
113 config: &NotificationConfig,
114 ui_active: bool,
115) {
116 send_notification(
117 NotificationType::TaskComplete,
118 task_id,
119 task_title,
120 config,
121 ui_active,
122 );
123}
124
125pub fn notify_task_failed(
128 task_id: &str,
129 task_title: &str,
130 error: &str,
131 config: &NotificationConfig,
132) {
133 dispatch_notification(
134 NotificationType::TaskFailed,
135 NotificationDisplayRequest::Failure {
136 task_id,
137 task_title,
138 error,
139 },
140 config,
141 false,
142 );
143}
144
145pub fn notify_loop_complete(
148 tasks_total: usize,
149 tasks_succeeded: usize,
150 tasks_failed: usize,
151 config: &NotificationConfig,
152) {
153 dispatch_notification(
154 NotificationType::LoopComplete {
155 tasks_total,
156 tasks_succeeded,
157 tasks_failed,
158 },
159 NotificationDisplayRequest::Loop {
160 tasks_total,
161 tasks_succeeded,
162 tasks_failed,
163 },
164 config,
165 false,
166 );
167}
168
169pub fn notify_watch_new_task(count: usize, config: &NotificationConfig) {
172 if !should_deliver_notification(config, Some(NotificationType::WatchNewTasks), false) {
173 return;
174 }
175
176 if let Err(error) = show_watch_notification(count, config.timeout_ms) {
177 log::debug!("Failed to show watch notification: {}", error);
178 }
179 play_sound_if_enabled(config);
180}
181
182#[cfg(all(feature = "notifications", target_os = "macos"))]
183pub(crate) fn prepare_platform_notification_delivery() {
184 if let Err(error) = notify_rust::set_application(MACOS_NOTIFICATION_BUNDLE_ID) {
185 log::trace!(
186 "macOS notification bundle already configured or unavailable (bundle={}, error={})",
187 MACOS_NOTIFICATION_BUNDLE_ID,
188 error
189 );
190 }
191}
192
193#[cfg(not(all(feature = "notifications", target_os = "macos")))]
194pub(crate) fn prepare_platform_notification_delivery() {}
195
196fn ui_activity_override_from_env_value(value: Option<&str>) -> bool {
197 value.is_some_and(|raw| {
198 let normalized = raw.trim();
199 normalized == "1"
200 || normalized.eq_ignore_ascii_case("true")
201 || normalized.eq_ignore_ascii_case("yes")
202 || normalized.eq_ignore_ascii_case("on")
203 })
204}
205
206fn effective_ui_active(ui_active: bool) -> bool {
207 ui_active
208 || ui_activity_override_from_env_value(std::env::var(UI_ACTIVE_ENV_KEY).ok().as_deref())
209}
210
211fn should_suppress_notification_delivery(config: &NotificationConfig, ui_active: bool) -> bool {
212 let ui_active = effective_ui_active(ui_active);
213 ui_active || config.should_suppress(false)
214}
215
216fn should_deliver_notification(
217 config: &NotificationConfig,
218 notification_type: Option<NotificationType>,
219 ui_active: bool,
220) -> bool {
221 if let Some(notification_type) = notification_type {
222 let type_enabled = match notification_type {
223 NotificationType::TaskComplete => config.notify_on_complete,
224 NotificationType::TaskFailed => config.notify_on_fail,
225 NotificationType::LoopComplete { .. } => config.notify_on_loop_complete,
226 NotificationType::WatchNewTasks => config.notify_on_watch_new_tasks,
227 };
228 if !type_enabled {
229 log::debug!(
230 "Notification type {:?} disabled; skipping",
231 notification_type
232 );
233 return false;
234 }
235 }
236
237 if should_suppress_notification_delivery(config, ui_active) {
238 log::debug!("Notifications suppressed (UI active or globally disabled)");
239 return false;
240 }
241
242 true
243}
244
245fn dispatch_notification(
246 notification_type: NotificationType,
247 request: NotificationDisplayRequest<'_>,
248 config: &NotificationConfig,
249 ui_active: bool,
250) {
251 if !should_deliver_notification(config, Some(notification_type), ui_active) {
252 return;
253 }
254
255 let display_result = match request {
256 NotificationDisplayRequest::Task {
257 kind,
258 task_id,
259 task_title,
260 } => show_task_notification(kind, task_id, task_title, config.timeout_ms),
261 NotificationDisplayRequest::Failure {
262 task_id,
263 task_title,
264 error,
265 } => show_failure_notification(task_id, task_title, error, config.timeout_ms),
266 NotificationDisplayRequest::Loop {
267 tasks_total,
268 tasks_succeeded,
269 tasks_failed,
270 } => show_loop_notification(
271 tasks_total,
272 tasks_succeeded,
273 tasks_failed,
274 config.timeout_ms,
275 ),
276 };
277
278 if let Err(error) = display_result {
279 log::debug!("Failed to show notification: {}", error);
280 }
281 play_sound_if_enabled(config);
282}
283
284fn play_sound_if_enabled(config: &NotificationConfig) {
285 if config.sound_enabled
286 && let Err(error) = play_completion_sound(config.sound_path.as_deref())
287 {
288 log::debug!("Failed to play sound: {}", error);
289 }
290}
291
292#[cfg(test)]
293mod tests {
294 use super::display::show_task_notification;
295 use super::*;
296
297 #[test]
298 fn notification_config_default_values() {
299 let config = NotificationConfig::new();
300 assert!(config.enabled);
301 assert!(config.notify_on_complete);
302 assert!(config.notify_on_fail);
303 assert!(config.notify_on_loop_complete);
304 assert!(config.notify_on_watch_new_tasks);
305 assert!(config.suppress_when_active);
306 assert!(!config.sound_enabled);
307 assert!(config.sound_path.is_none());
308 assert_eq!(config.timeout_ms, 8000);
309 }
310
311 #[test]
312 fn notify_task_complete_disabled_does_nothing() {
313 let config = NotificationConfig {
314 enabled: false,
315 notify_on_complete: false,
316 notify_on_fail: false,
317 notify_on_loop_complete: false,
318 notify_on_watch_new_tasks: false,
319 suppress_when_active: true,
320 sound_enabled: true,
321 sound_path: None,
322 timeout_ms: 8000,
323 };
324 notify_task_complete("RQ-0001", "Test task", &config);
325 }
326
327 #[test]
328 fn show_task_notification_ignores_loop_complete_variant() {
329 let result = show_task_notification(
330 NotificationType::LoopComplete {
331 tasks_total: 3,
332 tasks_succeeded: 2,
333 tasks_failed: 1,
334 },
335 "RQ-0001",
336 "Test task",
337 8000,
338 );
339
340 assert!(result.is_ok());
341 }
342
343 #[test]
344 fn notification_config_can_be_customized() {
345 let config = NotificationConfig {
346 enabled: true,
347 notify_on_complete: true,
348 notify_on_fail: false,
349 notify_on_loop_complete: true,
350 notify_on_watch_new_tasks: true,
351 suppress_when_active: false,
352 sound_enabled: true,
353 sound_path: Some("/path/to/sound.wav".to_string()),
354 timeout_ms: 5000,
355 };
356 assert!(config.enabled);
357 assert!(config.notify_on_complete);
358 assert!(!config.notify_on_fail);
359 assert!(config.notify_on_loop_complete);
360 assert!(config.notify_on_watch_new_tasks);
361 assert!(!config.suppress_when_active);
362 assert!(config.sound_enabled);
363 assert_eq!(config.sound_path, Some("/path/to/sound.wav".to_string()));
364 assert_eq!(config.timeout_ms, 5000);
365 }
366
367 #[test]
368 fn ui_activity_override_from_env_value_accepts_truthy_values() {
369 for value in ["1", "true", "TRUE", "yes", "on"] {
370 assert!(ui_activity_override_from_env_value(Some(value)));
371 }
372 }
373
374 #[test]
375 fn ui_activity_override_from_env_value_rejects_missing_or_falsey_values() {
376 for value in [
377 None,
378 Some(""),
379 Some("0"),
380 Some("false"),
381 Some("off"),
382 Some("no"),
383 ] {
384 assert!(!ui_activity_override_from_env_value(value));
385 }
386 }
387
388 #[test]
389 fn effective_ui_active_keeps_explicit_ui_activity() {
390 assert!(effective_ui_active(true));
391 }
392
393 #[test]
394 fn should_suppress_notification_delivery_when_ui_active_even_if_config_disables_activity_gate()
395 {
396 let config = NotificationConfig {
397 enabled: true,
398 notify_on_complete: true,
399 notify_on_fail: true,
400 notify_on_loop_complete: true,
401 notify_on_watch_new_tasks: true,
402 suppress_when_active: false,
403 sound_enabled: false,
404 sound_path: None,
405 timeout_ms: 8000,
406 };
407
408 assert!(should_suppress_notification_delivery(&config, true));
409 }
410
411 #[test]
412 fn should_suppress_notification_delivery_respects_global_disable_without_ui_activity() {
413 let config = NotificationConfig {
414 enabled: false,
415 notify_on_complete: true,
416 notify_on_fail: true,
417 notify_on_loop_complete: true,
418 notify_on_watch_new_tasks: true,
419 suppress_when_active: false,
420 sound_enabled: false,
421 sound_path: None,
422 timeout_ms: 8000,
423 };
424
425 assert!(should_suppress_notification_delivery(&config, false));
426 }
427
428 #[test]
429 fn should_deliver_notification_rejects_disabled_types() {
430 let config = NotificationConfig {
431 enabled: true,
432 notify_on_complete: false,
433 notify_on_fail: true,
434 notify_on_loop_complete: true,
435 notify_on_watch_new_tasks: true,
436 suppress_when_active: true,
437 sound_enabled: false,
438 sound_path: None,
439 timeout_ms: 8000,
440 };
441
442 assert!(!should_deliver_notification(
443 &config,
444 Some(NotificationType::TaskComplete),
445 false
446 ));
447 }
448
449 #[test]
450 fn should_deliver_notification_respects_watch_new_task_gate() {
451 let config = NotificationConfig {
452 enabled: true,
453 notify_on_complete: false,
454 notify_on_fail: false,
455 notify_on_loop_complete: false,
456 notify_on_watch_new_tasks: true,
457 suppress_when_active: false,
458 sound_enabled: false,
459 sound_path: None,
460 timeout_ms: 8000,
461 };
462
463 assert!(should_deliver_notification(
464 &config,
465 Some(NotificationType::WatchNewTasks),
466 false
467 ));
468 assert!(!should_deliver_notification(
469 &config,
470 Some(NotificationType::WatchNewTasks),
471 true
472 ));
473
474 let config = NotificationConfig {
475 notify_on_watch_new_tasks: false,
476 ..config
477 };
478 assert!(!should_deliver_notification(
479 &config,
480 Some(NotificationType::WatchNewTasks),
481 false
482 ));
483 }
484}