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/// Types of notifications that can be sent.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum NotificationType {
34    /// Task completed successfully.
35    TaskComplete,
36    /// Task failed.
37    TaskFailed,
38    /// Loop mode completed with summary.
39    LoopComplete {
40        tasks_total: usize,
41        tasks_succeeded: usize,
42        tasks_failed: usize,
43    },
44}
45
46/// Send a notification based on the notification type.
47/// Silently logs errors but never fails the calling operation.
48///
49/// # Arguments
50/// * `notification_type` - The type of notification to send
51/// * `task_id` - The task identifier (for task-specific notifications)
52/// * `task_title` - The task title (for task-specific notifications)
53/// * `config` - Notification configuration
54/// * `ui_active` - Whether a foreground UI client is currently active (for suppression)
55pub fn send_notification(
56    notification_type: NotificationType,
57    task_id: &str,
58    task_title: &str,
59    config: &NotificationConfig,
60    ui_active: bool,
61) {
62    let request = match notification_type {
63        NotificationType::TaskComplete => NotificationDisplayRequest::Task {
64            kind: notification_type,
65            task_id,
66            task_title,
67        },
68        NotificationType::TaskFailed => NotificationDisplayRequest::Task {
69            kind: notification_type,
70            task_id,
71            task_title,
72        },
73        NotificationType::LoopComplete {
74            tasks_total,
75            tasks_succeeded,
76            tasks_failed,
77        } => NotificationDisplayRequest::Loop {
78            tasks_total,
79            tasks_succeeded,
80            tasks_failed,
81        },
82    };
83    dispatch_notification(notification_type, request, config, ui_active);
84}
85
86/// Send task completion notification.
87/// Silently logs errors but never fails the calling operation.
88pub fn notify_task_complete(task_id: &str, task_title: &str, config: &NotificationConfig) {
89    send_notification(
90        NotificationType::TaskComplete,
91        task_id,
92        task_title,
93        config,
94        false,
95    );
96}
97
98/// Send task completion notification with UI awareness.
99/// Silently logs errors but never fails the calling operation.
100pub fn notify_task_complete_with_context(
101    task_id: &str,
102    task_title: &str,
103    config: &NotificationConfig,
104    ui_active: bool,
105) {
106    send_notification(
107        NotificationType::TaskComplete,
108        task_id,
109        task_title,
110        config,
111        ui_active,
112    );
113}
114
115/// Send task failure notification.
116/// Silently logs errors but never fails the calling operation.
117pub fn notify_task_failed(
118    task_id: &str,
119    task_title: &str,
120    error: &str,
121    config: &NotificationConfig,
122) {
123    dispatch_notification(
124        NotificationType::TaskFailed,
125        NotificationDisplayRequest::Failure {
126            task_id,
127            task_title,
128            error,
129        },
130        config,
131        false,
132    );
133}
134
135/// Send loop completion notification.
136/// Silently logs errors but never fails the calling operation.
137pub fn notify_loop_complete(
138    tasks_total: usize,
139    tasks_succeeded: usize,
140    tasks_failed: usize,
141    config: &NotificationConfig,
142) {
143    dispatch_notification(
144        NotificationType::LoopComplete {
145            tasks_total,
146            tasks_succeeded,
147            tasks_failed,
148        },
149        NotificationDisplayRequest::Loop {
150            tasks_total,
151            tasks_succeeded,
152            tasks_failed,
153        },
154        config,
155        false,
156    );
157}
158
159/// Send watch mode notification for newly detected tasks.
160/// Silently logs errors but never fails the calling operation.
161pub fn notify_watch_new_task(count: usize, config: &NotificationConfig) {
162    if !config.enabled {
163        log::debug!("Notifications disabled; skipping");
164        return;
165    }
166
167    if config.should_suppress(false) {
168        log::debug!("Notifications suppressed (globally disabled)");
169        return;
170    }
171
172    if let Err(error) = show_watch_notification(count, config.timeout_ms) {
173        log::debug!("Failed to show watch notification: {}", error);
174    }
175    play_sound_if_enabled(config);
176}
177
178fn dispatch_notification(
179    notification_type: NotificationType,
180    request: NotificationDisplayRequest<'_>,
181    config: &NotificationConfig,
182    ui_active: bool,
183) {
184    let type_enabled = match notification_type {
185        NotificationType::TaskComplete => config.notify_on_complete,
186        NotificationType::TaskFailed => config.notify_on_fail,
187        NotificationType::LoopComplete { .. } => config.notify_on_loop_complete,
188    };
189
190    if !type_enabled {
191        log::debug!(
192            "Notification type {:?} disabled; skipping",
193            notification_type
194        );
195        return;
196    }
197
198    if config.should_suppress(ui_active) {
199        log::debug!("Notifications suppressed (UI active or globally disabled)");
200        return;
201    }
202
203    let display_result = match request {
204        NotificationDisplayRequest::Task {
205            kind,
206            task_id,
207            task_title,
208        } => show_task_notification(kind, task_id, task_title, config.timeout_ms),
209        NotificationDisplayRequest::Failure {
210            task_id,
211            task_title,
212            error,
213        } => show_failure_notification(task_id, task_title, error, config.timeout_ms),
214        NotificationDisplayRequest::Loop {
215            tasks_total,
216            tasks_succeeded,
217            tasks_failed,
218        } => show_loop_notification(
219            tasks_total,
220            tasks_succeeded,
221            tasks_failed,
222            config.timeout_ms,
223        ),
224    };
225
226    if let Err(error) = display_result {
227        log::debug!("Failed to show notification: {}", error);
228    }
229    play_sound_if_enabled(config);
230}
231
232fn play_sound_if_enabled(config: &NotificationConfig) {
233    if config.sound_enabled
234        && let Err(error) = play_completion_sound(config.sound_path.as_deref())
235    {
236        log::debug!("Failed to play sound: {}", error);
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::display::show_task_notification;
243    use super::*;
244
245    #[test]
246    fn notification_config_default_values() {
247        let config = NotificationConfig::new();
248        assert!(config.enabled);
249        assert!(config.notify_on_complete);
250        assert!(config.notify_on_fail);
251        assert!(config.notify_on_loop_complete);
252        assert!(config.suppress_when_active);
253        assert!(!config.sound_enabled);
254        assert!(config.sound_path.is_none());
255        assert_eq!(config.timeout_ms, 8000);
256    }
257
258    #[test]
259    fn notify_task_complete_disabled_does_nothing() {
260        let config = NotificationConfig {
261            enabled: false,
262            notify_on_complete: false,
263            notify_on_fail: false,
264            notify_on_loop_complete: false,
265            suppress_when_active: true,
266            sound_enabled: true,
267            sound_path: None,
268            timeout_ms: 8000,
269        };
270        notify_task_complete("RQ-0001", "Test task", &config);
271    }
272
273    #[test]
274    fn show_task_notification_ignores_loop_complete_variant() {
275        let result = show_task_notification(
276            NotificationType::LoopComplete {
277                tasks_total: 3,
278                tasks_succeeded: 2,
279                tasks_failed: 1,
280            },
281            "RQ-0001",
282            "Test task",
283            8000,
284        );
285
286        assert!(result.is_ok());
287    }
288
289    #[test]
290    fn notification_config_can_be_customized() {
291        let config = NotificationConfig {
292            enabled: true,
293            notify_on_complete: true,
294            notify_on_fail: false,
295            notify_on_loop_complete: true,
296            suppress_when_active: false,
297            sound_enabled: true,
298            sound_path: Some("/path/to/sound.wav".to_string()),
299            timeout_ms: 5000,
300        };
301        assert!(config.enabled);
302        assert!(config.notify_on_complete);
303        assert!(!config.notify_on_fail);
304        assert!(config.notify_on_loop_complete);
305        assert!(!config.suppress_when_active);
306        assert!(config.sound_enabled);
307        assert_eq!(config.sound_path, Some("/path/to/sound.wav".to_string()));
308        assert_eq!(config.timeout_ms, 5000);
309    }
310}