Skip to main content

ralph/notification/
mod.rs

1//! Desktop notification system for task completion and failures.
2//!
3//! Responsibilities:
4//! - Expose the notification configuration and runtime API.
5//! - Coordinate notification delivery, suppression, and optional sound playback.
6//! - Keep platform-specific display and sound logic isolated in focused submodules.
7//!
8//! Does NOT handle:
9//! - Notification scheduling or queuing (callers trigger explicitly).
10//! - Persistent notification history or logging.
11//! - UI mode detection (callers should suppress if desired).
12//! - Do Not Disturb detection (handled at call site if needed).
13//!
14//! Invariants:
15//! - Sound playback failures do not fail the notification call.
16//! - Notification failures are logged but do not fail the calling operation.
17//! - Public call sites continue to use `crate::notification::{...}` without change.
18
19mod 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/// Types of notifications that can be sent.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum NotificationType {
38    /// Task completed successfully.
39    TaskComplete,
40    /// Task failed.
41    TaskFailed,
42    /// Loop mode completed with summary.
43    LoopComplete {
44        tasks_total: usize,
45        tasks_succeeded: usize,
46        tasks_failed: usize,
47    },
48    /// Watch mode added new tasks from comments.
49    WatchNewTasks,
50}
51
52/// Send a notification based on the notification type.
53/// Silently logs errors but never fails the calling operation.
54///
55/// # Arguments
56/// * `notification_type` - The type of notification to send
57/// * `task_id` - The task identifier (for task-specific notifications)
58/// * `task_title` - The task title (for task-specific notifications)
59/// * `config` - Notification configuration
60/// * `ui_active` - Whether a foreground UI client is currently active (for suppression)
61pub 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
96/// Send task completion notification.
97/// Silently logs errors but never fails the calling operation.
98pub 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
108/// Send task completion notification with UI awareness.
109/// Silently logs errors but never fails the calling operation.
110pub 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
125/// Send task failure notification.
126/// Silently logs errors but never fails the calling operation.
127pub 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
145/// Send loop completion notification.
146/// Silently logs errors but never fails the calling operation.
147pub 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
169/// Send watch mode notification for newly detected tasks.
170/// Silently logs errors but never fails the calling operation.
171pub 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}