Skip to main content

aurum_core/
error.rs

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