whisper-macos-cli 0.1.0

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::process::ExitCode;

#[derive(Debug, thiserror::Error)]
pub enum Error {
    #[error("no audio input provided")]
    NoInput,

    #[error("input not found: {path}")]
    InputNotFound { path: String },

    #[error("audio decode failed: {0}")]
    AudioDecode(#[source] anyhow::Error),

    #[error("unsupported audio format: {format}")]
    UnsupportedFormat { format: String },

    #[error("model not found: {name}")]
    ModelNotFound { name: String },

    #[error("model download failed: {0}")]
    ModelDownload(#[source] anyhow::Error),

    #[error("whisper inference failed: {0}")]
    WhisperInference(String),

    #[error("unsupported platform: macOS with Apple Silicon required")]
    UnsupportedPlatform,

    #[error("io error: {0}")]
    Io(#[from] std::io::Error),

    #[error("configuration error: {0}")]
    Config(String),
}

impl Error {
    pub fn exit_code(&self) -> u8 {
        match self {
            Self::NoInput => 64,
            Self::InputNotFound { .. } => 66,
            Self::AudioDecode(_) => 65,
            Self::UnsupportedFormat { .. } => 65,
            Self::ModelNotFound { .. } => 78,
            Self::ModelDownload(_) => 69,
            Self::WhisperInference(_) => 70,
            Self::UnsupportedPlatform => 69,
            Self::Io(_) => 74,
            Self::Config(_) => 78,
        }
    }

    pub fn to_exit_code(&self) -> ExitCode {
        ExitCode::from(self.exit_code())
    }

    pub fn category(&self) -> &'static str {
        match self {
            Self::NoInput => "usage",
            Self::InputNotFound { .. } => "input",
            Self::AudioDecode(_) | Self::UnsupportedFormat { .. } => "data",
            Self::ModelNotFound { .. } | Self::Config(_) => "config",
            Self::ModelDownload(_) | Self::UnsupportedPlatform => "service",
            Self::WhisperInference(_) => "internal",
            Self::Io(_) => "io",
        }
    }

    pub fn retryable(&self) -> bool {
        matches!(self, Self::ModelDownload(_))
    }

    pub fn retry_after_ms(&self) -> Option<u64> {
        match self {
            Self::ModelDownload(_) => Some(2000),
            _ => None,
        }
    }

    pub fn hint(&self) -> Option<&'static str> {
        match self {
            Self::NoInput => Some("provide audio file(s) as arguments or pipe via stdin"),
            Self::InputNotFound { .. } => Some("check the file path and try again"),
            Self::AudioDecode(_) => {
                Some("verify the file is a valid audio format (ogg, mp3, wav, flac)")
            }
            Self::UnsupportedFormat { .. } => Some("use --input-format to force a specific codec"),
            Self::ModelNotFound { .. } => {
                Some("run 'whisper-macos-cli models list' to see available models")
            }
            Self::ModelDownload(_) => Some("check network connectivity and retry"),
            Self::WhisperInference(_) => Some("try a smaller model with --model base"),
            Self::UnsupportedPlatform => Some("this CLI requires macOS with Apple Silicon (M1+)"),
            Self::Io(_) => None,
            Self::Config(_) => Some("run 'whisper-macos-cli doctor' to diagnose"),
        }
    }

    pub fn docs_url(&self) -> &'static str {
        match self {
            Self::NoInput => {
                "https://github.com/daniloaguiarbr/whisper-macos-cli/blob/main/AGENTS.md#contract"
            }
            Self::InputNotFound { .. } => {
                "https://github.com/daniloaguiarbr/whisper-macos-cli/blob/main/docs/TROUBLESHOOTING.md"
            }
            Self::AudioDecode(_) => {
                "https://github.com/daniloaguiarbr/whisper-macos-cli/blob/main/docs/TROUBLESHOOTING.md#audio-decode"
            }
            Self::UnsupportedFormat { .. } => {
                "https://github.com/daniloaguiarbr/whisper-macos-cli/blob/main/docs/TROUBLESHOOTING.md#unsupported-format"
            }
            Self::ModelNotFound { .. } => {
                "https://github.com/daniloaguiarbr/whisper-macos-cli/blob/main/AGENTS.md#model-management"
            }
            Self::ModelDownload(_) => {
                "https://github.com/daniloaguiarbr/whisper-macos-cli/blob/main/docs/TROUBLESHOOTING.md#model-download"
            }
            Self::WhisperInference(_) => {
                "https://github.com/daniloaguiarbr/whisper-macos-cli/blob/main/docs/TROUBLESHOOTING.md#inference"
            }
            Self::UnsupportedPlatform => {
                "https://github.com/daniloaguiarbr/whisper-macos-cli/blob/main/README.md#platform-requirements"
            }
            Self::Io(_) => {
                "https://github.com/daniloaguiarbr/whisper-macos-cli/blob/main/docs/TROUBLESHOOTING.md"
            }
            Self::Config(_) => {
                "https://github.com/daniloteixeira/Dropbox/ai/dev/rust/macos/whisper-macos-cli/blob/main/docs/TROUBLESHOOTING.md"
            }
        }
    }

    pub fn to_json(&self, correlation_id: &str) -> serde_json::Value {
        serde_json::json!({
            "schema_version": env!("CARGO_PKG_VERSION"),
            "error": true,
            "code": self.exit_code(),
            "message": self.to_string(),
            "category": self.category(),
            "retryable": self.retryable(),
            "retry_after_ms": self.retry_after_ms(),
            "hint": self.hint(),
            "docs_url": self.docs_url(),
            "correlation_id": correlation_id,
        })
    }
}

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

    #[test]
    fn no_input_exit_code_is_64() {
        assert_eq!(Error::NoInput.exit_code(), 64);
    }

    #[test]
    fn input_not_found_exit_code_is_66() {
        let err = Error::InputNotFound {
            path: "test.mp3".to_string(),
        };
        assert_eq!(err.exit_code(), 66);
    }

    #[test]
    fn model_not_found_exit_code_is_78() {
        let err = Error::ModelNotFound {
            name: "unknown".to_string(),
        };
        assert_eq!(err.exit_code(), 78);
    }

    #[test]
    fn error_json_contains_all_required_fields() {
        let err = Error::ModelDownload(anyhow::anyhow!("HTTP 503"));
        let json = err.to_json("test-corr-id");
        assert_eq!(json["error"], true);
        assert!(json["code"].is_number());
        assert!(json["message"].is_string());
        assert!(json["category"].is_string());
        assert!(json["retryable"].is_boolean());
        assert!(json["retry_after_ms"].is_number());
        assert!(json["docs_url"].is_string());
        assert_eq!(json["correlation_id"], "test-corr-id");
    }
}