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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum NotificationType {
34 TaskComplete,
36 TaskFailed,
38 LoopComplete {
40 tasks_total: usize,
41 tasks_succeeded: usize,
42 tasks_failed: usize,
43 },
44}
45
46pub 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
86pub 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
98pub 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
115pub 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
135pub 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
159pub 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}