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