1use std::path::Path;
22
23#[derive(Debug, Clone, Default)]
26pub struct NotificationOverrides {
27 pub notify_on_complete: Option<bool>,
29 pub notify_on_fail: Option<bool>,
31 pub notify_sound: Option<bool>,
33}
34
35pub fn build_notification_config(
46 config: &crate::contracts::NotificationConfig,
47 overrides: &NotificationOverrides,
48) -> NotificationConfig {
49 let notify_on_complete = overrides
50 .notify_on_complete
51 .or(config.notify_on_complete)
52 .unwrap_or(true);
53 let notify_on_fail = overrides
54 .notify_on_fail
55 .or(config.notify_on_fail)
56 .unwrap_or(true);
57 let notify_on_loop_complete = config.notify_on_loop_complete.unwrap_or(true);
58 let enabled = notify_on_complete || notify_on_fail || notify_on_loop_complete;
60
61 NotificationConfig {
62 enabled,
63 notify_on_complete,
64 notify_on_fail,
65 notify_on_loop_complete,
66 suppress_when_active: config.suppress_when_active.unwrap_or(true),
67 sound_enabled: overrides
68 .notify_sound
69 .or(config.sound_enabled)
70 .unwrap_or(false),
71 sound_path: config.sound_path.clone(),
72 timeout_ms: config.timeout_ms.unwrap_or(8000),
73 }
74}
75
76#[derive(Debug, Clone, Default)]
78pub struct NotificationConfig {
79 pub enabled: bool,
81 pub notify_on_complete: bool,
83 pub notify_on_fail: bool,
85 pub notify_on_loop_complete: bool,
87 pub suppress_when_active: bool,
89 pub sound_enabled: bool,
91 pub sound_path: Option<String>,
94 pub timeout_ms: u32,
96}
97
98impl NotificationConfig {
99 pub fn new() -> Self {
101 Self {
102 enabled: true,
103 notify_on_complete: true,
104 notify_on_fail: true,
105 notify_on_loop_complete: true,
106 suppress_when_active: true,
107 sound_enabled: false,
108 sound_path: None,
109 timeout_ms: 8000,
110 }
111 }
112
113 pub fn should_suppress(&self, ui_active: bool) -> bool {
115 if !self.enabled {
116 return true;
117 }
118 if ui_active && self.suppress_when_active {
119 return true;
120 }
121 false
122 }
123}
124
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub enum NotificationType {
128 TaskComplete,
130 TaskFailed,
132 LoopComplete {
134 tasks_total: usize,
135 tasks_succeeded: usize,
136 tasks_failed: usize,
137 },
138}
139
140pub fn send_notification(
150 notification_type: NotificationType,
151 task_id: &str,
152 task_title: &str,
153 config: &NotificationConfig,
154 ui_active: bool,
155) {
156 let type_enabled = match notification_type {
158 NotificationType::TaskComplete => config.notify_on_complete,
159 NotificationType::TaskFailed => config.notify_on_fail,
160 NotificationType::LoopComplete { .. } => config.notify_on_loop_complete,
161 };
162
163 if !type_enabled {
164 log::debug!(
165 "Notification type {:?} disabled; skipping",
166 notification_type
167 );
168 return;
169 }
170
171 if config.should_suppress(ui_active) {
172 log::debug!("Notifications suppressed (UI active or globally disabled)");
173 return;
174 }
175
176 if let Err(e) =
178 show_notification_typed(notification_type, task_id, task_title, config.timeout_ms)
179 {
180 log::debug!("Failed to show notification: {}", e);
181 }
182
183 if config.sound_enabled
185 && let Err(e) = play_completion_sound(config.sound_path.as_deref())
186 {
187 log::debug!("Failed to play sound: {}", e);
188 }
189}
190
191pub fn notify_task_complete(task_id: &str, task_title: &str, config: &NotificationConfig) {
194 send_notification(
195 NotificationType::TaskComplete,
196 task_id,
197 task_title,
198 config,
199 false,
200 );
201}
202
203pub fn notify_task_complete_with_context(
206 task_id: &str,
207 task_title: &str,
208 config: &NotificationConfig,
209 ui_active: bool,
210) {
211 send_notification(
212 NotificationType::TaskComplete,
213 task_id,
214 task_title,
215 config,
216 ui_active,
217 );
218}
219
220pub fn notify_task_failed(
223 task_id: &str,
224 task_title: &str,
225 error: &str,
226 config: &NotificationConfig,
227) {
228 if !config.notify_on_fail {
229 log::debug!("Failure notifications disabled; skipping");
230 return;
231 }
232
233 if config.should_suppress(false) {
234 log::debug!("Notifications suppressed (globally disabled)");
235 return;
236 }
237
238 if let Err(e) = show_notification_failure(task_id, task_title, error, config.timeout_ms) {
240 log::debug!("Failed to show failure notification: {}", e);
241 }
242
243 if config.sound_enabled
245 && let Err(e) = play_completion_sound(config.sound_path.as_deref())
246 {
247 log::debug!("Failed to play sound: {}", e);
248 }
249}
250
251pub fn notify_loop_complete(
254 tasks_total: usize,
255 tasks_succeeded: usize,
256 tasks_failed: usize,
257 config: &NotificationConfig,
258) {
259 if !config.notify_on_loop_complete {
260 log::debug!("Loop completion notifications disabled; skipping");
261 return;
262 }
263
264 if config.should_suppress(false) {
265 log::debug!("Notifications suppressed (globally disabled)");
266 return;
267 }
268
269 if let Err(e) = show_notification_loop(
271 tasks_total,
272 tasks_succeeded,
273 tasks_failed,
274 config.timeout_ms,
275 ) {
276 log::debug!("Failed to show loop notification: {}", e);
277 }
278
279 if config.sound_enabled
281 && let Err(e) = play_completion_sound(config.sound_path.as_deref())
282 {
283 log::debug!("Failed to play sound: {}", e);
284 }
285}
286
287pub fn notify_watch_new_task(count: usize, config: &NotificationConfig) {
290 if !config.enabled {
291 log::debug!("Notifications disabled; skipping");
292 return;
293 }
294
295 if config.should_suppress(false) {
296 log::debug!("Notifications suppressed (globally disabled)");
297 return;
298 }
299
300 if let Err(e) = show_notification_watch(count, config.timeout_ms) {
302 log::debug!("Failed to show watch notification: {}", e);
303 }
304
305 if config.sound_enabled
307 && let Err(e) = play_completion_sound(config.sound_path.as_deref())
308 {
309 log::debug!("Failed to play sound: {}", e);
310 }
311}
312
313#[cfg(feature = "notifications")]
314fn show_notification_watch(count: usize, timeout_ms: u32) -> anyhow::Result<()> {
315 use notify_rust::{Notification, Timeout};
316
317 let body = if count == 1 {
318 "1 new task detected from code comments".to_string()
319 } else {
320 format!("{} new tasks detected from code comments", count)
321 };
322
323 Notification::new()
324 .summary("Ralph: Watch Mode")
325 .body(&body)
326 .timeout(Timeout::Milliseconds(timeout_ms))
327 .show()
328 .map_err(|e| anyhow::anyhow!("Failed to show notification: {}", e))?;
329
330 Ok(())
331}
332
333#[cfg(not(feature = "notifications"))]
334fn show_notification_watch(_count: usize, _timeout_ms: u32) -> anyhow::Result<()> {
335 log::debug!("Notifications feature not compiled in; skipping notification display");
336 Ok(())
337}
338
339#[cfg(feature = "notifications")]
340fn show_notification_typed(
341 notification_type: NotificationType,
342 task_id: &str,
343 task_title: &str,
344 timeout_ms: u32,
345) -> anyhow::Result<()> {
346 use notify_rust::{Notification, Timeout};
347
348 let (summary, body) = match notification_type {
349 NotificationType::TaskComplete => (
350 "Ralph: Task Complete",
351 format!("{} - {}", task_id, task_title),
352 ),
353 NotificationType::TaskFailed => (
354 "Ralph: Task Failed",
355 format!("{} - {}", task_id, task_title),
356 ),
357 NotificationType::LoopComplete {
358 tasks_total,
359 tasks_succeeded,
360 tasks_failed,
361 } => (
362 "Ralph: Loop Complete",
363 format!(
364 "{} tasks completed ({} succeeded, {} failed)",
365 tasks_total, tasks_succeeded, tasks_failed
366 ),
367 ),
368 };
369
370 Notification::new()
371 .summary(summary)
372 .body(&body)
373 .timeout(Timeout::Milliseconds(timeout_ms))
374 .show()
375 .map_err(|e| anyhow::anyhow!("Failed to show notification: {}", e))?;
376
377 Ok(())
378}
379
380#[cfg(not(feature = "notifications"))]
381fn show_notification_typed(
382 _notification_type: NotificationType,
383 _task_id: &str,
384 _task_title: &str,
385 _timeout_ms: u32,
386) -> anyhow::Result<()> {
387 log::debug!("Notifications feature not compiled in; skipping notification display");
388 Ok(())
389}
390
391#[cfg(feature = "notifications")]
392fn show_notification_failure(
393 task_id: &str,
394 task_title: &str,
395 error: &str,
396 timeout_ms: u32,
397) -> anyhow::Result<()> {
398 use notify_rust::{Notification, Timeout};
399
400 let error_summary = if error.len() > 100 {
402 format!("{}...", &error[..97])
403 } else {
404 error.to_string()
405 };
406
407 Notification::new()
408 .summary("Ralph: Task Failed")
409 .body(&format!(
410 "{} - {}\nError: {}",
411 task_id, task_title, error_summary
412 ))
413 .timeout(Timeout::Milliseconds(timeout_ms))
414 .show()
415 .map_err(|e| anyhow::anyhow!("Failed to show notification: {}", e))?;
416
417 Ok(())
418}
419
420#[cfg(not(feature = "notifications"))]
421fn show_notification_failure(
422 _task_id: &str,
423 _task_title: &str,
424 _error: &str,
425 _timeout_ms: u32,
426) -> anyhow::Result<()> {
427 log::debug!("Notifications feature not compiled in; skipping notification display");
428 Ok(())
429}
430
431#[cfg(feature = "notifications")]
432fn show_notification_loop(
433 tasks_total: usize,
434 tasks_succeeded: usize,
435 tasks_failed: usize,
436 timeout_ms: u32,
437) -> anyhow::Result<()> {
438 use notify_rust::{Notification, Timeout};
439
440 Notification::new()
441 .summary("Ralph: Loop Complete")
442 .body(&format!(
443 "{} tasks completed ({} succeeded, {} failed)",
444 tasks_total, tasks_succeeded, tasks_failed
445 ))
446 .timeout(Timeout::Milliseconds(timeout_ms))
447 .show()
448 .map_err(|e| anyhow::anyhow!("Failed to show notification: {}", e))?;
449
450 Ok(())
451}
452
453#[cfg(not(feature = "notifications"))]
454fn show_notification_loop(
455 _tasks_total: usize,
456 _tasks_succeeded: usize,
457 _tasks_failed: usize,
458 _timeout_ms: u32,
459) -> anyhow::Result<()> {
460 log::debug!("Notifications feature not compiled in; skipping notification display");
461 Ok(())
462}
463
464pub fn play_completion_sound(custom_path: Option<&str>) -> anyhow::Result<()> {
466 #[cfg(target_os = "macos")]
467 {
468 play_macos_sound(custom_path)
469 }
470
471 #[cfg(target_os = "linux")]
472 {
473 play_linux_sound(custom_path)
474 }
475
476 #[cfg(target_os = "windows")]
477 {
478 play_windows_sound(custom_path)
479 }
480
481 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
482 {
483 log::debug!("Sound playback not supported on this platform");
484 Ok(())
485 }
486}
487
488#[cfg(target_os = "macos")]
489fn play_macos_sound(custom_path: Option<&str>) -> anyhow::Result<()> {
490 let sound_path = if let Some(path) = custom_path {
491 path.to_string()
492 } else {
493 "/System/Library/Sounds/Glass.aiff".to_string()
494 };
495
496 if !Path::new(&sound_path).exists() {
497 return Err(anyhow::anyhow!("Sound file not found: {}", sound_path));
498 }
499
500 let output = std::process::Command::new("afplay")
501 .arg(&sound_path)
502 .output()
503 .map_err(|e| anyhow::anyhow!("Failed to execute afplay: {}", e))?;
504
505 if !output.status.success() {
506 let stderr = String::from_utf8_lossy(&output.stderr);
507 return Err(anyhow::anyhow!("afplay failed: {}", stderr));
508 }
509
510 Ok(())
511}
512
513#[cfg(target_os = "linux")]
514fn play_linux_sound(custom_path: Option<&str>) -> anyhow::Result<()> {
515 if let Some(path) = custom_path {
516 if Path::new(path).exists() {
518 let result = std::process::Command::new("paplay").arg(path).output();
519 if let Ok(output) = result {
520 if output.status.success() {
521 return Ok(());
522 }
523 }
524
525 let output = std::process::Command::new("aplay")
527 .arg(path)
528 .output()
529 .map_err(|e| anyhow::anyhow!("Failed to execute aplay: {}", e))?;
530
531 if !output.status.success() {
532 let stderr = String::from_utf8_lossy(&output.stderr);
533 return Err(anyhow::anyhow!("aplay failed: {}", stderr));
534 }
535 return Ok(());
536 } else {
537 return Err(anyhow::anyhow!("Sound file not found: {}", path));
538 }
539 }
540
541 let result = std::process::Command::new("canberra-gtk-play")
543 .arg("--id=message")
544 .output();
545
546 if let Ok(output) = result {
547 if output.status.success() {
548 return Ok(());
549 }
550 }
551
552 log::debug!(
554 "Could not play default notification sound (canberra-gtk-play not available or failed)"
555 );
556 Ok(())
557}
558
559#[cfg(target_os = "windows")]
560fn play_windows_sound(custom_path: Option<&str>) -> anyhow::Result<()> {
561 if let Some(path) = custom_path {
562 let path_obj = Path::new(path);
563 if !path_obj.exists() {
564 return Err(anyhow::anyhow!("Sound file not found: {}", path));
565 }
566
567 if path.ends_with(".wav") || path.ends_with(".WAV") {
569 if let Ok(()) = play_sound_winmm(path) {
570 return Ok(());
571 }
572 }
573
574 return Err(anyhow::anyhow!(
575 "Windows custom notification sounds must be .wav files"
576 ));
577 }
578
579 Ok(())
581}
582
583#[cfg(target_os = "windows")]
584fn play_sound_winmm(path: &str) -> anyhow::Result<()> {
585 use std::os::windows::ffi::OsStrExt;
586 use windows_sys::Win32::Media::Audio::{PlaySoundW, SND_FILENAME, SND_SYNC};
587
588 let wide_path = Path::new(path)
589 .as_os_str()
590 .encode_wide()
591 .chain(std::iter::once(0))
592 .collect::<Vec<u16>>();
593
594 let result = unsafe {
597 PlaySoundW(
598 wide_path.as_ptr(),
599 std::ptr::null_mut(),
600 SND_FILENAME | SND_SYNC,
601 )
602 };
603
604 if result == 0 {
605 return Err(anyhow::anyhow!("PlaySoundW failed"));
606 }
607
608 Ok(())
609}
610
611#[cfg(test)]
612mod tests {
613 use super::*;
614
615 #[test]
616 fn notification_config_default_values() {
617 let config = NotificationConfig::new();
618 assert!(config.enabled);
619 assert!(config.notify_on_complete);
620 assert!(config.notify_on_fail);
621 assert!(config.notify_on_loop_complete);
622 assert!(config.suppress_when_active);
623 assert!(!config.sound_enabled);
624 assert!(config.sound_path.is_none());
625 assert_eq!(config.timeout_ms, 8000);
626 }
627
628 #[test]
629 fn notify_task_complete_disabled_does_nothing() {
630 let config = NotificationConfig {
631 enabled: false,
632 notify_on_complete: false,
633 notify_on_fail: false,
634 notify_on_loop_complete: false,
635 suppress_when_active: true,
636 sound_enabled: true,
637 sound_path: None,
638 timeout_ms: 8000,
639 };
640 notify_task_complete("RQ-0001", "Test task", &config);
642 }
643
644 #[test]
645 fn notification_config_can_be_customized() {
646 let config = NotificationConfig {
647 enabled: true,
648 notify_on_complete: true,
649 notify_on_fail: false,
650 notify_on_loop_complete: true,
651 suppress_when_active: false,
652 sound_enabled: true,
653 sound_path: Some("/path/to/sound.wav".to_string()),
654 timeout_ms: 5000,
655 };
656 assert!(config.enabled);
657 assert!(config.notify_on_complete);
658 assert!(!config.notify_on_fail);
659 assert!(config.notify_on_loop_complete);
660 assert!(!config.suppress_when_active);
661 assert!(config.sound_enabled);
662 assert_eq!(config.sound_path, Some("/path/to/sound.wav".to_string()));
663 assert_eq!(config.timeout_ms, 5000);
664 }
665
666 #[test]
667 fn build_notification_config_uses_defaults() {
668 let config = crate::contracts::NotificationConfig::default();
669 let overrides = NotificationOverrides::default();
670 let result = build_notification_config(&config, &overrides);
671
672 assert!(result.enabled);
673 assert!(result.notify_on_complete);
674 assert!(result.notify_on_fail);
675 assert!(result.notify_on_loop_complete);
676 assert!(result.suppress_when_active);
677 assert!(!result.sound_enabled);
678 assert!(result.sound_path.is_none());
679 assert_eq!(result.timeout_ms, 8000);
680 }
681
682 #[test]
683 fn build_notification_config_overrides_take_precedence() {
684 let config = crate::contracts::NotificationConfig {
685 notify_on_complete: Some(false),
686 notify_on_fail: Some(false),
687 sound_enabled: Some(false),
688 ..Default::default()
689 };
690 let overrides = NotificationOverrides {
691 notify_on_complete: Some(true),
692 notify_on_fail: Some(true),
693 notify_sound: Some(true),
694 };
695 let result = build_notification_config(&config, &overrides);
696
697 assert!(result.notify_on_complete); assert!(result.notify_on_fail); assert!(result.sound_enabled); }
701
702 #[test]
703 fn build_notification_config_config_used_when_no_override() {
704 let config = crate::contracts::NotificationConfig {
705 notify_on_complete: Some(false),
706 notify_on_fail: Some(true),
707 suppress_when_active: Some(false),
708 timeout_ms: Some(5000),
709 sound_path: Some("/path/to/sound.wav".to_string()),
710 ..Default::default()
711 };
712 let overrides = NotificationOverrides::default();
713 let result = build_notification_config(&config, &overrides);
714
715 assert!(!result.notify_on_complete); assert!(result.notify_on_fail); assert!(!result.suppress_when_active); assert_eq!(result.timeout_ms, 5000); assert_eq!(result.sound_path, Some("/path/to/sound.wav".to_string()));
720 }
721
722 #[test]
723 fn build_notification_config_enabled_computed_correctly() {
724 let config = crate::contracts::NotificationConfig {
726 notify_on_complete: Some(false),
727 notify_on_fail: Some(false),
728 notify_on_loop_complete: Some(false),
729 ..Default::default()
730 };
731 let overrides = NotificationOverrides::default();
732 let result = build_notification_config(&config, &overrides);
733 assert!(!result.enabled);
734
735 let config = crate::contracts::NotificationConfig {
737 notify_on_complete: Some(true),
738 notify_on_fail: Some(false),
739 notify_on_loop_complete: Some(false),
740 ..Default::default()
741 };
742 let result = build_notification_config(&config, &overrides);
743 assert!(result.enabled);
744 }
745
746 #[cfg(target_os = "windows")]
747 mod windows_tests {
748 use super::*;
749 use std::io::Write;
750 use tempfile::NamedTempFile;
751
752 #[test]
753 fn play_windows_sound_missing_file() {
754 let result = play_windows_sound(Some("/nonexistent/path/sound.wav"));
755 assert!(result.is_err());
756 assert!(result.unwrap_err().to_string().contains("not found"));
757 }
758
759 #[test]
760 fn play_windows_sound_none_path() {
761 let result = play_windows_sound(None);
763 assert!(result.is_ok());
764 }
765
766 #[test]
767 fn play_windows_sound_wav_file_exists() {
768 let mut temp_file = NamedTempFile::with_suffix(".wav").unwrap();
770 let wav_header: Vec<u8> = vec![
772 0x52, 0x49, 0x46, 0x46, 0x24, 0x00, 0x00, 0x00, 0x57, 0x41, 0x56, 0x45, 0x66, 0x6D, 0x74, 0x20, 0x10, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x44, 0xAC, 0x00, 0x00, 0x88, 0x58, 0x01, 0x00, 0x02, 0x00, 0x10, 0x00, 0x64, 0x61, 0x74, 0x61, 0x00, 0x00, 0x00, 0x00, ];
789 temp_file.write_all(&wav_header).unwrap();
790 temp_file.flush().unwrap();
791
792 let path = temp_file.path().to_str().unwrap();
793 if let Err(e) = play_windows_sound(Some(path)) {
796 log::debug!("Sound playback failed in test (expected in CI): {}", e);
797 }
798 }
799
800 #[test]
801 fn play_windows_sound_non_wav_is_rejected() {
802 let mut temp_file = NamedTempFile::with_suffix(".mp3").unwrap();
804 let mp3_header: Vec<u8> = vec![0xFF, 0xFB, 0x90, 0x00];
806 temp_file.write_all(&mp3_header).unwrap();
807 temp_file.flush().unwrap();
808
809 let path = temp_file.path().to_str().unwrap();
810 let err = play_windows_sound(Some(path)).unwrap_err();
811 assert!(err.to_string().contains(".wav"));
812 }
813 }
814}