stax 0.96.3

Fast stacked Git branches and PRs
Documentation
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};

const SUCCESS_ALERT_WAV: &[u8] = include_bytes!("../assets/ci-alert-success.wav");
const ERROR_ALERT_WAV: &[u8] = include_bytes!("../assets/ci-alert-error.wav");

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BuiltInSound {
    Success,
    Error,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Sound {
    BuiltIn(BuiltInSound),
    Path(PathBuf),
}

pub fn play_sound(sound: &Sound) -> Result<(), String> {
    match sound {
        Sound::BuiltIn(kind) => {
            let file = write_built_in_sound(*kind)?;
            play_sound_file(file.path())
        }
        Sound::Path(path) => {
            if !path.exists() {
                return Err(format!("sound file does not exist: {}", path.display()));
            }

            play_sound_file(path)
        }
    }
}

fn write_built_in_sound(kind: BuiltInSound) -> Result<tempfile::NamedTempFile, String> {
    let (prefix, bytes) = match kind {
        BuiltInSound::Success => ("stax-ci-alert-success-", SUCCESS_ALERT_WAV),
        BuiltInSound::Error => ("stax-ci-alert-error-", ERROR_ALERT_WAV),
    };

    let mut file = tempfile::Builder::new()
        .prefix(prefix)
        .suffix(".wav")
        .tempfile()
        .map_err(|err| format!("could not create built-in CI alert sound file: {err}"))?;
    file.write_all(bytes).map_err(|err| {
        format!(
            "could not write built-in CI alert sound to {}: {}",
            file.path().display(),
            err
        )
    })?;
    Ok(file)
}

#[allow(clippy::needless_return)]
fn play_sound_file(path: &Path) -> Result<(), String> {
    #[cfg(target_os = "macos")]
    {
        return run_status(Command::new("afplay").arg(path)).map_err(|err| err.to_string());
    }

    #[cfg(target_os = "linux")]
    {
        for program in ["paplay", "pw-play", "aplay", "ffplay"] {
            let mut command = Command::new(program);
            if program == "ffplay" {
                command.args(["-nodisp", "-autoexit", "-loglevel", "quiet"]);
            }
            command.arg(path);

            match run_status(&mut command) {
                Ok(()) => return Ok(()),
                Err(SoundCommandError::NotFound) => continue,
                Err(SoundCommandError::Failed(_)) => continue,
            }
        }

        return Err(
            "no supported audio player found (tried paplay, pw-play, aplay, ffplay)".to_string(),
        );
    }

    #[cfg(target_os = "windows")]
    {
        let path = path
            .canonicalize()
            .unwrap_or_else(|_| path.to_path_buf())
            .display()
            .to_string()
            .replace('\'', "''");
        let script = format!(
            "Add-Type -AssemblyName PresentationCore; \
             $p = New-Object System.Windows.Media.MediaPlayer; \
             $p.Open([Uri]::new('{path}')); \
             while (-not $p.NaturalDuration.HasTimeSpan) {{ Start-Sleep -Milliseconds 50 }}; \
             $ms = [Math]::Ceiling($p.NaturalDuration.TimeSpan.TotalMilliseconds) + 100; \
             $p.Play(); \
             Start-Sleep -Milliseconds $ms;"
        );
        let mut command = Command::new("powershell");
        command.args([
            "-NoProfile",
            "-ExecutionPolicy",
            "Bypass",
            "-Command",
            &script,
        ]);
        return run_status(&mut command).map_err(|err| err.to_string());
    }

    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
    {
        let _ = path;
        Err("CI alert sounds are not supported on this platform".to_string())
    }
}

enum SoundCommandError {
    NotFound,
    Failed(String),
}

impl std::fmt::Display for SoundCommandError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::NotFound => write!(f, "audio player command was not found"),
            Self::Failed(message) => write!(f, "{message}"),
        }
    }
}

fn run_status(command: &mut Command) -> Result<(), SoundCommandError> {
    let output = command
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status();

    match output {
        Ok(status) if status.success() => Ok(()),
        Ok(status) => Err(SoundCommandError::Failed(format!(
            "audio player exited with {}",
            status
        ))),
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Err(SoundCommandError::NotFound),
        Err(err) => Err(SoundCommandError::Failed(err.to_string())),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn built_in_alert_sounds_are_wav_files() {
        assert_wav(SUCCESS_ALERT_WAV);
        assert_wav(ERROR_ALERT_WAV);
    }

    fn assert_wav(bytes: &[u8]) {
        assert!(bytes.starts_with(b"RIFF"));
        assert_eq!(&bytes[8..12], b"WAVE");
        assert!(bytes.windows(4).any(|chunk| chunk == b"data"));
        assert!(bytes.len() > 44);
    }
}