aurum-core 0.0.7

On-device speech I/O core: whisper.cpp STT, ONNX TTS, cleanup, providers
Documentation
//! Error taxonomy for Aurum (JOE-1611).
//!
//! [`TranscriptionError`] is the crate-wide error type (also exported as
//! [`AurumError`]). High-level [`ErrorCategory`] identifiers are stable at
//! v0.0.3 / v0.1.0; detailed variants may evolve under a non-exhaustive policy.
//!
//! Groups:
//! - [`TranscriptionError::User`] — bad input, missing key, invalid model name
//! - [`TranscriptionError::Environment`] — missing ffmpeg, disk full, cache issues
//! - [`TranscriptionError::Provider`] — network, rate limit, model load failure
//! - [`TranscriptionError::Internal`] — unexpected bugs

use thiserror::Error;

/// Stable high-level category identifiers (frozen at 0.1 policy).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ErrorCategory {
    InvalidInput,
    UnsupportedCapability,
    Cancelled,
    DeadlineExceeded,
    BusyOverloaded,
    ModelUnavailable,
    ArtifactIntegrity,
    Network,
    Auth,
    RateLimit,
    Quota,
    Filesystem,
    DiskFull,
    Subprocess,
    NativeInference,
    Internal,
    /// Catch-all for user/config class errors.
    User,
    Environment,
    Provider,
}

impl ErrorCategory {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::InvalidInput => "invalid_input",
            Self::UnsupportedCapability => "unsupported_capability",
            Self::Cancelled => "cancelled",
            Self::DeadlineExceeded => "deadline_exceeded",
            Self::BusyOverloaded => "busy_overloaded",
            Self::ModelUnavailable => "model_unavailable",
            Self::ArtifactIntegrity => "artifact_integrity",
            Self::Network => "network",
            Self::Auth => "auth",
            Self::RateLimit => "rate_limit",
            Self::Quota => "quota",
            Self::Filesystem => "filesystem",
            Self::DiskFull => "disk_full",
            Self::Subprocess => "subprocess",
            Self::NativeInference => "native_inference",
            Self::Internal => "internal",
            Self::User => "user",
            Self::Environment => "environment",
            Self::Provider => "provider",
        }
    }
}

/// Top-level error type used across the core library and CLI.
///
/// Prefer the [`AurumError`] alias in new code.
#[derive(Debug, Error)]
pub enum TranscriptionError {
    #[error("{0}")]
    User(#[from] UserError),

    #[error("{0}")]
    Environment(#[from] EnvironmentError),

    #[error("{0}")]
    Provider(#[from] ProviderError),

    #[error("internal error: {0}")]
    Internal(String),
}

/// Preferred name for the crate-wide error type (JOE-1611).
pub type AurumError = TranscriptionError;

#[derive(Debug, Error)]
pub enum UserError {
    #[error("audio file not found: {path}\n  Hint: check the path and try again.")]
    FileNotFound { path: String },

    #[error("invalid audio file: {reason}\n  Hint: ensure the file is a supported audio format (mp3, m4a, wav, flac, ogg, …).")]
    InvalidAudio { reason: String },

    #[error(
        "audio is too long ({duration_secs:.1}s); maximum accepted is {max_secs:.0}s.\n  \
         Hint: split the file, or raise the limit once longer-form support lands."
    )]
    AudioTooLong { duration_secs: f64, max_secs: f64 },

    #[error(
        "decoded audio is too large ({decoded_bytes} bytes); maximum is {max_bytes} bytes.\n  \
         Hint: split the file into shorter clips."
    )]
    AudioTooLarge {
        decoded_bytes: usize,
        max_bytes: usize,
    },

    #[error("unsupported output format: {format}\n  Hint: use one of: txt, srt, json.")]
    InvalidOutputFormat { format: String },

    #[error("unknown provider: {provider}\n  Hint: use one of: local, openrouter.")]
    InvalidProvider { provider: String },

    #[error("unknown local model: {model}\n  Hint: available models: {available}")]
    InvalidModel { model: String, available: String },

    #[error(
        "model '{model}' is not cached and downloads are disabled (local_only).\n  \
         Hint: download it once while online, or pass local_only=false."
    )]
    ModelNotCached { model: String },

    #[error(
        "unsupported PCM sample rate {got} Hz (need {need}).\n  \
         Hint: resample to {need} Hz mono f32 before calling from_pcm."
    )]
    UnsupportedSampleRate { got: u32, need: u32 },

    #[error(
        "OpenRouter API key is missing.\n  \
         Set OPENROUTER_API_KEY in your environment, or add api_key under [openrouter] in the config file.\n  \
         Get a key at https://openrouter.ai/keys"
    )]
    MissingApiKey,

    #[error("invalid configuration: {reason}")]
    InvalidConfig { reason: String },

    #[error("unsupported capability for {provider}/{model}: {reason}\n  Hint: {hint}")]
    UnsupportedCapability {
        provider: String,
        model: String,
        reason: String,
        hint: String,
    },

    #[error("{message}")]
    Other { message: String },
}

#[derive(Debug, Error)]
pub enum EnvironmentError {
    #[error(
        "ffmpeg is required but was not found on PATH.\n  \
         Install it, then retry:\n  \
         • macOS:   brew install ffmpeg\n  \
         • Ubuntu:  sudo apt install ffmpeg\n  \
         • Windows: winget install ffmpeg\n  \
         • Or see:  https://ffmpeg.org/download.html"
    )]
    FfmpegMissing,

    #[error("ffmpeg failed: {reason}")]
    FfmpegFailed { reason: String },

    #[error("insufficient disk space while writing {path}: {reason}")]
    DiskSpace { path: String, reason: String },

    #[error("failed to access cache/config directory {path}: {reason}")]
    DirectoryAccess { path: String, reason: String },

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

    #[error("{message}")]
    Other { message: String },
}

#[derive(Debug, Error)]
pub enum ProviderError {
    #[error("failed to load model '{model}': {reason}")]
    ModelLoad { model: String, reason: String },

    #[error("failed to download model '{model}': {reason}")]
    ModelDownload { model: String, reason: String },

    #[error("transcription failed: {reason}")]
    TranscriptionFailed { reason: String },

    #[error("transcription cancelled")]
    Cancelled,

    #[error("operation deadline exceeded")]
    DeadlineExceeded,

    #[error("resource overload: {reason}")]
    Overload { reason: String },

    #[error("network error talking to {provider}: {reason}")]
    Network { provider: String, reason: String },

    #[error(
        "rate limited by {provider}.\n  \
         Wait a moment and retry. If this persists, check your plan/quota."
    )]
    RateLimited { provider: String },

    #[error(
        "quota or billing issue with {provider}: {reason}\n  \
         Check your account balance and plan limits."
    )]
    QuotaExceeded { provider: String, reason: String },

    #[error("authentication failed for {provider}: {reason}")]
    Auth { provider: String, reason: String },

    #[error("{provider} returned an error: {reason}")]
    Remote { provider: String, reason: String },

    #[error("{provider} response too large: {reason}")]
    ResponseTooLarge { provider: String, reason: String },

    #[error("{provider} returned an invalid payload: {reason}")]
    InvalidProviderPayload { provider: String, reason: String },

    #[error("limit exceeded: {reason}")]
    LimitExceeded { reason: String },

    #[error("{message}")]
    Other { message: String },
}

impl TranscriptionError {
    /// Coarse group label (backward compatible).
    pub fn category(&self) -> &'static str {
        match self {
            Self::User(_) => "user",
            Self::Environment(_) => "environment",
            Self::Provider(_) => "provider",
            Self::Internal(_) => "internal",
        }
    }

    /// Stable semantic category (JOE-1611).
    pub fn error_category(&self) -> ErrorCategory {
        match self {
            Self::User(u) => match u {
                UserError::UnsupportedCapability { .. } => ErrorCategory::UnsupportedCapability,
                UserError::ModelNotCached { .. } | UserError::InvalidModel { .. } => {
                    ErrorCategory::ModelUnavailable
                }
                UserError::MissingApiKey => ErrorCategory::Auth,
                UserError::InvalidConfig { .. }
                | UserError::InvalidProvider { .. }
                | UserError::InvalidOutputFormat { .. }
                | UserError::FileNotFound { .. }
                | UserError::InvalidAudio { .. }
                | UserError::AudioTooLong { .. }
                | UserError::AudioTooLarge { .. }
                | UserError::UnsupportedSampleRate { .. }
                | UserError::Other { .. } => ErrorCategory::InvalidInput,
            },
            Self::Environment(e) => match e {
                EnvironmentError::DiskSpace { .. } => ErrorCategory::DiskFull,
                EnvironmentError::FfmpegMissing | EnvironmentError::FfmpegFailed { .. } => {
                    ErrorCategory::Subprocess
                }
                EnvironmentError::DirectoryAccess { .. } | EnvironmentError::Io(_) => {
                    ErrorCategory::Filesystem
                }
                EnvironmentError::Other { .. } => ErrorCategory::Environment,
            },
            Self::Provider(p) => match p {
                ProviderError::Cancelled => ErrorCategory::Cancelled,
                ProviderError::DeadlineExceeded => ErrorCategory::DeadlineExceeded,
                ProviderError::Overload { .. } => ErrorCategory::BusyOverloaded,
                ProviderError::Network { .. } => ErrorCategory::Network,
                ProviderError::Auth { .. } => ErrorCategory::Auth,
                ProviderError::RateLimited { .. } => ErrorCategory::RateLimit,
                ProviderError::QuotaExceeded { .. } => ErrorCategory::Quota,
                ProviderError::ModelLoad { .. } | ProviderError::ModelDownload { .. } => {
                    ErrorCategory::ModelUnavailable
                }
                ProviderError::TranscriptionFailed { .. } => ErrorCategory::NativeInference,
                ProviderError::InvalidProviderPayload { .. }
                | ProviderError::ResponseTooLarge { .. }
                | ProviderError::LimitExceeded { .. }
                | ProviderError::Remote { .. }
                | ProviderError::Other { .. } => ErrorCategory::Provider,
            },
            Self::Internal(_) => ErrorCategory::Internal,
        }
    }

    /// Whether a caller may reasonably retry the same operation.
    pub fn retryable(&self) -> bool {
        matches!(
            self.error_category(),
            ErrorCategory::Network
                | ErrorCategory::RateLimit
                | ErrorCategory::BusyOverloaded
                | ErrorCategory::DeadlineExceeded
        )
    }

    /// Suggested process exit code.
    ///
    /// 1 internal · 2 user/input · 3 environment · 4 provider · 5 cancelled ·
    /// 6 deadline · 7 overload
    pub fn exit_code(&self) -> i32 {
        match self.error_category() {
            ErrorCategory::Internal => 1,
            ErrorCategory::Cancelled => 5,
            ErrorCategory::DeadlineExceeded => 6,
            ErrorCategory::BusyOverloaded => 7,
            ErrorCategory::User
            | ErrorCategory::InvalidInput
            | ErrorCategory::UnsupportedCapability
            | ErrorCategory::Auth
            | ErrorCategory::ModelUnavailable
            | ErrorCategory::ArtifactIntegrity => 2,
            ErrorCategory::Environment
            | ErrorCategory::Filesystem
            | ErrorCategory::DiskFull
            | ErrorCategory::Subprocess => 3,
            ErrorCategory::Provider
            | ErrorCategory::Network
            | ErrorCategory::RateLimit
            | ErrorCategory::Quota
            | ErrorCategory::NativeInference => 4,
        }
    }

    pub fn internal(msg: impl Into<String>) -> Self {
        Self::Internal(msg.into())
    }
}

impl From<std::io::Error> for TranscriptionError {
    fn from(value: std::io::Error) -> Self {
        Self::Environment(EnvironmentError::Io(value))
    }
}

/// Result alias used throughout the crate.
pub type Result<T> = std::result::Result<T, TranscriptionError>;