1use std::path::Path;
21
22#[derive(Debug, Clone, Default)]
25pub struct NotificationOverrides {
26 pub notify_on_complete: Option<bool>,
28 pub notify_on_fail: Option<bool>,
30 pub notify_sound: Option<bool>,
32}
33
34pub fn build_notification_config(
45 config: &crate::contracts::NotificationConfig,
46 overrides: &NotificationOverrides,
47) -> NotificationConfig {
48 let notify_on_complete = overrides
49 .notify_on_complete
50 .or(config.notify_on_complete)
51 .unwrap_or(true);
52 let notify_on_fail = overrides
53 .notify_on_fail
54 .or(config.notify_on_fail)
55 .unwrap_or(true);
56 let notify_on_loop_complete = config.notify_on_loop_complete.unwrap_or(true);
57 let enabled = notify_on_complete || notify_on_fail || notify_on_loop_complete;
59
60 NotificationConfig {
61 enabled,
62 notify_on_complete,
63 notify_on_fail,
64 notify_on_loop_complete,
65 suppress_when_active: config.suppress_when_active.unwrap_or(true),
66 sound_enabled: overrides
67 .notify_sound
68 .or(config.sound_enabled)
69 .unwrap_or(false),
70 sound_path: config.sound_path.clone(),
71 timeout_ms: config.timeout_ms.unwrap_or(8000),
72 }
73}
74
75#[derive(Debug, Clone, Default)]
77pub struct NotificationConfig {
78 pub enabled: bool,
80 pub notify_on_complete: bool,
82 pub notify_on_fail: bool,
84 pub notify_on_loop_complete: bool,
86 pub suppress_when_active: bool,
88 pub sound_enabled: bool,
90 pub sound_path: Option<String>,
93 pub timeout_ms: u32,
95}
96
97impl NotificationConfig {
98 pub fn new() -> Self {
100 Self {
101 enabled: true,
102 notify_on_complete: true,
103 notify_on_fail: true,
104 notify_on_loop_complete: true,
105 suppress_when_active: true,
106 sound_enabled: false,
107 sound_path: None,
108 timeout_ms: 8000,
109 }
110 }
111
112 pub fn should_suppress(&self, ui_active: bool) -> bool {
114 if !self.enabled {
115 return true;
116 }
117 if ui_active && self.suppress_when_active {
118 return true;
119 }
120 false
121 }
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub enum NotificationType {
127 TaskComplete,
129 TaskFailed,
131 LoopComplete {
133 tasks_total: usize,
134 tasks_succeeded: usize,
135 tasks_failed: usize,
136 },
137}
138
139pub fn send_notification(
149 notification_type: NotificationType,
150 task_id: &str,
151 task_title: &str,
152 config: &NotificationConfig,
153 ui_active: bool,
154) {
155 let type_enabled = match notification_type {
157 NotificationType::TaskComplete => config.notify_on_complete,
158 NotificationType::TaskFailed => config.notify_on_fail,
159 NotificationType::LoopComplete { .. } => config.notify_on_loop_complete,
160 };
161
162 if !type_enabled {
163 log::debug!(
164 "Notification type {:?} disabled; skipping",
165 notification_type
166 );
167 return;
168 }
169
170 if config.should_suppress(ui_active) {
171 log::debug!("Notifications suppressed (UI active or globally disabled)");
172 return;
173 }
174
175 if let Err(e) =
177 show_notification_typed(notification_type, task_id, task_title, config.timeout_ms)
178 {
179 log::debug!("Failed to show notification: {}", e);
180 }
181
182 if config.sound_enabled
184 && let Err(e) = play_completion_sound(config.sound_path.as_deref())
185 {
186 log::debug!("Failed to play sound: {}", e);
187 }
188}
189
190pub fn notify_task_complete(task_id: &str, task_title: &str, config: &NotificationConfig) {
193 send_notification(
194 NotificationType::TaskComplete,
195 task_id,
196 task_title,
197 config,
198 false,
199 );
200}
201
202pub fn notify_task_complete_with_context(
205 task_id: &str,
206 task_title: &str,
207 config: &NotificationConfig,
208 ui_active: bool,
209) {
210 send_notification(
211 NotificationType::TaskComplete,
212 task_id,
213 task_title,
214 config,
215 ui_active,
216 );
217}
218
219pub fn notify_task_failed(
222 task_id: &str,
223 task_title: &str,
224 error: &str,
225 config: &NotificationConfig,
226) {
227 if !config.notify_on_fail {
228 log::debug!("Failure notifications disabled; skipping");
229 return;
230 }
231
232 if config.should_suppress(false) {
233 log::debug!("Notifications suppressed (globally disabled)");
234 return;
235 }
236
237 if let Err(e) = show_notification_failure(task_id, task_title, error, config.timeout_ms) {
239 log::debug!("Failed to show failure notification: {}", e);
240 }
241
242 if config.sound_enabled
244 && let Err(e) = play_completion_sound(config.sound_path.as_deref())
245 {
246 log::debug!("Failed to play sound: {}", e);
247 }
248}
249
250pub fn notify_loop_complete(
253 tasks_total: usize,
254 tasks_succeeded: usize,
255 tasks_failed: usize,
256 config: &NotificationConfig,
257) {
258 if !config.notify_on_loop_complete {
259 log::debug!("Loop completion notifications disabled; skipping");
260 return;
261 }
262
263 if config.should_suppress(false) {
264 log::debug!("Notifications suppressed (globally disabled)");
265 return;
266 }
267
268 if let Err(e) = show_notification_loop(
270 tasks_total,
271 tasks_succeeded,
272 tasks_failed,
273 config.timeout_ms,
274 ) {
275 log::debug!("Failed to show loop notification: {}", e);
276 }
277
278 if config.sound_enabled
280 && let Err(e) = play_completion_sound(config.sound_path.as_deref())
281 {
282 log::debug!("Failed to play sound: {}", e);
283 }
284}
285
286pub fn notify_watch_new_task(count: usize, config: &NotificationConfig) {
289 if !config.enabled {
290 log::debug!("Notifications disabled; skipping");
291 return;
292 }
293
294 if config.should_suppress(false) {
295 log::debug!("Notifications suppressed (globally disabled)");
296 return;
297 }
298
299 if let Err(e) = show_notification_watch(count, config.timeout_ms) {
301 log::debug!("Failed to show watch notification: {}", e);
302 }
303
304 if config.sound_enabled
306 && let Err(e) = play_completion_sound(config.sound_path.as_deref())
307 {
308 log::debug!("Failed to play sound: {}", e);
309 }
310}
311
312#[cfg(feature = "notifications")]
313fn show_notification_watch(count: usize, timeout_ms: u32) -> anyhow::Result<()> {
314 use notify_rust::{Notification, Timeout};
315
316 let body = if count == 1 {
317 "1 new task detected from code comments".to_string()
318 } else {
319 format!("{} new tasks detected from code comments", count)
320 };
321
322 Notification::new()
323 .summary("Ralph: Watch Mode")
324 .body(&body)
325 .timeout(Timeout::Milliseconds(timeout_ms))
326 .show()
327 .map_err(|e| anyhow::anyhow!("Failed to show notification: {}", e))?;
328
329 Ok(())
330}
331
332#[cfg(not(feature = "notifications"))]
333fn show_notification_watch(_count: usize, _timeout_ms: u32) -> anyhow::Result<()> {
334 log::debug!("Notifications feature not compiled in; skipping notification display");
335 Ok(())
336}
337
338#[cfg(feature = "notifications")]
339fn show_notification_typed(
340 notification_type: NotificationType,
341 task_id: &str,
342 task_title: &str,
343 timeout_ms: u32,
344) -> anyhow::Result<()> {
345 use notify_rust::{Notification, Timeout};
346
347 let (summary, body) = match notification_type {
348 NotificationType::TaskComplete => (
349 "Ralph: Task Complete",
350 format!("{} - {}", task_id, task_title),
351 ),
352 NotificationType::TaskFailed => (
353 "Ralph: Task Failed",
354 format!("{} - {}", task_id, task_title),
355 ),
356 NotificationType::LoopComplete {
357 tasks_total,
358 tasks_succeeded,
359 tasks_failed,
360 } => (
361 "Ralph: Loop Complete",
362 format!(
363 "{} tasks completed ({} succeeded, {} failed)",
364 tasks_total, tasks_succeeded, tasks_failed
365 ),
366 ),
367 };
368
369 Notification::new()
370 .summary(summary)
371 .body(&body)
372 .timeout(Timeout::Milliseconds(timeout_ms))
373 .show()
374 .map_err(|e| anyhow::anyhow!("Failed to show notification: {}", e))?;
375
376 Ok(())
377}
378
379#[cfg(not(feature = "notifications"))]
380fn show_notification_typed(
381 _notification_type: NotificationType,
382 _task_id: &str,
383 _task_title: &str,
384 _timeout_ms: u32,
385) -> anyhow::Result<()> {
386 log::debug!("Notifications feature not compiled in; skipping notification display");
387 Ok(())
388}
389
390#[cfg(feature = "notifications")]
391fn show_notification_failure(
392 task_id: &str,
393 task_title: &str,
394 error: &str,
395 timeout_ms: u32,
396) -> anyhow::Result<()> {
397 use notify_rust::{Notification, Timeout};
398
399 let error_summary = if error.len() > 100 {
401 format!("{}...", &error[..97])
402 } else {
403 error.to_string()
404 };
405
406 Notification::new()
407 .summary("Ralph: Task Failed")
408 .body(&format!(
409 "{} - {}\nError: {}",
410 task_id, task_title, error_summary
411 ))
412 .timeout(Timeout::Milliseconds(timeout_ms))
413 .show()
414 .map_err(|e| anyhow::anyhow!("Failed to show notification: {}", e))?;
415
416 Ok(())
417}
418
419#[cfg(not(feature = "notifications"))]
420fn show_notification_failure(
421 _task_id: &str,
422 _task_title: &str,
423 _error: &str,
424 _timeout_ms: u32,
425) -> anyhow::Result<()> {
426 log::debug!("Notifications feature not compiled in; skipping notification display");
427 Ok(())
428}
429
430#[cfg(feature = "notifications")]
431fn show_notification_loop(
432 tasks_total: usize,
433 tasks_succeeded: usize,
434 tasks_failed: usize,
435 timeout_ms: u32,
436) -> anyhow::Result<()> {
437 use notify_rust::{Notification, Timeout};
438
439 Notification::new()
440 .summary("Ralph: Loop Complete")
441 .body(&format!(
442 "{} tasks completed ({} succeeded, {} failed)",
443 tasks_total, tasks_succeeded, tasks_failed
444 ))
445 .timeout(Timeout::Milliseconds(timeout_ms))
446 .show()
447 .map_err(|e| anyhow::anyhow!("Failed to show notification: {}", e))?;
448
449 Ok(())
450}
451
452#[cfg(not(feature = "notifications"))]
453fn show_notification_loop(
454 _tasks_total: usize,
455 _tasks_succeeded: usize,
456 _tasks_failed: usize,
457 _timeout_ms: u32,
458) -> anyhow::Result<()> {
459 log::debug!("Notifications feature not compiled in; skipping notification display");
460 Ok(())
461}
462
463pub fn play_completion_sound(custom_path: Option<&str>) -> anyhow::Result<()> {
465 #[cfg(target_os = "macos")]
466 {
467 play_macos_sound(custom_path)
468 }
469
470 #[cfg(target_os = "linux")]
471 {
472 play_linux_sound(custom_path)
473 }
474
475 #[cfg(target_os = "windows")]
476 {
477 play_windows_sound(custom_path)
478 }
479
480 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
481 {
482 log::debug!("Sound playback not supported on this platform");
483 Ok(())
484 }
485}
486
487#[cfg(target_os = "macos")]
488fn play_macos_sound(custom_path: Option<&str>) -> anyhow::Result<()> {
489 let sound_path = if let Some(path) = custom_path {
490 path.to_string()
491 } else {
492 "/System/Library/Sounds/Glass.aiff".to_string()
493 };
494
495 if !Path::new(&sound_path).exists() {
496 return Err(anyhow::anyhow!("Sound file not found: {}", sound_path));
497 }
498
499 let output = std::process::Command::new("afplay")
500 .arg(&sound_path)
501 .output()
502 .map_err(|e| anyhow::anyhow!("Failed to execute afplay: {}", e))?;
503
504 if !output.status.success() {
505 let stderr = String::from_utf8_lossy(&output.stderr);
506 return Err(anyhow::anyhow!("afplay failed: {}", stderr));
507 }
508
509 Ok(())
510}
511
512#[cfg(target_os = "linux")]
513fn play_linux_sound(custom_path: Option<&str>) -> anyhow::Result<()> {
514 if let Some(path) = custom_path {
515 if Path::new(path).exists() {
517 let result = std::process::Command::new("paplay").arg(path).output();
518 if let Ok(output) = result {
519 if output.status.success() {
520 return Ok(());
521 }
522 }
523
524 let output = std::process::Command::new("aplay")
526 .arg(path)
527 .output()
528 .map_err(|e| anyhow::anyhow!("Failed to execute aplay: {}", e))?;
529
530 if !output.status.success() {
531 let stderr = String::from_utf8_lossy(&output.stderr);
532 return Err(anyhow::anyhow!("aplay failed: {}", stderr));
533 }
534 return Ok(());
535 } else {
536 return Err(anyhow::anyhow!("Sound file not found: {}", path));
537 }
538 }
539
540 let result = std::process::Command::new("canberra-gtk-play")
542 .arg("--id=message")
543 .output();
544
545 if let Ok(output) = result {
546 if output.status.success() {
547 return Ok(());
548 }
549 }
550
551 log::debug!(
553 "Could not play default notification sound (canberra-gtk-play not available or failed)"
554 );
555 Ok(())
556}
557
558#[cfg(target_os = "windows")]
559fn play_windows_sound(custom_path: Option<&str>) -> anyhow::Result<()> {
560 if let Some(path) = custom_path {
561 let path_obj = Path::new(path);
562 if !path_obj.exists() {
563 return Err(anyhow::anyhow!("Sound file not found: {}", path));
564 }
565
566 if path.ends_with(".wav") || path.ends_with(".WAV") {
568 if let Ok(()) = play_sound_winmm(path) {
569 return Ok(());
570 }
571 }
572
573 if let Ok(()) = play_sound_powershell(path) {
575 return Ok(());
576 }
577
578 return Err(anyhow::anyhow!(
579 "Failed to play sound with all available methods"
580 ));
581 }
582
583 Ok(())
585}
586
587#[cfg(target_os = "windows")]
588fn play_sound_winmm(path: &str) -> anyhow::Result<()> {
589 use std::ffi::CString;
590 use windows_sys::Win32::Media::Audio::{PlaySoundA, SND_FILENAME, SND_SYNC};
591
592 let c_path = CString::new(path).map_err(|e| anyhow::anyhow!("Invalid path encoding: {}", e))?;
593
594 let result = unsafe {
598 PlaySoundA(
599 c_path.as_ptr(),
600 std::ptr::null_mut(),
601 SND_FILENAME | SND_SYNC,
602 )
603 };
604
605 if result == 0 {
606 return Err(anyhow::anyhow!("PlaySoundA failed"));
607 }
608
609 Ok(())
610}
611
612#[cfg(target_os = "windows")]
613fn play_sound_powershell(path: &str) -> anyhow::Result<()> {
614 let script = format!(
615 "$player = New-Object System.Media.SoundPlayer '{}'; $player.PlaySync()",
616 path.replace('\'', "''")
617 );
618
619 let output = std::process::Command::new("powershell.exe")
620 .arg("-Command")
621 .arg(&script)
622 .output()
623 .map_err(|e| anyhow::anyhow!("Failed to execute PowerShell: {}", e))?;
624
625 if !output.status.success() {
626 let stderr = String::from_utf8_lossy(&output.stderr);
627 return Err(anyhow::anyhow!(
628 "PowerShell sound playback failed: {}",
629 stderr
630 ));
631 }
632
633 Ok(())
634}
635
636#[cfg(test)]
637mod tests {
638 use super::*;
639
640 #[test]
641 fn notification_config_default_values() {
642 let config = NotificationConfig::new();
643 assert!(config.enabled);
644 assert!(config.notify_on_complete);
645 assert!(config.notify_on_fail);
646 assert!(config.notify_on_loop_complete);
647 assert!(config.suppress_when_active);
648 assert!(!config.sound_enabled);
649 assert!(config.sound_path.is_none());
650 assert_eq!(config.timeout_ms, 8000);
651 }
652
653 #[test]
654 fn notify_task_complete_disabled_does_nothing() {
655 let config = NotificationConfig {
656 enabled: false,
657 notify_on_complete: false,
658 notify_on_fail: false,
659 notify_on_loop_complete: false,
660 suppress_when_active: true,
661 sound_enabled: true,
662 sound_path: None,
663 timeout_ms: 8000,
664 };
665 notify_task_complete("RQ-0001", "Test task", &config);
667 }
668
669 #[test]
670 fn notification_config_can_be_customized() {
671 let config = NotificationConfig {
672 enabled: true,
673 notify_on_complete: true,
674 notify_on_fail: false,
675 notify_on_loop_complete: true,
676 suppress_when_active: false,
677 sound_enabled: true,
678 sound_path: Some("/path/to/sound.wav".to_string()),
679 timeout_ms: 5000,
680 };
681 assert!(config.enabled);
682 assert!(config.notify_on_complete);
683 assert!(!config.notify_on_fail);
684 assert!(config.notify_on_loop_complete);
685 assert!(!config.suppress_when_active);
686 assert!(config.sound_enabled);
687 assert_eq!(config.sound_path, Some("/path/to/sound.wav".to_string()));
688 assert_eq!(config.timeout_ms, 5000);
689 }
690
691 #[test]
692 fn build_notification_config_uses_defaults() {
693 let config = crate::contracts::NotificationConfig::default();
694 let overrides = NotificationOverrides::default();
695 let result = build_notification_config(&config, &overrides);
696
697 assert!(result.enabled);
698 assert!(result.notify_on_complete);
699 assert!(result.notify_on_fail);
700 assert!(result.notify_on_loop_complete);
701 assert!(result.suppress_when_active);
702 assert!(!result.sound_enabled);
703 assert!(result.sound_path.is_none());
704 assert_eq!(result.timeout_ms, 8000);
705 }
706
707 #[test]
708 fn build_notification_config_overrides_take_precedence() {
709 let config = crate::contracts::NotificationConfig {
710 notify_on_complete: Some(false),
711 notify_on_fail: Some(false),
712 sound_enabled: Some(false),
713 ..Default::default()
714 };
715 let overrides = NotificationOverrides {
716 notify_on_complete: Some(true),
717 notify_on_fail: Some(true),
718 notify_sound: Some(true),
719 };
720 let result = build_notification_config(&config, &overrides);
721
722 assert!(result.notify_on_complete); assert!(result.notify_on_fail); assert!(result.sound_enabled); }
726
727 #[test]
728 fn build_notification_config_config_used_when_no_override() {
729 let config = crate::contracts::NotificationConfig {
730 notify_on_complete: Some(false),
731 notify_on_fail: Some(true),
732 suppress_when_active: Some(false),
733 timeout_ms: Some(5000),
734 sound_path: Some("/path/to/sound.wav".to_string()),
735 ..Default::default()
736 };
737 let overrides = NotificationOverrides::default();
738 let result = build_notification_config(&config, &overrides);
739
740 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()));
745 }
746
747 #[test]
748 fn build_notification_config_enabled_computed_correctly() {
749 let config = crate::contracts::NotificationConfig {
751 notify_on_complete: Some(false),
752 notify_on_fail: Some(false),
753 notify_on_loop_complete: Some(false),
754 ..Default::default()
755 };
756 let overrides = NotificationOverrides::default();
757 let result = build_notification_config(&config, &overrides);
758 assert!(!result.enabled);
759
760 let config = crate::contracts::NotificationConfig {
762 notify_on_complete: Some(true),
763 notify_on_fail: Some(false),
764 notify_on_loop_complete: Some(false),
765 ..Default::default()
766 };
767 let result = build_notification_config(&config, &overrides);
768 assert!(result.enabled);
769 }
770
771 #[cfg(target_os = "windows")]
772 mod windows_tests {
773 use super::*;
774 use std::io::Write;
775 use tempfile::NamedTempFile;
776
777 #[test]
778 fn play_windows_sound_missing_file() {
779 let result = play_windows_sound(Some("/nonexistent/path/sound.wav"));
780 assert!(result.is_err());
781 assert!(result.unwrap_err().to_string().contains("not found"));
782 }
783
784 #[test]
785 fn play_windows_sound_none_path() {
786 let result = play_windows_sound(None);
788 assert!(result.is_ok());
789 }
790
791 #[test]
792 fn play_windows_sound_wav_file_exists() {
793 let mut temp_file = NamedTempFile::with_suffix(".wav").unwrap();
795 let wav_header: Vec<u8> = vec![
797 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, ];
814 temp_file.write_all(&wav_header).unwrap();
815 temp_file.flush().unwrap();
816
817 let path = temp_file.path().to_str().unwrap();
818 if let Err(e) = play_windows_sound(Some(path)) {
821 log::debug!("Sound playback failed in test (expected in CI): {}", e);
822 }
823 }
824
825 #[test]
826 fn play_windows_sound_non_wav_uses_powershell() {
827 let mut temp_file = NamedTempFile::with_suffix(".mp3").unwrap();
829 let mp3_header: Vec<u8> = vec![0xFF, 0xFB, 0x90, 0x00];
831 temp_file.write_all(&mp3_header).unwrap();
832 temp_file.flush().unwrap();
833
834 let path = temp_file.path().to_str().unwrap();
835 if let Err(e) = play_windows_sound(Some(path)) {
838 log::debug!("Sound playback failed in test (expected in CI): {}", e);
839 }
840 }
841 }
842}