Skip to main content

mermaid_cli/models/
error.rs

1//! Comprehensive error types for the model system
2//!
3//! Replaces scattered anyhow::Error usage with structured, actionable errors
4//! that enable proper recovery, retry logic, and user-friendly messages.
5
6use serde::{Deserialize, Serialize};
7use std::fmt;
8
9/// User-facing error information with actionable suggestions
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct UserFacingError {
12    /// Short summary for status bar (e.g., "Connection failed")
13    pub summary: String,
14    /// Detailed message for chat display
15    pub message: String,
16    /// Actionable suggestion for the user
17    pub suggestion: String,
18    /// Error category for styling/icons
19    pub category: ErrorCategory,
20    /// Whether this error is recoverable (user can retry)
21    pub recoverable: bool,
22}
23
24/// Error categories for visual differentiation
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26pub enum ErrorCategory {
27    /// Connection/network issues
28    Connection,
29    /// Authentication/authorization issues
30    Auth,
31    /// Configuration issues
32    Config,
33    /// Resource not found
34    NotFound,
35    /// Temporary issue (rate limit, timeout)
36    Temporary,
37    /// Internal/unexpected error
38    Internal,
39}
40
41/// Top-level error type for all model operations
42#[derive(Debug)]
43pub enum ModelError {
44    /// Backend-specific error (connection, API, etc)
45    Backend(BackendError),
46
47    /// Configuration error (invalid settings, missing keys, etc)
48    Config(ConfigError),
49
50    /// Model not found or unavailable
51    ModelNotFound {
52        model: String,
53        searched: Vec<String>,
54    },
55
56    /// Request timeout
57    Timeout {
58        operation: String,
59        duration_secs: u64,
60    },
61
62    /// Rate limit exceeded
63    RateLimit { retry_after: Option<u64> },
64
65    /// Invalid request (malformed input, bad parameters)
66    InvalidRequest(String),
67
68    /// Response parsing error
69    ParseError {
70        message: String,
71        raw: Option<String>,
72    },
73
74    /// Stream error (connection dropped, incomplete response)
75    StreamError(String),
76
77    /// Authentication error
78    Authentication(String),
79}
80
81impl fmt::Display for ModelError {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        match self {
84            ModelError::Backend(e) => write!(f, "Backend error: {}", e),
85            ModelError::Config(e) => write!(f, "Configuration error: {}", e),
86            ModelError::ModelNotFound { model, searched } => {
87                write!(
88                    f,
89                    "Model '{}' not found. Searched: {}",
90                    model,
91                    searched.join(", ")
92                )
93            },
94            ModelError::Timeout {
95                operation,
96                duration_secs,
97            } => {
98                write!(
99                    f,
100                    "Operation '{}' timed out after {} seconds",
101                    operation, duration_secs
102                )
103            },
104            ModelError::RateLimit { retry_after } => {
105                if let Some(secs) = retry_after {
106                    write!(f, "Rate limit exceeded. Retry after {} seconds", secs)
107                } else {
108                    write!(f, "Rate limit exceeded")
109                }
110            },
111            ModelError::InvalidRequest(msg) => write!(f, "Invalid request: {}", msg),
112            ModelError::ParseError { message, raw } => {
113                if let Some(r) = raw {
114                    write!(f, "Parse error: {} (raw: {})", message, r)
115                } else {
116                    write!(f, "Parse error: {}", message)
117                }
118            },
119            ModelError::StreamError(msg) => write!(f, "Stream error: {}", msg),
120            ModelError::Authentication(msg) => write!(f, "Authentication error: {}", msg),
121        }
122    }
123}
124
125impl std::error::Error for ModelError {}
126
127impl ModelError {
128    /// Convert to user-facing error with actionable suggestions
129    pub fn to_user_facing(&self) -> UserFacingError {
130        match self {
131            ModelError::Backend(BackendError::ConnectionFailed { backend, url, .. }) => {
132                UserFacingError {
133                    summary: format!("{} connection failed", backend),
134                    message: format!("Could not connect to {} at {}", backend, url),
135                    suggestion: if backend == "ollama" {
136                        "Run 'ollama serve' to start Ollama, or check if it's running on the correct port".to_string()
137                    } else {
138                        format!("Check if {} is running and accessible", backend)
139                    },
140                    category: ErrorCategory::Connection,
141                    recoverable: true,
142                }
143            },
144            ModelError::Backend(BackendError::NotAvailable { backend, reason }) => {
145                UserFacingError {
146                    summary: format!("{} unavailable", backend),
147                    message: format!("{} is not available: {}", backend, reason),
148                    suggestion: if backend == "ollama" {
149                        "Start Ollama with 'ollama serve' or pull the model with 'ollama pull <model>'".to_string()
150                    } else {
151                        format!("Ensure {} service is running and healthy", backend)
152                    },
153                    category: ErrorCategory::Connection,
154                    recoverable: true,
155                }
156            },
157            ModelError::Backend(BackendError::HttpError { status, message }) => {
158                let (summary, suggestion) = match status {
159                    401 | 403 => (
160                        "Authentication failed",
161                        "Check your API key in ~/.config/mermaid/config.toml",
162                    ),
163                    404 => (
164                        "Model not found",
165                        "Use :model <name> to switch models (auto-pulls if needed), or pull manually with 'ollama pull <name>'",
166                    ),
167                    429 => (
168                        "Rate limited",
169                        "Wait a moment before retrying, or switch to a local model",
170                    ),
171                    500..=599 => (
172                        "Server error",
173                        "The backend service is experiencing issues - try again later",
174                    ),
175                    _ => (
176                        "Request failed",
177                        "Check your network connection and backend configuration",
178                    ),
179                };
180                UserFacingError {
181                    summary: summary.to_string(),
182                    message: format!("HTTP {}: {}", status, message),
183                    suggestion: suggestion.to_string(),
184                    category: if *status == 401 || *status == 403 {
185                        ErrorCategory::Auth
186                    } else if *status == 429 {
187                        ErrorCategory::Temporary
188                    } else {
189                        ErrorCategory::Internal
190                    },
191                    recoverable: *status == 429 || *status >= 500,
192                }
193            },
194            ModelError::Backend(BackendError::UnexpectedResponse { backend, message }) => {
195                UserFacingError {
196                    summary: "Unexpected response".to_string(),
197                    message: format!("Received unexpected response from {}: {}", backend, message),
198                    suggestion: "This might be a version mismatch - try updating the backend"
199                        .to_string(),
200                    category: ErrorCategory::Internal,
201                    recoverable: false,
202                }
203            },
204            ModelError::Backend(BackendError::ProviderError {
205                provider,
206                code,
207                message,
208            }) => {
209                let code_str = code.as_deref().unwrap_or("unknown");
210                UserFacingError {
211                    summary: format!("{} error", provider),
212                    message: format!("{} returned error {}: {}", provider, code_str, message),
213                    suggestion: format!(
214                        "Check {} documentation for error code {}",
215                        provider, code_str
216                    ),
217                    category: ErrorCategory::Internal,
218                    recoverable: false,
219                }
220            },
221            ModelError::Config(ConfigError::MissingRequired(field)) => UserFacingError {
222                summary: "Missing configuration".to_string(),
223                message: format!("Required configuration '{}' is missing", field),
224                suggestion: format!("Add '{}' to ~/.config/mermaid/config.toml", field),
225                category: ErrorCategory::Config,
226                recoverable: false,
227            },
228            ModelError::Config(ConfigError::InvalidValue {
229                field,
230                value,
231                reason,
232            }) => UserFacingError {
233                summary: "Invalid configuration".to_string(),
234                message: format!("Invalid value '{}' for '{}': {}", value, field, reason),
235                suggestion: format!("Fix '{}' in ~/.config/mermaid/config.toml", field),
236                category: ErrorCategory::Config,
237                recoverable: false,
238            },
239            ModelError::Config(ConfigError::FileError { path, reason }) => UserFacingError {
240                summary: "Config file error".to_string(),
241                message: format!("Cannot read config file '{}': {}", path, reason),
242                suggestion: "Check file permissions and syntax".to_string(),
243                category: ErrorCategory::Config,
244                recoverable: false,
245            },
246            ModelError::ModelNotFound { model, searched } => UserFacingError {
247                summary: "Model not found".to_string(),
248                message: format!("Model '{}' not found in: {}", model, searched.join(", ")),
249                suggestion: format!(
250                    "Pull the model with 'ollama pull {}' or check if the model name is correct",
251                    model
252                ),
253                category: ErrorCategory::NotFound,
254                recoverable: false,
255            },
256            ModelError::Timeout {
257                operation,
258                duration_secs,
259            } => UserFacingError {
260                summary: "Request timed out".to_string(),
261                message: format!("'{}' timed out after {} seconds", operation, duration_secs),
262                suggestion: "The model might be overloaded - try a smaller model or wait and retry"
263                    .to_string(),
264                category: ErrorCategory::Temporary,
265                recoverable: true,
266            },
267            ModelError::RateLimit { retry_after } => {
268                let wait_msg = retry_after
269                    .map(|s| format!("Wait {} seconds", s))
270                    .unwrap_or_else(|| "Wait a moment".to_string());
271                UserFacingError {
272                    summary: "Rate limited".to_string(),
273                    message: "Too many requests - rate limit exceeded".to_string(),
274                    suggestion: format!(
275                        "{}. Consider using a local Ollama model to avoid rate limits",
276                        wait_msg
277                    ),
278                    category: ErrorCategory::Temporary,
279                    recoverable: true,
280                }
281            },
282            ModelError::InvalidRequest(msg) => UserFacingError {
283                summary: "Invalid request".to_string(),
284                message: format!("The request was invalid: {}", msg),
285                suggestion: "Check your message format or try rephrasing".to_string(),
286                category: ErrorCategory::Internal,
287                recoverable: false,
288            },
289            ModelError::ParseError { message, .. } => UserFacingError {
290                summary: "Parse error".to_string(),
291                message: format!("Failed to parse response: {}", message),
292                suggestion:
293                    "The model returned an unexpected format - try sending the message again"
294                        .to_string(),
295                category: ErrorCategory::Internal,
296                recoverable: true,
297            },
298            ModelError::StreamError(msg) => UserFacingError {
299                summary: "Stream interrupted".to_string(),
300                message: format!("Connection lost during streaming: {}", msg),
301                suggestion: "Check your network connection and try again".to_string(),
302                category: ErrorCategory::Connection,
303                recoverable: true,
304            },
305            ModelError::Authentication(msg) => UserFacingError {
306                summary: "Authentication failed".to_string(),
307                message: format!("Authentication error: {}", msg),
308                suggestion:
309                    "Check your API key in ~/.config/mermaid/config.toml or environment variables"
310                        .to_string(),
311                category: ErrorCategory::Auth,
312                recoverable: false,
313            },
314        }
315    }
316}
317
318/// Backend-specific errors
319#[derive(Debug)]
320pub enum BackendError {
321    /// Connection failed (network, DNS, etc)
322    ConnectionFailed {
323        backend: String,
324        url: String,
325        reason: String,
326    },
327
328    /// Backend not available (not running, health check failed)
329    NotAvailable { backend: String, reason: String },
330
331    /// HTTP error from backend
332    HttpError { status: u16, message: String },
333
334    /// Backend returned unexpected response format
335    UnexpectedResponse { backend: String, message: String },
336
337    /// Provider-specific error
338    ProviderError {
339        provider: String,
340        code: Option<String>,
341        message: String,
342    },
343}
344
345impl fmt::Display for BackendError {
346    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
347        match self {
348            BackendError::ConnectionFailed {
349                backend,
350                url,
351                reason,
352            } => {
353                write!(f, "Failed to connect to {} at {}: {}", backend, url, reason)
354            },
355            BackendError::NotAvailable { backend, reason } => {
356                write!(f, "Backend '{}' not available: {}", backend, reason)
357            },
358            BackendError::HttpError { status, message } => {
359                write!(f, "HTTP error {}: {}", status, message)
360            },
361            BackendError::UnexpectedResponse { backend, message } => {
362                write!(f, "Unexpected response from {}: {}", backend, message)
363            },
364            BackendError::ProviderError {
365                provider,
366                code,
367                message,
368            } => {
369                if let Some(c) = code {
370                    write!(f, "{} error {}: {}", provider, c, message)
371                } else {
372                    write!(f, "{} error: {}", provider, message)
373                }
374            },
375        }
376    }
377}
378
379impl std::error::Error for BackendError {}
380
381/// Configuration errors
382#[derive(Debug)]
383pub enum ConfigError {
384    /// Missing required configuration
385    MissingRequired(String),
386
387    /// Invalid value for configuration
388    InvalidValue {
389        field: String,
390        value: String,
391        reason: String,
392    },
393
394    /// File operation error (read, parse, etc)
395    FileError { path: String, reason: String },
396}
397
398impl fmt::Display for ConfigError {
399    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
400        match self {
401            ConfigError::MissingRequired(field) => {
402                write!(f, "Missing required configuration: {}", field)
403            },
404            ConfigError::InvalidValue {
405                field,
406                value,
407                reason,
408            } => {
409                write!(f, "Invalid value for '{}': '{}' ({})", field, value, reason)
410            },
411            ConfigError::FileError { path, reason } => {
412                write!(f, "Error reading config file '{}': {}", path, reason)
413            },
414        }
415    }
416}
417
418impl std::error::Error for ConfigError {}
419
420/// Result type alias for model operations
421pub type Result<T> = std::result::Result<T, ModelError>;
422
423/// Conversion from anyhow::Error (for gradual migration)
424impl From<anyhow::Error> for ModelError {
425    fn from(err: anyhow::Error) -> Self {
426        ModelError::InvalidRequest(err.to_string())
427    }
428}
429
430/// Conversion from reqwest::Error
431impl From<reqwest::Error> for ModelError {
432    fn from(err: reqwest::Error) -> Self {
433        if err.is_timeout() {
434            ModelError::Timeout {
435                operation: "HTTP request".to_string(),
436                duration_secs: 120,
437            }
438        } else if err.is_connect() {
439            ModelError::Backend(BackendError::ConnectionFailed {
440                backend: "unknown".to_string(),
441                url: err
442                    .url()
443                    .map(|u| u.to_string())
444                    .unwrap_or_else(|| "unknown".to_string()),
445                reason: err.to_string(),
446            })
447        } else if err.is_status() {
448            let status = err.status().map(|s| s.as_u16()).unwrap_or(500);
449            ModelError::Backend(BackendError::HttpError {
450                status,
451                message: err.to_string(),
452            })
453        } else {
454            ModelError::Backend(BackendError::UnexpectedResponse {
455                backend: "unknown".to_string(),
456                message: err.to_string(),
457            })
458        }
459    }
460}
461
462/// Conversion from serde_json::Error
463impl From<serde_json::Error> for ModelError {
464    fn from(err: serde_json::Error) -> Self {
465        ModelError::ParseError {
466            message: err.to_string(),
467            raw: None,
468        }
469    }
470}