Skip to main content

aurum_core/
error.rs

1//! Error taxonomy for Aurum (JOE-1611).
2//!
3//! [`TranscriptionError`] is the crate-wide error type (also exported as
4//! [`AurumError`]). High-level [`ErrorCategory`] identifiers are stable at
5//! v0.0.3 / v0.1.0; detailed variants may evolve under a non-exhaustive policy.
6//!
7//! Groups:
8//! - [`TranscriptionError::User`] — bad input, missing key, invalid model name
9//! - [`TranscriptionError::Environment`] — missing ffmpeg, disk full, cache issues
10//! - [`TranscriptionError::Provider`] — network, rate limit, model load failure
11//! - [`TranscriptionError::Internal`] — unexpected bugs
12
13use thiserror::Error;
14
15/// Stable high-level category identifiers (frozen at 0.1 policy).
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17pub enum ErrorCategory {
18    InvalidInput,
19    UnsupportedCapability,
20    Cancelled,
21    DeadlineExceeded,
22    BusyOverloaded,
23    ModelUnavailable,
24    ArtifactIntegrity,
25    Network,
26    Auth,
27    RateLimit,
28    Quota,
29    Filesystem,
30    DiskFull,
31    Subprocess,
32    NativeInference,
33    Internal,
34    /// Catch-all for user/config class errors.
35    User,
36    Environment,
37    Provider,
38}
39
40impl ErrorCategory {
41    pub fn as_str(self) -> &'static str {
42        match self {
43            Self::InvalidInput => "invalid_input",
44            Self::UnsupportedCapability => "unsupported_capability",
45            Self::Cancelled => "cancelled",
46            Self::DeadlineExceeded => "deadline_exceeded",
47            Self::BusyOverloaded => "busy_overloaded",
48            Self::ModelUnavailable => "model_unavailable",
49            Self::ArtifactIntegrity => "artifact_integrity",
50            Self::Network => "network",
51            Self::Auth => "auth",
52            Self::RateLimit => "rate_limit",
53            Self::Quota => "quota",
54            Self::Filesystem => "filesystem",
55            Self::DiskFull => "disk_full",
56            Self::Subprocess => "subprocess",
57            Self::NativeInference => "native_inference",
58            Self::Internal => "internal",
59            Self::User => "user",
60            Self::Environment => "environment",
61            Self::Provider => "provider",
62        }
63    }
64}
65
66/// Top-level error type used across the core library and CLI.
67///
68/// Prefer the [`AurumError`] alias in new code.
69#[derive(Debug, Error)]
70pub enum TranscriptionError {
71    #[error("{0}")]
72    User(#[from] UserError),
73
74    #[error("{0}")]
75    Environment(#[from] EnvironmentError),
76
77    #[error("{0}")]
78    Provider(#[from] ProviderError),
79
80    #[error("internal error: {0}")]
81    Internal(String),
82}
83
84/// Preferred name for the crate-wide error type (JOE-1611).
85pub type AurumError = TranscriptionError;
86
87#[derive(Debug, Error)]
88pub enum UserError {
89    #[error("audio file not found: {path}\n  Hint: check the path and try again.")]
90    FileNotFound { path: String },
91
92    #[error("invalid audio file: {reason}\n  Hint: ensure the file is a supported audio format (mp3, m4a, wav, flac, ogg, …).")]
93    InvalidAudio { reason: String },
94
95    #[error(
96        "audio is too long ({duration_secs:.1}s); maximum accepted is {max_secs:.0}s.\n  \
97         Hint: split the file, or raise the limit once longer-form support lands."
98    )]
99    AudioTooLong { duration_secs: f64, max_secs: f64 },
100
101    #[error(
102        "decoded audio is too large ({decoded_bytes} bytes); maximum is {max_bytes} bytes.\n  \
103         Hint: split the file into shorter clips."
104    )]
105    AudioTooLarge {
106        decoded_bytes: usize,
107        max_bytes: usize,
108    },
109
110    #[error("unsupported output format: {format}\n  Hint: use one of: txt, srt, json.")]
111    InvalidOutputFormat { format: String },
112
113    #[error("unknown provider: {provider}\n  Hint: use one of: local, openrouter.")]
114    InvalidProvider { provider: String },
115
116    #[error("unknown local model: {model}\n  Hint: available models: {available}")]
117    InvalidModel { model: String, available: String },
118
119    #[error(
120        "model '{model}' is not cached and downloads are disabled (local_only).\n  \
121         Hint: download it once while online, or pass local_only=false."
122    )]
123    ModelNotCached { model: String },
124
125    #[error(
126        "unsupported PCM sample rate {got} Hz (need {need}).\n  \
127         Hint: resample to {need} Hz mono f32 before calling from_pcm."
128    )]
129    UnsupportedSampleRate { got: u32, need: u32 },
130
131    #[error(
132        "OpenRouter API key is missing.\n  \
133         Set OPENROUTER_API_KEY in your environment, or add api_key under [openrouter] in the config file.\n  \
134         Get a key at https://openrouter.ai/keys"
135    )]
136    MissingApiKey,
137
138    #[error("invalid configuration: {reason}")]
139    InvalidConfig { reason: String },
140
141    #[error("unsupported capability for {provider}/{model}: {reason}\n  Hint: {hint}")]
142    UnsupportedCapability {
143        provider: String,
144        model: String,
145        reason: String,
146        hint: String,
147    },
148
149    #[error("{message}")]
150    Other { message: String },
151}
152
153#[derive(Debug, Error)]
154pub enum EnvironmentError {
155    #[error(
156        "ffmpeg is required but was not found on PATH.\n  \
157         Install it, then retry:\n  \
158         • macOS:   brew install ffmpeg\n  \
159         • Ubuntu:  sudo apt install ffmpeg\n  \
160         • Windows: winget install ffmpeg\n  \
161         • Or see:  https://ffmpeg.org/download.html"
162    )]
163    FfmpegMissing,
164
165    #[error("ffmpeg failed: {reason}")]
166    FfmpegFailed { reason: String },
167
168    #[error("insufficient disk space while writing {path}: {reason}")]
169    DiskSpace { path: String, reason: String },
170
171    #[error("failed to access cache/config directory {path}: {reason}")]
172    DirectoryAccess { path: String, reason: String },
173
174    #[error("I/O error: {0}")]
175    Io(#[from] std::io::Error),
176
177    #[error("{message}")]
178    Other { message: String },
179}
180
181#[derive(Debug, Error)]
182pub enum ProviderError {
183    #[error("failed to load model '{model}': {reason}")]
184    ModelLoad { model: String, reason: String },
185
186    #[error("failed to download model '{model}': {reason}")]
187    ModelDownload { model: String, reason: String },
188
189    #[error("transcription failed: {reason}")]
190    TranscriptionFailed { reason: String },
191
192    #[error("transcription cancelled")]
193    Cancelled,
194
195    #[error("operation deadline exceeded")]
196    DeadlineExceeded,
197
198    #[error("resource overload: {reason}")]
199    Overload { reason: String },
200
201    #[error("network error talking to {provider}: {reason}")]
202    Network { provider: String, reason: String },
203
204    #[error(
205        "rate limited by {provider}.\n  \
206         Wait a moment and retry. If this persists, check your plan/quota."
207    )]
208    RateLimited { provider: String },
209
210    #[error(
211        "quota or billing issue with {provider}: {reason}\n  \
212         Check your account balance and plan limits."
213    )]
214    QuotaExceeded { provider: String, reason: String },
215
216    #[error("authentication failed for {provider}: {reason}")]
217    Auth { provider: String, reason: String },
218
219    #[error("{provider} returned an error: {reason}")]
220    Remote { provider: String, reason: String },
221
222    #[error("{provider} response too large: {reason}")]
223    ResponseTooLarge { provider: String, reason: String },
224
225    #[error("{provider} returned an invalid payload: {reason}")]
226    InvalidProviderPayload { provider: String, reason: String },
227
228    #[error("limit exceeded: {reason}")]
229    LimitExceeded { reason: String },
230
231    #[error("{message}")]
232    Other { message: String },
233}
234
235impl TranscriptionError {
236    /// Coarse group label (backward compatible).
237    pub fn category(&self) -> &'static str {
238        match self {
239            Self::User(_) => "user",
240            Self::Environment(_) => "environment",
241            Self::Provider(_) => "provider",
242            Self::Internal(_) => "internal",
243        }
244    }
245
246    /// Stable semantic category (JOE-1611).
247    pub fn error_category(&self) -> ErrorCategory {
248        match self {
249            Self::User(u) => match u {
250                UserError::UnsupportedCapability { .. } => ErrorCategory::UnsupportedCapability,
251                UserError::ModelNotCached { .. } | UserError::InvalidModel { .. } => {
252                    ErrorCategory::ModelUnavailable
253                }
254                UserError::MissingApiKey => ErrorCategory::Auth,
255                UserError::InvalidConfig { .. }
256                | UserError::InvalidProvider { .. }
257                | UserError::InvalidOutputFormat { .. }
258                | UserError::FileNotFound { .. }
259                | UserError::InvalidAudio { .. }
260                | UserError::AudioTooLong { .. }
261                | UserError::AudioTooLarge { .. }
262                | UserError::UnsupportedSampleRate { .. }
263                | UserError::Other { .. } => ErrorCategory::InvalidInput,
264            },
265            Self::Environment(e) => match e {
266                EnvironmentError::DiskSpace { .. } => ErrorCategory::DiskFull,
267                EnvironmentError::FfmpegMissing | EnvironmentError::FfmpegFailed { .. } => {
268                    ErrorCategory::Subprocess
269                }
270                EnvironmentError::DirectoryAccess { .. } | EnvironmentError::Io(_) => {
271                    ErrorCategory::Filesystem
272                }
273                EnvironmentError::Other { .. } => ErrorCategory::Environment,
274            },
275            Self::Provider(p) => match p {
276                ProviderError::Cancelled => ErrorCategory::Cancelled,
277                ProviderError::DeadlineExceeded => ErrorCategory::DeadlineExceeded,
278                ProviderError::Overload { .. } => ErrorCategory::BusyOverloaded,
279                ProviderError::Network { .. } => ErrorCategory::Network,
280                ProviderError::Auth { .. } => ErrorCategory::Auth,
281                ProviderError::RateLimited { .. } => ErrorCategory::RateLimit,
282                ProviderError::QuotaExceeded { .. } => ErrorCategory::Quota,
283                ProviderError::ModelLoad { .. } | ProviderError::ModelDownload { .. } => {
284                    ErrorCategory::ModelUnavailable
285                }
286                ProviderError::TranscriptionFailed { .. } => ErrorCategory::NativeInference,
287                ProviderError::InvalidProviderPayload { .. }
288                | ProviderError::ResponseTooLarge { .. }
289                | ProviderError::LimitExceeded { .. }
290                | ProviderError::Remote { .. }
291                | ProviderError::Other { .. } => ErrorCategory::Provider,
292            },
293            Self::Internal(_) => ErrorCategory::Internal,
294        }
295    }
296
297    /// Whether a caller may reasonably retry the same operation.
298    pub fn retryable(&self) -> bool {
299        matches!(
300            self.error_category(),
301            ErrorCategory::Network
302                | ErrorCategory::RateLimit
303                | ErrorCategory::BusyOverloaded
304                | ErrorCategory::DeadlineExceeded
305        )
306    }
307
308    /// Suggested process exit code.
309    ///
310    /// 1 internal · 2 user/input · 3 environment · 4 provider · 5 cancelled ·
311    /// 6 deadline · 7 overload
312    pub fn exit_code(&self) -> i32 {
313        match self.error_category() {
314            ErrorCategory::Internal => 1,
315            ErrorCategory::Cancelled => 5,
316            ErrorCategory::DeadlineExceeded => 6,
317            ErrorCategory::BusyOverloaded => 7,
318            ErrorCategory::User
319            | ErrorCategory::InvalidInput
320            | ErrorCategory::UnsupportedCapability
321            | ErrorCategory::Auth
322            | ErrorCategory::ModelUnavailable
323            | ErrorCategory::ArtifactIntegrity => 2,
324            ErrorCategory::Environment
325            | ErrorCategory::Filesystem
326            | ErrorCategory::DiskFull
327            | ErrorCategory::Subprocess => 3,
328            ErrorCategory::Provider
329            | ErrorCategory::Network
330            | ErrorCategory::RateLimit
331            | ErrorCategory::Quota
332            | ErrorCategory::NativeInference => 4,
333        }
334    }
335
336    pub fn internal(msg: impl Into<String>) -> Self {
337        Self::Internal(msg.into())
338    }
339}
340
341impl From<std::io::Error> for TranscriptionError {
342    fn from(value: std::io::Error) -> Self {
343        Self::Environment(EnvironmentError::Io(value))
344    }
345}
346
347/// Result alias used throughout the crate.
348pub type Result<T> = std::result::Result<T, TranscriptionError>;