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    /// The adapter does not implement the requested feature (e.g. an
81    /// Anthropic adapter has no `list_models` endpoint, so the trait's
82    /// default impl returns this).
83    Unsupported { feature: String },
84}
85
86impl fmt::Display for ModelError {
87    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88        match self {
89            ModelError::Backend(e) => write!(f, "Backend error: {}", e),
90            ModelError::Config(e) => write!(f, "Configuration error: {}", e),
91            ModelError::ModelNotFound { model, searched } => {
92                write!(
93                    f,
94                    "Model '{}' not found. Searched: {}",
95                    model,
96                    searched.join(", ")
97                )
98            },
99            ModelError::Timeout {
100                operation,
101                duration_secs,
102            } => {
103                if *duration_secs == 0 {
104                    write!(f, "Operation '{}' timed out", operation)
105                } else {
106                    write!(
107                        f,
108                        "Operation '{}' timed out after {} seconds",
109                        operation, duration_secs
110                    )
111                }
112            },
113            ModelError::RateLimit { retry_after } => {
114                if let Some(secs) = retry_after {
115                    write!(f, "Rate limit exceeded. Retry after {} seconds", secs)
116                } else {
117                    write!(f, "Rate limit exceeded")
118                }
119            },
120            ModelError::InvalidRequest(msg) => write!(f, "Invalid request: {}", msg),
121            ModelError::ParseError { message, raw } => {
122                if let Some(r) = raw {
123                    write!(f, "Parse error: {} (raw: {})", message, r)
124                } else {
125                    write!(f, "Parse error: {}", message)
126                }
127            },
128            ModelError::StreamError(msg) => write!(f, "Stream error: {}", msg),
129            ModelError::Authentication(msg) => write!(f, "Authentication error: {}", msg),
130            ModelError::Unsupported { feature } => {
131                write!(f, "Feature not supported by this adapter: {}", feature)
132            },
133        }
134    }
135}
136
137impl std::error::Error for ModelError {}
138
139impl ModelError {
140    /// Convert to user-facing error with actionable suggestions
141    pub fn to_user_facing(&self) -> UserFacingError {
142        match self {
143            ModelError::Backend(BackendError::ConnectionFailed { backend, url, .. }) => {
144                UserFacingError {
145                    summary: format!("{} connection failed", backend),
146                    message: format!("Could not connect to {} at {}", backend, url),
147                    suggestion: if backend == "ollama" {
148                        "Run 'ollama serve' to start Ollama, or check if it's running on the correct port".to_string()
149                    } else {
150                        format!("Check if {} is running and accessible", backend)
151                    },
152                    category: ErrorCategory::Connection,
153                    recoverable: true,
154                }
155            },
156            ModelError::Backend(BackendError::NotAvailable { backend, reason }) => {
157                UserFacingError {
158                    summary: format!("{} unavailable", backend),
159                    message: format!("{} is not available: {}", backend, reason),
160                    suggestion: if backend == "ollama" {
161                        "Start Ollama with 'ollama serve' or pull the model with 'ollama pull <model>'".to_string()
162                    } else {
163                        format!("Ensure {} service is running and healthy", backend)
164                    },
165                    category: ErrorCategory::Connection,
166                    recoverable: true,
167                }
168            },
169            ModelError::Backend(BackendError::HttpError { status, message }) => {
170                let (summary, suggestion) = match status {
171                    401 | 403 => (
172                        "Authentication failed",
173                        "Check your API key in ~/.config/mermaid/config.toml",
174                    ),
175                    404 => (
176                        "Model not found",
177                        "Use :model <name> to switch models (auto-pulls if needed), or pull manually with 'ollama pull <name>'",
178                    ),
179                    429 => (
180                        "Rate limited",
181                        "Wait a moment before retrying, or switch to a local model",
182                    ),
183                    500..=599 => (
184                        "Server error",
185                        "The backend service is experiencing issues - try again later",
186                    ),
187                    _ => (
188                        "Request failed",
189                        "Check your network connection and backend configuration",
190                    ),
191                };
192                UserFacingError {
193                    summary: summary.to_string(),
194                    message: format!("HTTP {}: {}", status, message),
195                    suggestion: suggestion.to_string(),
196                    category: if *status == 401 || *status == 403 {
197                        ErrorCategory::Auth
198                    } else if *status == 429 {
199                        ErrorCategory::Temporary
200                    } else {
201                        ErrorCategory::Internal
202                    },
203                    recoverable: *status == 429 || *status >= 500,
204                }
205            },
206            ModelError::Backend(BackendError::UnexpectedResponse { backend, message }) => {
207                UserFacingError {
208                    summary: "Unexpected response".to_string(),
209                    message: format!("Received unexpected response from {}: {}", backend, message),
210                    suggestion: "This might be a version mismatch - try updating the backend"
211                        .to_string(),
212                    category: ErrorCategory::Internal,
213                    recoverable: false,
214                }
215            },
216            ModelError::Backend(BackendError::ProviderError {
217                provider,
218                code,
219                message,
220            }) => {
221                let code_str = code.as_deref().unwrap_or("unknown");
222                UserFacingError {
223                    summary: format!("{} error", provider),
224                    message: format!("{} returned error {}: {}", provider, code_str, message),
225                    suggestion: format!(
226                        "Check {} documentation for error code {}",
227                        provider, code_str
228                    ),
229                    category: ErrorCategory::Internal,
230                    recoverable: false,
231                }
232            },
233            ModelError::Config(ConfigError::MissingRequired(field)) => UserFacingError {
234                summary: "Missing configuration".to_string(),
235                message: format!("Required configuration '{}' is missing", field),
236                suggestion: format!("Add '{}' to ~/.config/mermaid/config.toml", field),
237                category: ErrorCategory::Config,
238                recoverable: false,
239            },
240            ModelError::Config(ConfigError::InvalidValue {
241                field,
242                value,
243                reason,
244            }) => UserFacingError {
245                summary: "Invalid configuration".to_string(),
246                message: format!("Invalid value '{}' for '{}': {}", value, field, reason),
247                suggestion: format!("Fix '{}' in ~/.config/mermaid/config.toml", field),
248                category: ErrorCategory::Config,
249                recoverable: false,
250            },
251            ModelError::Config(ConfigError::FileError { path, reason }) => UserFacingError {
252                summary: "Config file error".to_string(),
253                message: format!("Cannot read config file '{}': {}", path, reason),
254                suggestion: "Check file permissions and syntax".to_string(),
255                category: ErrorCategory::Config,
256                recoverable: false,
257            },
258            ModelError::ModelNotFound { model, searched } => UserFacingError {
259                summary: "Model not found".to_string(),
260                message: format!("Model '{}' not found in: {}", model, searched.join(", ")),
261                suggestion: format!(
262                    "Pull the model with 'ollama pull {}' or check if the model name is correct",
263                    model
264                ),
265                category: ErrorCategory::NotFound,
266                recoverable: false,
267            },
268            ModelError::Timeout {
269                operation,
270                duration_secs,
271            } => UserFacingError {
272                summary: "Request timed out".to_string(),
273                message: if *duration_secs == 0 {
274                    format!("'{}' timed out", operation)
275                } else {
276                    format!("'{}' timed out after {} seconds", operation, duration_secs)
277                },
278                suggestion: "The model might be overloaded - try a smaller model or wait and retry"
279                    .to_string(),
280                category: ErrorCategory::Temporary,
281                recoverable: true,
282            },
283            ModelError::RateLimit { retry_after } => {
284                let wait_msg = retry_after
285                    .map(|s| format!("Wait {} seconds", s))
286                    .unwrap_or_else(|| "Wait a moment".to_string());
287                UserFacingError {
288                    summary: "Rate limited".to_string(),
289                    message: "Too many requests - rate limit exceeded".to_string(),
290                    suggestion: format!(
291                        "{}. Consider using a local Ollama model to avoid rate limits",
292                        wait_msg
293                    ),
294                    category: ErrorCategory::Temporary,
295                    recoverable: true,
296                }
297            },
298            ModelError::InvalidRequest(msg) => UserFacingError {
299                summary: "Invalid request".to_string(),
300                message: format!("The request was invalid: {}", msg),
301                suggestion: "Check your message format or try rephrasing".to_string(),
302                category: ErrorCategory::Internal,
303                recoverable: false,
304            },
305            ModelError::ParseError { message, .. } => UserFacingError {
306                summary: "Parse error".to_string(),
307                message: format!("Failed to parse response: {}", message),
308                suggestion:
309                    "The model returned an unexpected format - try sending the message again"
310                        .to_string(),
311                category: ErrorCategory::Internal,
312                recoverable: true,
313            },
314            ModelError::StreamError(msg) => UserFacingError {
315                summary: "Stream interrupted".to_string(),
316                message: format!("Connection lost during streaming: {}", msg),
317                suggestion: "Check your network connection and try again".to_string(),
318                category: ErrorCategory::Connection,
319                recoverable: true,
320            },
321            ModelError::Authentication(msg) => UserFacingError {
322                summary: "Authentication failed".to_string(),
323                message: format!("Authentication error: {}", msg),
324                suggestion:
325                    "Check your API key in ~/.config/mermaid/config.toml or environment variables"
326                        .to_string(),
327                category: ErrorCategory::Auth,
328                recoverable: false,
329            },
330            ModelError::Unsupported { feature } => UserFacingError {
331                summary: "Unsupported feature".to_string(),
332                message: format!("The current model adapter does not support '{}'.", feature),
333                suggestion: format!(
334                    "Switch to a provider/model that supports '{}', or omit this operation.",
335                    feature
336                ),
337                category: ErrorCategory::Internal,
338                recoverable: false,
339            },
340        }
341    }
342}
343
344/// Backend-specific errors
345#[derive(Debug)]
346pub enum BackendError {
347    /// Connection failed (network, DNS, etc)
348    ConnectionFailed {
349        backend: String,
350        url: String,
351        reason: String,
352    },
353
354    /// Backend not available (not running, health check failed)
355    NotAvailable { backend: String, reason: String },
356
357    /// HTTP error from backend
358    HttpError { status: u16, message: String },
359
360    /// Backend returned unexpected response format
361    UnexpectedResponse { backend: String, message: String },
362
363    /// Provider-specific error
364    ProviderError {
365        provider: String,
366        code: Option<String>,
367        message: String,
368    },
369}
370
371impl fmt::Display for BackendError {
372    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
373        match self {
374            BackendError::ConnectionFailed {
375                backend,
376                url,
377                reason,
378            } => {
379                write!(f, "Failed to connect to {} at {}: {}", backend, url, reason)
380            },
381            BackendError::NotAvailable { backend, reason } => {
382                write!(f, "Backend '{}' not available: {}", backend, reason)
383            },
384            BackendError::HttpError { status, message } => {
385                write!(f, "HTTP error {}: {}", status, message)
386            },
387            BackendError::UnexpectedResponse { backend, message } => {
388                write!(f, "Unexpected response from {}: {}", backend, message)
389            },
390            BackendError::ProviderError {
391                provider,
392                code,
393                message,
394            } => {
395                if let Some(c) = code {
396                    write!(f, "{} error {}: {}", provider, c, message)
397                } else {
398                    write!(f, "{} error: {}", provider, message)
399                }
400            },
401        }
402    }
403}
404
405impl std::error::Error for BackendError {}
406
407/// Configuration errors
408#[derive(Debug)]
409pub enum ConfigError {
410    /// Missing required configuration
411    MissingRequired(String),
412
413    /// Invalid value for configuration
414    InvalidValue {
415        field: String,
416        value: String,
417        reason: String,
418    },
419
420    /// File operation error (read, parse, etc)
421    FileError { path: String, reason: String },
422}
423
424impl fmt::Display for ConfigError {
425    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
426        match self {
427            ConfigError::MissingRequired(field) => {
428                write!(f, "Missing required configuration: {}", field)
429            },
430            ConfigError::InvalidValue {
431                field,
432                value,
433                reason,
434            } => {
435                write!(f, "Invalid value for '{}': '{}' ({})", field, value, reason)
436            },
437            ConfigError::FileError { path, reason } => {
438                write!(f, "Error reading config file '{}': {}", path, reason)
439            },
440        }
441    }
442}
443
444impl std::error::Error for ConfigError {}
445
446/// Result type alias for model operations
447pub type Result<T> = std::result::Result<T, ModelError>;
448
449/// Conversion from anyhow::Error (for gradual migration)
450impl From<anyhow::Error> for ModelError {
451    fn from(err: anyhow::Error) -> Self {
452        ModelError::InvalidRequest(err.to_string())
453    }
454}
455
456/// Conversion from reqwest::Error
457impl From<reqwest::Error> for ModelError {
458    fn from(err: reqwest::Error) -> Self {
459        if err.is_timeout() {
460            // reqwest::Error doesn't expose the actual elapsed duration,
461            // and the adapter only sets a connect_timeout (no global
462            // request timeout), so there is no truthful number to report.
463            // 0 is a sentinel meaning "unknown" — the Display and
464            // to_user_facing impls for ModelError::Timeout omit the
465            // "after N seconds" suffix when duration_secs == 0.
466            ModelError::Timeout {
467                operation: "HTTP request".to_string(),
468                duration_secs: 0,
469            }
470        } else if err.is_connect() {
471            ModelError::Backend(BackendError::ConnectionFailed {
472                backend: "unknown".to_string(),
473                url: err
474                    .url()
475                    .map(|u| u.to_string())
476                    .unwrap_or_else(|| "unknown".to_string()),
477                reason: err.to_string(),
478            })
479        } else if err.is_status() {
480            let status = err.status().map(|s| s.as_u16()).unwrap_or(500);
481            ModelError::Backend(BackendError::HttpError {
482                status,
483                message: err.to_string(),
484            })
485        } else {
486            ModelError::Backend(BackendError::UnexpectedResponse {
487                backend: "unknown".to_string(),
488                message: err.to_string(),
489            })
490        }
491    }
492}
493
494/// Conversion from serde_json::Error
495impl From<serde_json::Error> for ModelError {
496    fn from(err: serde_json::Error) -> Self {
497        ModelError::ParseError {
498            message: err.to_string(),
499            raw: None,
500        }
501    }
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507
508    #[test]
509    fn timeout_display_omits_zero_duration() {
510        let err = ModelError::Timeout {
511            operation: "HTTP request".to_string(),
512            duration_secs: 0,
513        };
514        let rendered = err.to_string();
515        assert_eq!(rendered, "Operation 'HTTP request' timed out");
516        assert!(!rendered.contains("0 seconds"));
517    }
518
519    #[test]
520    fn timeout_display_shows_nonzero_duration() {
521        let err = ModelError::Timeout {
522            operation: "HTTP request".to_string(),
523            duration_secs: 45,
524        };
525        let rendered = err.to_string();
526        assert_eq!(
527            rendered,
528            "Operation 'HTTP request' timed out after 45 seconds"
529        );
530    }
531
532    #[test]
533    fn timeout_user_facing_omits_zero_duration() {
534        let err = ModelError::Timeout {
535            operation: "HTTP request".to_string(),
536            duration_secs: 0,
537        };
538        let ufe = err.to_user_facing();
539        assert_eq!(ufe.message, "'HTTP request' timed out");
540        assert!(!ufe.message.contains("0 seconds"));
541    }
542}