Skip to main content

ralph/
notification.rs

1//! Desktop notification system for task completion and failures.
2//!
3//! Responsibilities:
4//! - Send cross-platform desktop notifications via notify-rust.
5//! - Play optional sound alerts using platform-specific mechanisms.
6//! - Provide graceful degradation when notification systems are unavailable.
7//! - Support different notification types: task success, task failure, loop completion.
8//!
9//! Does NOT handle:
10//! - Notification scheduling or queuing (callers trigger explicitly).
11//! - Persistent notification history or logging.
12//! - UI mode detection (callers should suppress if desired).
13//! - Do Not Disturb detection (handled at call site if needed).
14//!
15//! Invariants:
16//! - Sound playback failures don't fail the notification.
17//! - Notification failures are logged but don't fail the calling operation.
18//! - All platform-specific code is isolated per target OS.
19
20use std::path::Path;
21
22/// CLI overrides for notification settings.
23/// Fields are `Option<bool>` to distinguish "not set" from explicit false.
24#[derive(Debug, Clone, Default)]
25pub struct NotificationOverrides {
26    /// Override notify_on_complete from CLI.
27    pub notify_on_complete: Option<bool>,
28    /// Override notify_on_fail from CLI.
29    pub notify_on_fail: Option<bool>,
30    /// Override sound_enabled from CLI.
31    pub notify_sound: Option<bool>,
32}
33
34/// Build a runtime NotificationConfig from config and CLI overrides.
35///
36/// Precedence: CLI overrides > config values > defaults.
37///
38/// # Arguments
39/// * `config` - The notification config from resolved configuration
40/// * `overrides` - CLI overrides for notification settings
41///
42/// # Returns
43/// A fully-resolved NotificationConfig ready for use at runtime.
44pub 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    // enabled acts as a global on/off switch - true if ANY notification type is enabled
58    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/// Configuration for desktop notifications.
76#[derive(Debug, Clone, Default)]
77pub struct NotificationConfig {
78    /// Enable desktop notifications on task completion (legacy field).
79    pub enabled: bool,
80    /// Enable desktop notifications on task completion.
81    pub notify_on_complete: bool,
82    /// Enable desktop notifications on task failure.
83    pub notify_on_fail: bool,
84    /// Enable desktop notifications when loop mode completes.
85    pub notify_on_loop_complete: bool,
86    /// Suppress notifications when a foreground UI client is active.
87    pub suppress_when_active: bool,
88    /// Enable sound alerts with notifications.
89    pub sound_enabled: bool,
90    /// Custom sound file path (platform-specific format).
91    /// If not set, uses platform default sounds.
92    pub sound_path: Option<String>,
93    /// Notification timeout in milliseconds (default: 8000).
94    pub timeout_ms: u32,
95}
96
97impl NotificationConfig {
98    /// Create a new config with sensible defaults.
99    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    /// Check if notifications should be suppressed based on UI state.
113    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/// Types of notifications that can be sent.
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub enum NotificationType {
127    /// Task completed successfully.
128    TaskComplete,
129    /// Task failed.
130    TaskFailed,
131    /// Loop mode completed with summary.
132    LoopComplete {
133        tasks_total: usize,
134        tasks_succeeded: usize,
135        tasks_failed: usize,
136    },
137}
138
139/// Send a notification based on the notification type.
140/// Silently logs errors but never fails the calling operation.
141///
142/// # Arguments
143/// * `notification_type` - The type of notification to send
144/// * `task_id` - The task identifier (for task-specific notifications)
145/// * `task_title` - The task title (for task-specific notifications)
146/// * `config` - Notification configuration
147/// * `ui_active` - Whether a foreground UI client is currently active (for suppression)
148pub fn send_notification(
149    notification_type: NotificationType,
150    task_id: &str,
151    task_title: &str,
152    config: &NotificationConfig,
153    ui_active: bool,
154) {
155    // Check if this notification type is enabled
156    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    // Build and show notification
176    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    // Play sound if enabled
183    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
190/// Send task completion notification.
191/// Silently logs errors but never fails the calling operation.
192pub 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
202/// Send task completion notification with UI awareness.
203/// Silently logs errors but never fails the calling operation.
204pub 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
219/// Send task failure notification.
220/// Silently logs errors but never fails the calling operation.
221pub 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    // Build and show notification
238    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    // Play sound if enabled
243    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
250/// Send loop completion notification.
251/// Silently logs errors but never fails the calling operation.
252pub 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    // Build and show notification
269    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    // Play sound if enabled
279    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
286/// Send watch mode notification for newly detected tasks.
287/// Silently logs errors but never fails the calling operation.
288pub 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    // Build and show notification
300    if let Err(e) = show_notification_watch(count, config.timeout_ms) {
301        log::debug!("Failed to show watch notification: {}", e);
302    }
303
304    // Play sound if enabled
305    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    // Truncate error message to fit notification display
400    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
463/// Play completion sound using platform-specific mechanisms.
464pub 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        // Try paplay first (PulseAudio), fall back to aplay (ALSA)
516        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            // Fall back to aplay
525            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    // No custom path - try to play default notification sound via canberra-gtk-play
541    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    // If canberra-gtk-play fails or isn't available, that's okay - just log it
552    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        // Try winmm PlaySound first for .wav files
567        if path.ends_with(".wav") || path.ends_with(".WAV") {
568            if let Ok(()) = play_sound_winmm(path) {
569                return Ok(());
570            }
571        }
572
573        // Fall back to PowerShell MediaPlayer for other formats or if winmm fails
574        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    // No custom path - Windows toast notification handles default sound
584    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    // SAFETY: PlaySoundA is a Windows API that accepts a valid null-terminated C string
595    // pointer (c_path.as_ptr()) and flags. The SND_FILENAME flag tells it to treat the
596    // pointer as a file path. The pointer is valid for the duration of the call.
597    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        // Should not panic or fail
666        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); // override wins
723        assert!(result.notify_on_fail); // override wins
724        assert!(result.sound_enabled); // override wins
725    }
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); // from config
741        assert!(result.notify_on_fail); // from config
742        assert!(!result.suppress_when_active); // from config
743        assert_eq!(result.timeout_ms, 5000); // from config
744        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        // If all notification types are disabled, enabled should be false
750        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        // If any notification type is enabled, enabled should be true
761        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            // Should succeed (no custom sound requested)
787            let result = play_windows_sound(None);
788            assert!(result.is_ok());
789        }
790
791        #[test]
792        fn play_windows_sound_wav_file_exists() {
793            // Create a minimal valid WAV file header
794            let mut temp_file = NamedTempFile::with_suffix(".wav").unwrap();
795            // RIFF WAV header (44 bytes minimum)
796            let wav_header: Vec<u8> = vec![
797                // RIFF chunk
798                0x52, 0x49, 0x46, 0x46, // "RIFF"
799                0x24, 0x00, 0x00, 0x00, // file size - 8
800                0x57, 0x41, 0x56, 0x45, // "WAVE"
801                // fmt chunk
802                0x66, 0x6D, 0x74, 0x20, // "fmt "
803                0x10, 0x00, 0x00, 0x00, // chunk size (16)
804                0x01, 0x00, // audio format (PCM)
805                0x01, 0x00, // num channels (1)
806                0x44, 0xAC, 0x00, 0x00, // sample rate (44100)
807                0x88, 0x58, 0x01, 0x00, // byte rate
808                0x02, 0x00, // block align
809                0x10, 0x00, // bits per sample (16)
810                // data chunk
811                0x64, 0x61, 0x74, 0x61, // "data"
812                0x00, 0x00, 0x00, 0x00, // data size
813            ];
814            temp_file.write_all(&wav_header).unwrap();
815            temp_file.flush().unwrap();
816
817            let path = temp_file.path().to_str().unwrap();
818            // Should not error on file existence check
819            // Actual playback may fail in CI without audio subsystem
820            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            // Create a dummy mp3 file (just a header, not a real mp3)
828            let mut temp_file = NamedTempFile::with_suffix(".mp3").unwrap();
829            // MP3 sync word (not a full valid header, but enough for path validation)
830            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            // Should attempt PowerShell fallback for non-WAV files
836            // Result depends on whether PowerShell is available
837            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}