whisper-macos-cli 0.1.2

Transcribe audio files locally on Apple Silicon via whisper.cpp with Metal GPU acceleration, exposing a strict stdin/stdout JSON contract for AI agents and Unix pipelines.
Documentation
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
use std::time::Duration;

static SHUTDOWN_REQUESTED: AtomicBool = AtomicBool::new(false);
static SHUTDOWN_SIGNAL: AtomicU8 = AtomicU8::new(0);
static LAST_SIGNAL_TIME_MS: AtomicU64 = AtomicU64::new(0);

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShutdownReason {
    None,
    Sigint,
    Sigterm,
    ForcedExit,
}

impl ShutdownReason {
    pub fn from_u8(v: u8) -> Self {
        match v {
            1 => Self::Sigint,
            2 => Self::Sigterm,
            _ => Self::None,
        }
    }
}

pub fn reset_sigpipe() {
    #[cfg(unix)]
    unsafe {
        libc::signal(libc::SIGPIPE, libc::SIG_DFL);
    }
}

pub fn install_handlers() {
    install_sigint();
    install_sigterm();
}

fn install_sigint() {
    let handler = || {
        SHUTDOWN_REQUESTED.store(true, Ordering::SeqCst);
        let now = monotonic_ms();
        let last = LAST_SIGNAL_TIME_MS.load(Ordering::SeqCst);
        SHUTDOWN_SIGNAL.store(1, Ordering::SeqCst);
        if now.saturating_sub(last) < 1500 {
            SHUTDOWN_SIGNAL.store(3, Ordering::SeqCst);
        }
        LAST_SIGNAL_TIME_MS.store(now, Ordering::SeqCst);
    };
    if let Err(e) = ctrlc::set_handler(handler) {
        tracing::warn!("failed to install SIGINT handler: {e}");
    }
}

#[cfg(unix)]
fn install_sigterm() {
    use signal_hook::consts::TERM_SIGNALS;
    for sig in TERM_SIGNALS {
        let handler = || {
            SHUTDOWN_REQUESTED.store(true, Ordering::SeqCst);
            SHUTDOWN_SIGNAL.store(2, Ordering::SeqCst);
        };
        if let Err(e) = signal_hook::flag::register(*sig, Arc::new(AtomicBool::new(true))) {
            tracing::warn!(?sig, "failed to install SIGTERM flag: {e}");
        }
        let _ = handler;
    }
}

#[cfg(not(unix))]
fn install_sigterm() {}

pub fn is_shutdown_requested() -> bool {
    SHUTDOWN_REQUESTED.load(Ordering::SeqCst)
}

pub fn shutdown_reason() -> ShutdownReason {
    ShutdownReason::from_u8(SHUTDOWN_SIGNAL.load(Ordering::SeqCst))
}

pub fn is_forced_exit() -> bool {
    matches!(shutdown_reason(), ShutdownReason::ForcedExit)
}

pub fn shutdown_signal_exit_code() -> u8 {
    match shutdown_reason() {
        ShutdownReason::Sigint => 130,
        ShutdownReason::Sigterm => 143,
        ShutdownReason::ForcedExit => 137,
        ShutdownReason::None => 0,
    }
}

pub fn wait_or_timeout(timeout: Duration) -> bool {
    let start = std::time::Instant::now();
    while !is_shutdown_requested() && start.elapsed() < timeout {
        std::thread::sleep(Duration::from_millis(50));
    }
    is_shutdown_requested()
}

pub fn cleanup_partial_downloads(temp_paths: &[std::path::PathBuf]) {
    for path in temp_paths {
        if path.exists() {
            if let Err(e) = std::fs::remove_file(path) {
                tracing::warn!(path = %path.display(), "failed to clean up temp file: {e}");
            }
        }
    }
}

fn monotonic_ms() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}

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

    #[test]
    fn shutdown_reason_default_is_none() {
        assert_eq!(shutdown_reason(), ShutdownReason::None);
    }

    #[test]
    fn shutdown_reason_from_u8_mapping() {
        assert_eq!(ShutdownReason::from_u8(0), ShutdownReason::None);
        assert_eq!(ShutdownReason::from_u8(1), ShutdownReason::Sigint);
        assert_eq!(ShutdownReason::from_u8(2), ShutdownReason::Sigterm);
        assert_eq!(ShutdownReason::from_u8(3), ShutdownReason::None);
        assert_eq!(ShutdownReason::from_u8(99), ShutdownReason::None);
        assert_eq!(ShutdownReason::from_u8(255), ShutdownReason::None);
    }

    #[test]
    fn shutdown_signal_exit_codes() {
        assert_eq!(shutdown_signal_exit_code(), 0);
    }

    #[test]
    fn is_shutdown_requested_initially_false() {
        assert!(!is_shutdown_requested());
    }

    #[test]
    fn is_forced_exit_initially_false() {
        assert!(!is_forced_exit());
    }

    #[test]
    fn cleanup_partial_downloads_handles_empty_slice() {
        cleanup_partial_downloads(&[]);
    }

    #[test]
    fn cleanup_partial_downloads_handles_missing_files() {
        let paths = vec![
            std::path::PathBuf::from("/tmp/does_not_exist_xyz_1.tmp"),
            std::path::PathBuf::from("/tmp/does_not_exist_xyz_2.tmp"),
        ];
        cleanup_partial_downloads(&paths);
    }

    #[test]
    fn cleanup_partial_downloads_removes_existing_file() {
        let dir = std::env::temp_dir().join(format!("whisper_cleanup_{}", std::process::id()));
        let _ = std::fs::create_dir_all(&dir);
        let file = dir.join("test.tmp");
        std::fs::write(&file, b"data").unwrap();
        assert!(file.exists());
        cleanup_partial_downloads(&[file.clone()]);
        assert!(!file.exists());
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn wait_or_timeout_returns_false_when_no_shutdown() {
        let result = wait_or_timeout(std::time::Duration::from_millis(100));
        assert!(!result);
    }
}