1use thiserror::Error;
2
3#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum GeminiErrorKind {
33 Authentication,
35 RateLimit,
37 QuotaExceeded,
39 ServerError,
41 NetworkError,
43 Unknown,
45}
46
47#[derive(Debug, Clone)]
49pub struct GeminiErrorDetails {
50 pub kind: GeminiErrorKind,
52 pub message: String,
54 pub status_code: u16,
56}
57
58impl GeminiErrorDetails {
59 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 #[error("Database error: {0}")]
86 DatabaseError(#[from] sqlx::Error),
87
88 #[error("API Client error: {0}")]
93 ClientError(String),
94
95 #[error("Gemini error: {0}")]
101 GeminiError(GeminiErrorDetails),
102
103 #[error("Serialization error: {0}")]
108 SerializationError(#[from] serde_json::Error),
109
110 #[error("Invalid URL: {0}")]
115 InvalidUrl(String),
116
117 #[error("Dataset not found: {0}")]
121 DatasetNotFound(String),
122
123 #[error("Invalid CKAN portal URL: {0}")]
128 InvalidPortalUrl(String),
129
130 #[error("Empty response from API")]
135 EmptyResponse,
136
137 #[error("Network error: {0}")]
142 NetworkError(String),
143
144 #[error("Request timed out after {0} seconds")]
148 Timeout(u64),
149
150 #[error("Rate limit exceeded. Please wait and try again.")]
154 RateLimitExceeded,
155
156 #[error("Configuration error: {0}")]
161 ConfigError(String),
162
163 #[error("Error: {0}")]
168 Generic(String),
169}
170
171impl AppError {
172 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 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 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}