ceres_core/
error.rs

1use thiserror::Error;
2
3/// Application-wide error types.
4///
5/// This enum represents all possible errors that can occur in the Ceres application.
6/// It uses the `thiserror` crate for ergonomic error handling and automatic conversion
7/// from underlying library errors.
8///
9/// # Error Conversion
10///
11/// Most errors automatically convert from their source types using the `#[from]` attribute:
12/// - `sqlx::Error` → `AppError::DatabaseError`
13/// - `reqwest::Error` → `AppError::ClientError`
14/// - `serde_json::Error` → `AppError::SerializationError`
15/// - `url::ParseError` → `AppError::InvalidUrl`
16///
17/// # Examples
18///
19/// ```no_run
20/// use ceres_core::error::AppError;
21///
22/// fn example() -> Result<(), AppError> {
23///     // Errors automatically convert
24///     Err(AppError::Generic("Something went wrong".to_string()))
25/// }
26/// ```
27///
28/// # Gemini Error Classification
29///
30/// Gemini API errors are classified into specific categories for better error handling.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum GeminiErrorKind {
33    /// Authentication failure (401, invalid API key)
34    Authentication,
35    /// Rate limit exceeded (429)
36    RateLimit,
37    /// Quota exceeded (insufficient_quota)
38    QuotaExceeded,
39    /// Server error (5xx)
40    ServerError,
41    /// Network/connection error
42    NetworkError,
43    /// Unknown or unclassified error
44    Unknown,
45}
46
47/// Structured error details from Gemini API
48#[derive(Debug, Clone)]
49pub struct GeminiErrorDetails {
50    /// The specific error category
51    pub kind: GeminiErrorKind,
52    /// Human-readable error message from the API
53    pub message: String,
54    /// HTTP status code
55    pub status_code: u16,
56}
57
58impl GeminiErrorDetails {
59    /// Create a new GeminiErrorDetails
60    pub fn new(kind: GeminiErrorKind, message: String, status_code: u16) -> Self {
61        Self {
62            kind,
63            message,
64            status_code,
65        }
66    }
67}
68
69impl std::fmt::Display for GeminiErrorDetails {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        write!(
72            f,
73            "Gemini API error (HTTP {}): {}",
74            self.status_code, self.message
75        )
76    }
77}
78
79#[derive(Error, Debug)]
80pub enum AppError {
81    /// Database operation failed.
82    ///
83    /// This error wraps all errors from SQLx database operations, including
84    /// connection failures, query errors, and constraint violations.
85    #[error("Database error: {0}")]
86    DatabaseError(#[from] sqlx::Error),
87
88    /// HTTP client request failed.
89    ///
90    /// This error occurs when HTTP requests fail due to network issues,
91    /// timeouts, or server errors.
92    #[error("API Client error: {0}")]
93    ClientError(String),
94
95    /// Gemini API call failed.
96    ///
97    /// This error occurs when Gemini API calls fail, including
98    /// authentication failures, rate limiting, and API errors.
99    /// Contains structured error information for better error handling.
100    #[error("Gemini error: {0}")]
101    GeminiError(GeminiErrorDetails),
102
103    /// JSON serialization or deserialization failed.
104    ///
105    /// This error occurs when converting between Rust types and JSON,
106    /// typically when parsing API responses or preparing database values.
107    #[error("Serialization error: {0}")]
108    SerializationError(#[from] serde_json::Error),
109
110    /// URL parsing failed.
111    ///
112    /// This error occurs when attempting to parse an invalid URL string,
113    /// typically when constructing API endpoints or validating portal URLs.
114    #[error("Invalid URL: {0}")]
115    InvalidUrl(String),
116
117    /// Dataset not found in the database.
118    ///
119    /// This error indicates that a requested dataset does not exist.
120    #[error("Dataset not found: {0}")]
121    DatasetNotFound(String),
122
123    /// Invalid CKAN portal URL provided.
124    ///
125    /// This error occurs when the provided CKAN portal URL is malformed
126    /// or cannot be used to construct valid API endpoints.
127    #[error("Invalid CKAN portal URL: {0}")]
128    InvalidPortalUrl(String),
129
130    /// API response contained no data.
131    ///
132    /// This error occurs when an API returns a successful status but
133    /// the response body is empty or missing expected data.
134    #[error("Empty response from API")]
135    EmptyResponse,
136
137    /// Network or connection error.
138    ///
139    /// This error occurs when a network request fails due to connectivity issues,
140    /// DNS resolution failures, or the remote server being unreachable.
141    #[error("Network error: {0}")]
142    NetworkError(String),
143
144    /// Request timeout.
145    ///
146    /// This error occurs when a request takes longer than the configured timeout.
147    #[error("Request timed out after {0} seconds")]
148    Timeout(u64),
149
150    /// Rate limit exceeded.
151    ///
152    /// This error occurs when too many requests are made in a short period.
153    #[error("Rate limit exceeded. Please wait and try again.")]
154    RateLimitExceeded,
155
156    /// Configuration file error.
157    ///
158    /// This error occurs when reading or parsing the configuration file fails,
159    /// such as when the portals.toml file is malformed or contains invalid values.
160    #[error("Configuration error: {0}")]
161    ConfigError(String),
162
163    /// Generic application error for cases not covered by specific variants.
164    ///
165    /// Use this sparingly - prefer creating specific error variants
166    /// for better error handling and debugging.
167    #[error("Error: {0}")]
168    Generic(String),
169}
170
171impl AppError {
172    /// Returns a user-friendly error message suitable for CLI output.
173    pub fn user_message(&self) -> String {
174        match self {
175            AppError::DatabaseError(e) => {
176                if e.to_string().contains("connection") {
177                    "Cannot connect to database. Is PostgreSQL running?\n   Try: docker-compose up -d".to_string()
178                } else {
179                    format!("Database error: {}", e)
180                }
181            }
182            AppError::ClientError(msg) => {
183                if msg.contains("timeout") || msg.contains("timed out") {
184                    "Request timed out. The portal may be slow or unreachable.\n   Try again later or check the portal URL.".to_string()
185                } else if msg.contains("connect") {
186                    format!("Cannot connect to portal: {}\n   Check your internet connection and the portal URL.", msg)
187                } else {
188                    format!("API error: {}", msg)
189                }
190            }
191            AppError::GeminiError(details) => match details.kind {
192                GeminiErrorKind::Authentication => {
193                    "Invalid Gemini API key.\n   Check your GEMINI_API_KEY environment variable."
194                        .to_string()
195                }
196                GeminiErrorKind::RateLimit => {
197                    "Gemini rate limit reached.\n   Wait a moment and try again, or reduce concurrency."
198                        .to_string()
199                }
200                GeminiErrorKind::QuotaExceeded => {
201                    "Gemini quota exceeded.\n   Check your Google account billing.".to_string()
202                }
203                GeminiErrorKind::ServerError => {
204                    format!(
205                        "Gemini server error (HTTP {}).\n   Please try again later.",
206                        details.status_code
207                    )
208                }
209                GeminiErrorKind::NetworkError => {
210                    format!(
211                        "Network error connecting to Gemini: {}\n   Check your internet connection.",
212                        details.message
213                    )
214                }
215                GeminiErrorKind::Unknown => {
216                    format!("Gemini error: {}", details.message)
217                }
218            },
219            AppError::InvalidPortalUrl(url) => {
220                format!(
221                    "Invalid portal URL: {}\n   Example: https://dati.comune.milano.it",
222                    url
223                )
224            }
225            AppError::NetworkError(msg) => {
226                format!("Network error: {}\n   Check your internet connection.", msg)
227            }
228            AppError::Timeout(secs) => {
229                format!("Request timed out after {} seconds.\n   The server may be overloaded. Try again later.", secs)
230            }
231            AppError::RateLimitExceeded => {
232                "Too many requests. Please wait a moment and try again.".to_string()
233            }
234            AppError::EmptyResponse => {
235                "The API returned no data. The portal may be temporarily unavailable.".to_string()
236            }
237            AppError::ConfigError(msg) => {
238                format!(
239                    "Configuration error: {}\n   Check your configuration file.",
240                    msg
241                )
242            }
243            _ => self.to_string(),
244        }
245    }
246
247    /// Returns true if this error is retryable.
248    ///
249    /// # Examples
250    ///
251    /// ```
252    /// use ceres_core::error::AppError;
253    ///
254    /// // Network errors are retryable
255    /// let err = AppError::NetworkError("connection reset".to_string());
256    /// assert!(err.is_retryable());
257    ///
258    /// // Rate limits are retryable (after a delay)
259    /// let err = AppError::RateLimitExceeded;
260    /// assert!(err.is_retryable());
261    ///
262    /// // Dataset not found is NOT retryable
263    /// let err = AppError::DatasetNotFound("test".to_string());
264    /// assert!(!err.is_retryable());
265    /// ```
266    pub fn is_retryable(&self) -> bool {
267        match self {
268            AppError::NetworkError(_)
269            | AppError::Timeout(_)
270            | AppError::RateLimitExceeded
271            | AppError::ClientError(_) => true,
272            AppError::GeminiError(details) => matches!(
273                details.kind,
274                GeminiErrorKind::RateLimit
275                    | GeminiErrorKind::NetworkError
276                    | GeminiErrorKind::ServerError
277            ),
278            _ => false,
279        }
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    #[test]
288    fn test_error_display() {
289        let err = AppError::DatasetNotFound("test-id".to_string());
290        assert_eq!(err.to_string(), "Dataset not found: test-id");
291    }
292
293    #[test]
294    fn test_generic_error() {
295        let err = AppError::Generic("Something went wrong".to_string());
296        assert_eq!(err.to_string(), "Error: Something went wrong");
297    }
298
299    #[test]
300    fn test_empty_response_error() {
301        let err = AppError::EmptyResponse;
302        assert_eq!(err.to_string(), "Empty response from API");
303    }
304
305    #[test]
306    fn test_user_message_gemini_auth() {
307        let details = GeminiErrorDetails::new(
308            GeminiErrorKind::Authentication,
309            "Invalid API key".to_string(),
310            401,
311        );
312        let err = AppError::GeminiError(details);
313        let msg = err.user_message();
314        assert!(msg.contains("Invalid Gemini API key"));
315        assert!(msg.contains("GEMINI_API_KEY"));
316    }
317
318    #[test]
319    fn test_user_message_gemini_rate_limit() {
320        let details = GeminiErrorDetails::new(
321            GeminiErrorKind::RateLimit,
322            "Rate limit exceeded".to_string(),
323            429,
324        );
325        let err = AppError::GeminiError(details);
326        let msg = err.user_message();
327        assert!(msg.contains("rate limit"));
328    }
329
330    #[test]
331    fn test_user_message_gemini_quota() {
332        let details = GeminiErrorDetails::new(
333            GeminiErrorKind::QuotaExceeded,
334            "Insufficient quota".to_string(),
335            429,
336        );
337        let err = AppError::GeminiError(details);
338        let msg = err.user_message();
339        assert!(msg.contains("quota exceeded"));
340        assert!(msg.contains("Google account billing"));
341    }
342
343    #[test]
344    fn test_gemini_error_display() {
345        let details = GeminiErrorDetails::new(
346            GeminiErrorKind::Authentication,
347            "Invalid API key".to_string(),
348            401,
349        );
350        let err = AppError::GeminiError(details);
351        assert!(err.to_string().contains("Gemini error"));
352        assert!(err.to_string().contains("401"));
353    }
354
355    #[test]
356    fn test_gemini_error_retryable() {
357        let rate_limit = AppError::GeminiError(GeminiErrorDetails::new(
358            GeminiErrorKind::RateLimit,
359            "Rate limit".to_string(),
360            429,
361        ));
362        assert!(rate_limit.is_retryable());
363
364        let auth_error = AppError::GeminiError(GeminiErrorDetails::new(
365            GeminiErrorKind::Authentication,
366            "Invalid key".to_string(),
367            401,
368        ));
369        assert!(!auth_error.is_retryable());
370
371        let server_error = AppError::GeminiError(GeminiErrorDetails::new(
372            GeminiErrorKind::ServerError,
373            "Internal server error".to_string(),
374            500,
375        ));
376        assert!(server_error.is_retryable());
377    }
378
379    #[test]
380    fn test_invalid_portal_url() {
381        let err = AppError::InvalidPortalUrl("not a url".to_string());
382        assert!(err.to_string().contains("Invalid CKAN portal URL"));
383    }
384
385    #[test]
386    fn test_error_from_serde() {
387        let json = "{ invalid json }";
388        let result: Result<serde_json::Value, _> = serde_json::from_str(json);
389        let serde_err = result.unwrap_err();
390        let app_err: AppError = serde_err.into();
391        assert!(matches!(app_err, AppError::SerializationError(_)));
392    }
393
394    #[test]
395    fn test_user_message_database_connection() {
396        // PoolTimedOut message contains "connection", so it triggers the connection error branch
397        let err = AppError::DatabaseError(sqlx::Error::PoolTimedOut);
398        let msg = err.user_message();
399        assert!(msg.contains("Cannot connect to database") || msg.contains("Database error"));
400    }
401
402    #[test]
403    fn test_is_retryable() {
404        assert!(AppError::NetworkError("timeout".to_string()).is_retryable());
405        assert!(AppError::Timeout(30).is_retryable());
406        assert!(AppError::RateLimitExceeded.is_retryable());
407        assert!(!AppError::InvalidPortalUrl("bad".to_string()).is_retryable());
408    }
409
410    #[test]
411    fn test_timeout_error() {
412        let err = AppError::Timeout(30);
413        assert_eq!(err.to_string(), "Request timed out after 30 seconds");
414    }
415}