use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserFacingError {
pub summary: String,
pub message: String,
pub suggestion: String,
pub category: ErrorCategory,
pub recoverable: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ErrorCategory {
Connection,
Auth,
Config,
NotFound,
Temporary,
Internal,
}
#[derive(Debug)]
pub enum ModelError {
Backend(BackendError),
Config(ConfigError),
ModelNotFound { model: String, searched: Vec<String> },
Timeout { operation: String, duration_secs: u64 },
RateLimit { retry_after: Option<u64> },
InvalidRequest(String),
ParseError { message: String, raw: Option<String> },
StreamError(String),
Authentication(String),
}
impl fmt::Display for ModelError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ModelError::Backend(e) => write!(f, "Backend error: {}", e),
ModelError::Config(e) => write!(f, "Configuration error: {}", e),
ModelError::ModelNotFound { model, searched } => {
write!(f, "Model '{}' not found. Searched: {}", model, searched.join(", "))
}
ModelError::Timeout { operation, duration_secs } => {
write!(f, "Operation '{}' timed out after {} seconds", operation, duration_secs)
}
ModelError::RateLimit { retry_after } => {
if let Some(secs) = retry_after {
write!(f, "Rate limit exceeded. Retry after {} seconds", secs)
} else {
write!(f, "Rate limit exceeded")
}
}
ModelError::InvalidRequest(msg) => write!(f, "Invalid request: {}", msg),
ModelError::ParseError { message, raw } => {
if let Some(r) = raw {
write!(f, "Parse error: {} (raw: {})", message, r)
} else {
write!(f, "Parse error: {}", message)
}
}
ModelError::StreamError(msg) => write!(f, "Stream error: {}", msg),
ModelError::Authentication(msg) => write!(f, "Authentication error: {}", msg),
}
}
}
impl std::error::Error for ModelError {}
impl ModelError {
pub fn to_user_facing(&self) -> UserFacingError {
match self {
ModelError::Backend(BackendError::ConnectionFailed { backend, url, .. }) => {
UserFacingError {
summary: format!("{} connection failed", backend),
message: format!("Could not connect to {} at {}", backend, url),
suggestion: if backend == "ollama" {
"Run 'ollama serve' to start Ollama, or check if it's running on the correct port".to_string()
} else {
format!("Check if {} is running and accessible", backend)
},
category: ErrorCategory::Connection,
recoverable: true,
}
}
ModelError::Backend(BackendError::NotAvailable { backend, reason }) => {
UserFacingError {
summary: format!("{} unavailable", backend),
message: format!("{} is not available: {}", backend, reason),
suggestion: if backend == "ollama" {
"Start Ollama with 'ollama serve' or pull the model with 'ollama pull <model>'".to_string()
} else {
format!("Ensure {} service is running and healthy", backend)
},
category: ErrorCategory::Connection,
recoverable: true,
}
}
ModelError::Backend(BackendError::HttpError { status, message }) => {
let (summary, suggestion) = match status {
401 | 403 => ("Authentication failed", "Check your API key in ~/.config/mermaid/config.toml"),
404 => ("Model not found", "Use :model <name> to switch models (auto-pulls if needed), or pull manually with 'ollama pull <name>'"),
429 => ("Rate limited", "Wait a moment before retrying, or switch to a local model"),
500..=599 => ("Server error", "The backend service is experiencing issues - try again later"),
_ => ("Request failed", "Check your network connection and backend configuration"),
};
UserFacingError {
summary: summary.to_string(),
message: format!("HTTP {}: {}", status, message),
suggestion: suggestion.to_string(),
category: if *status == 401 || *status == 403 {
ErrorCategory::Auth
} else if *status == 429 {
ErrorCategory::Temporary
} else {
ErrorCategory::Internal
},
recoverable: *status == 429 || *status >= 500,
}
}
ModelError::Backend(BackendError::UnexpectedResponse { backend, message }) => {
UserFacingError {
summary: "Unexpected response".to_string(),
message: format!("Received unexpected response from {}: {}", backend, message),
suggestion: "This might be a version mismatch - try updating the backend".to_string(),
category: ErrorCategory::Internal,
recoverable: false,
}
}
ModelError::Backend(BackendError::ProviderError { provider, code, message }) => {
let code_str = code.as_deref().unwrap_or("unknown");
UserFacingError {
summary: format!("{} error", provider),
message: format!("{} returned error {}: {}", provider, code_str, message),
suggestion: format!("Check {} documentation for error code {}", provider, code_str),
category: ErrorCategory::Internal,
recoverable: false,
}
}
ModelError::Config(ConfigError::MissingRequired(field)) => {
UserFacingError {
summary: "Missing configuration".to_string(),
message: format!("Required configuration '{}' is missing", field),
suggestion: format!("Add '{}' to ~/.config/mermaid/config.toml", field),
category: ErrorCategory::Config,
recoverable: false,
}
}
ModelError::Config(ConfigError::InvalidValue { field, value, reason }) => {
UserFacingError {
summary: "Invalid configuration".to_string(),
message: format!("Invalid value '{}' for '{}': {}", value, field, reason),
suggestion: format!("Fix '{}' in ~/.config/mermaid/config.toml", field),
category: ErrorCategory::Config,
recoverable: false,
}
}
ModelError::Config(ConfigError::FileError { path, reason }) => {
UserFacingError {
summary: "Config file error".to_string(),
message: format!("Cannot read config file '{}': {}", path, reason),
suggestion: "Check file permissions and syntax".to_string(),
category: ErrorCategory::Config,
recoverable: false,
}
}
ModelError::ModelNotFound { model, searched } => {
UserFacingError {
summary: "Model not found".to_string(),
message: format!("Model '{}' not found in: {}", model, searched.join(", ")),
suggestion: format!(
"Pull the model with 'ollama pull {}' or check if the model name is correct",
model
),
category: ErrorCategory::NotFound,
recoverable: false,
}
}
ModelError::Timeout { operation, duration_secs } => {
UserFacingError {
summary: "Request timed out".to_string(),
message: format!("'{}' timed out after {} seconds", operation, duration_secs),
suggestion: "The model might be overloaded - try a smaller model or wait and retry".to_string(),
category: ErrorCategory::Temporary,
recoverable: true,
}
}
ModelError::RateLimit { retry_after } => {
let wait_msg = retry_after
.map(|s| format!("Wait {} seconds", s))
.unwrap_or_else(|| "Wait a moment".to_string());
UserFacingError {
summary: "Rate limited".to_string(),
message: "Too many requests - rate limit exceeded".to_string(),
suggestion: format!("{}. Consider using a local Ollama model to avoid rate limits", wait_msg),
category: ErrorCategory::Temporary,
recoverable: true,
}
}
ModelError::InvalidRequest(msg) => {
UserFacingError {
summary: "Invalid request".to_string(),
message: format!("The request was invalid: {}", msg),
suggestion: "Check your message format or try rephrasing".to_string(),
category: ErrorCategory::Internal,
recoverable: false,
}
}
ModelError::ParseError { message, .. } => {
UserFacingError {
summary: "Parse error".to_string(),
message: format!("Failed to parse response: {}", message),
suggestion: "The model returned an unexpected format - try sending the message again".to_string(),
category: ErrorCategory::Internal,
recoverable: true,
}
}
ModelError::StreamError(msg) => {
UserFacingError {
summary: "Stream interrupted".to_string(),
message: format!("Connection lost during streaming: {}", msg),
suggestion: "Check your network connection and try again".to_string(),
category: ErrorCategory::Connection,
recoverable: true,
}
}
ModelError::Authentication(msg) => {
UserFacingError {
summary: "Authentication failed".to_string(),
message: format!("Authentication error: {}", msg),
suggestion: "Check your API key in ~/.config/mermaid/config.toml or environment variables".to_string(),
category: ErrorCategory::Auth,
recoverable: false,
}
}
}
}
pub fn to_channel_message(&self) -> String {
let user_facing = self.to_user_facing();
serde_json::to_string(&user_facing).unwrap_or_else(|_| {
format!("{}|{}|{}", user_facing.summary, user_facing.message, user_facing.suggestion)
})
}
}
#[derive(Debug)]
pub enum BackendError {
ConnectionFailed { backend: String, url: String, reason: String },
NotAvailable { backend: String, reason: String },
HttpError { status: u16, message: String },
UnexpectedResponse { backend: String, message: String },
ProviderError { provider: String, code: Option<String>, message: String },
}
impl fmt::Display for BackendError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
BackendError::ConnectionFailed { backend, url, reason } => {
write!(f, "Failed to connect to {} at {}: {}", backend, url, reason)
}
BackendError::NotAvailable { backend, reason } => {
write!(f, "Backend '{}' not available: {}", backend, reason)
}
BackendError::HttpError { status, message } => {
write!(f, "HTTP error {}: {}", status, message)
}
BackendError::UnexpectedResponse { backend, message } => {
write!(f, "Unexpected response from {}: {}", backend, message)
}
BackendError::ProviderError { provider, code, message } => {
if let Some(c) = code {
write!(f, "{} error {}: {}", provider, c, message)
} else {
write!(f, "{} error: {}", provider, message)
}
}
}
}
}
impl std::error::Error for BackendError {}
#[derive(Debug)]
pub enum ConfigError {
MissingRequired(String),
InvalidValue { field: String, value: String, reason: String },
FileError { path: String, reason: String },
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConfigError::MissingRequired(field) => {
write!(f, "Missing required configuration: {}", field)
}
ConfigError::InvalidValue { field, value, reason } => {
write!(f, "Invalid value for '{}': '{}' ({})", field, value, reason)
}
ConfigError::FileError { path, reason } => {
write!(f, "Error reading config file '{}': {}", path, reason)
}
}
}
}
impl std::error::Error for ConfigError {}
pub type Result<T> = std::result::Result<T, ModelError>;
impl From<anyhow::Error> for ModelError {
fn from(err: anyhow::Error) -> Self {
ModelError::InvalidRequest(err.to_string())
}
}
impl From<reqwest::Error> for ModelError {
fn from(err: reqwest::Error) -> Self {
if err.is_timeout() {
ModelError::Timeout {
operation: "HTTP request".to_string(),
duration_secs: 120,
}
} else if err.is_connect() {
ModelError::Backend(BackendError::ConnectionFailed {
backend: "unknown".to_string(),
url: err.url().map(|u| u.to_string()).unwrap_or_else(|| "unknown".to_string()),
reason: err.to_string(),
})
} else if err.is_status() {
let status = err.status().map(|s| s.as_u16()).unwrap_or(500);
ModelError::Backend(BackendError::HttpError {
status,
message: err.to_string(),
})
} else {
ModelError::Backend(BackendError::UnexpectedResponse {
backend: "unknown".to_string(),
message: err.to_string(),
})
}
}
}
impl From<serde_json::Error> for ModelError {
fn from(err: serde_json::Error) -> Self {
ModelError::ParseError {
message: err.to_string(),
raw: None,
}
}
}