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//! - Windows custom sounds are limited to `.wav` files played through WinMM.
20
21use std::path::Path;
22
23/// CLI overrides for notification settings.
24/// Fields are `Option<bool>` to distinguish "not set" from explicit false.
25#[derive(Debug, Clone, Default)]
26pub struct NotificationOverrides {
27    /// Override notify_on_complete from CLI.
28    pub notify_on_complete: Option<bool>,
29    /// Override notify_on_fail from CLI.
30    pub notify_on_fail: Option<bool>,
31    /// Override sound_enabled from CLI.
32    pub notify_sound: Option<bool>,
33}
34
35/// Build a runtime NotificationConfig from config and CLI overrides.
36///
37/// Precedence: CLI overrides > config values > defaults.
38///
39/// # Arguments
40/// * `config` - The notification config from resolved configuration
41/// * `overrides` - CLI overrides for notification settings
42///
43/// # Returns
44/// A fully-resolved NotificationConfig ready for use at runtime.
45pub 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    // enabled acts as a global on/off switch - true if ANY notification type is enabled
59    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/// Configuration for desktop notifications.
77#[derive(Debug, Clone, Default)]
78pub struct NotificationConfig {
79    /// Enable desktop notifications on task completion (legacy field).
80    pub enabled: bool,
81    /// Enable desktop notifications on task completion.
82    pub notify_on_complete: bool,
83    /// Enable desktop notifications on task failure.
84    pub notify_on_fail: bool,
85    /// Enable desktop notifications when loop mode completes.
86    pub notify_on_loop_complete: bool,
87    /// Suppress notifications when a foreground UI client is active.
88    pub suppress_when_active: bool,
89    /// Enable sound alerts with notifications.
90    pub sound_enabled: bool,
91    /// Custom sound file path (platform-specific format).
92    /// If not set, uses platform default sounds.
93    pub sound_path: Option<String>,
94    /// Notification timeout in milliseconds (default: 8000).
95    pub timeout_ms: u32,
96}
97
98impl NotificationConfig {
99    /// Create a new config with sensible defaults.
100    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    /// Check if notifications should be suppressed based on UI state.
114    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/// Types of notifications that can be sent.
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub enum NotificationType {
128    /// Task completed successfully.
129    TaskComplete,
130    /// Task failed.
131    TaskFailed,
132    /// Loop mode completed with summary.
133    LoopComplete {
134        tasks_total: usize,
135        tasks_succeeded: usize,
136        tasks_failed: usize,
137    },
138}
139
140/// Send a notification based on the notification type.
141/// Silently logs errors but never fails the calling operation.
142///
143/// # Arguments
144/// * `notification_type` - The type of notification to send
145/// * `task_id` - The task identifier (for task-specific notifications)
146/// * `task_title` - The task title (for task-specific notifications)
147/// * `config` - Notification configuration
148/// * `ui_active` - Whether a foreground UI client is currently active (for suppression)
149pub fn send_notification(
150    notification_type: NotificationType,
151    task_id: &str,
152    task_title: &str,
153    config: &NotificationConfig,
154    ui_active: bool,
155) {
156    // Check if this notification type is enabled
157    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    // Build and show notification
177    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    // Play sound if enabled
184    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
191/// Send task completion notification.
192/// Silently logs errors but never fails the calling operation.
193pub 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
203/// Send task completion notification with UI awareness.
204/// Silently logs errors but never fails the calling operation.
205pub 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
220/// Send task failure notification.
221/// Silently logs errors but never fails the calling operation.
222pub 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    // Build and show notification
239    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    // Play sound if enabled
244    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
251/// Send loop completion notification.
252/// Silently logs errors but never fails the calling operation.
253pub 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    // Build and show notification
270    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    // Play sound if enabled
280    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
287/// Send watch mode notification for newly detected tasks.
288/// Silently logs errors but never fails the calling operation.
289pub 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    // Build and show notification
301    if let Err(e) = show_notification_watch(count, config.timeout_ms) {
302        log::debug!("Failed to show watch notification: {}", e);
303    }
304
305    // Play sound if enabled
306    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    // Truncate error message to fit notification display
401    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
464/// Play completion sound using platform-specific mechanisms.
465pub 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        // Try paplay first (PulseAudio), fall back to aplay (ALSA)
517        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            // Fall back to aplay
526            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    // No custom path - try to play default notification sound via canberra-gtk-play
542    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    // If canberra-gtk-play fails or isn't available, that's okay - just log it
553    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        // Try winmm PlaySound first for .wav files
568        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    // No custom path - Windows toast notification handles default sound
580    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    // SAFETY: PlaySoundW accepts a valid null-terminated UTF-16 file path pointer and flags.
595    // The pointer remains valid for the duration of the synchronous call.
596    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        // Should not panic or fail
641        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); // override wins
698        assert!(result.notify_on_fail); // override wins
699        assert!(result.sound_enabled); // override wins
700    }
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); // from config
716        assert!(result.notify_on_fail); // from config
717        assert!(!result.suppress_when_active); // from config
718        assert_eq!(result.timeout_ms, 5000); // from config
719        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        // If all notification types are disabled, enabled should be false
725        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        // If any notification type is enabled, enabled should be true
736        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            // Should succeed (no custom sound requested)
762            let result = play_windows_sound(None);
763            assert!(result.is_ok());
764        }
765
766        #[test]
767        fn play_windows_sound_wav_file_exists() {
768            // Create a minimal valid WAV file header
769            let mut temp_file = NamedTempFile::with_suffix(".wav").unwrap();
770            // RIFF WAV header (44 bytes minimum)
771            let wav_header: Vec<u8> = vec![
772                // RIFF chunk
773                0x52, 0x49, 0x46, 0x46, // "RIFF"
774                0x24, 0x00, 0x00, 0x00, // file size - 8
775                0x57, 0x41, 0x56, 0x45, // "WAVE"
776                // fmt chunk
777                0x66, 0x6D, 0x74, 0x20, // "fmt "
778                0x10, 0x00, 0x00, 0x00, // chunk size (16)
779                0x01, 0x00, // audio format (PCM)
780                0x01, 0x00, // num channels (1)
781                0x44, 0xAC, 0x00, 0x00, // sample rate (44100)
782                0x88, 0x58, 0x01, 0x00, // byte rate
783                0x02, 0x00, // block align
784                0x10, 0x00, // bits per sample (16)
785                // data chunk
786                0x64, 0x61, 0x74, 0x61, // "data"
787                0x00, 0x00, 0x00, 0x00, // data size
788            ];
789            temp_file.write_all(&wav_header).unwrap();
790            temp_file.flush().unwrap();
791
792            let path = temp_file.path().to_str().unwrap();
793            // Should not error on file existence check
794            // Actual playback may fail in CI without audio subsystem
795            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            // Create a dummy mp3 file (just a header, not a real mp3)
803            let mut temp_file = NamedTempFile::with_suffix(".mp3").unwrap();
804            // MP3 sync word (not a full valid header, but enough for path validation)
805            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}