1#[derive(Debug, thiserror::Error)]
8pub enum ConfigError {
9 #[error("missing required config field: {field}")]
11 MissingField {
12 field: String,
14 },
15
16 #[error("invalid value for config field '{field}': {message}")]
18 InvalidValue {
19 field: String,
21 message: String,
23 },
24
25 #[error("config file not found: {path}")]
27 FileNotFound {
28 path: String,
30 },
31
32 #[error("failed to parse config file: {source}")]
34 ParseError {
35 #[source]
37 source: toml::de::Error,
38 },
39}
40
41#[derive(Debug, thiserror::Error)]
43pub enum XApiError {
44 #[error("X API rate limited{}", match .retry_after {
46 Some(secs) => format!(", retry after {secs}s"),
47 None => String::new(),
48 })]
49 RateLimited {
50 retry_after: Option<u64>,
52 },
53
54 #[error("X API authentication expired, re-authentication required")]
56 AuthExpired,
57
58 #[error("X API account restricted: {message}")]
60 AccountRestricted {
61 message: String,
63 },
64
65 #[error("X API forbidden: {message}")]
67 Forbidden {
68 message: String,
70 },
71
72 #[error("X API scope insufficient: {message}")]
74 ScopeInsufficient {
75 message: String,
77 },
78
79 #[error("X API network error: {source}")]
81 Network {
82 #[source]
84 source: reqwest::Error,
85 },
86
87 #[error("X API error (HTTP {status}): {message}")]
89 ApiError {
90 status: u16,
92 message: String,
94 },
95
96 #[error("media upload failed: {message}")]
98 MediaUploadError {
99 message: String,
101 },
102
103 #[error("media processing timed out after {seconds}s")]
105 MediaProcessingTimeout {
106 seconds: u64,
108 },
109
110 #[error("scraper mutation blocked: {message}. Enable scraper_allow_mutations in config or switch to provider_backend = \"x_api\"")]
112 ScraperMutationBlocked {
113 message: String,
115 },
116
117 #[error("scraper transport unavailable: {message}")]
119 ScraperTransportUnavailable {
120 message: String,
122 },
123
124 #[error("feature requires X API authentication: {message}. Switch to provider_backend = \"x_api\" to use this feature")]
126 FeatureRequiresAuth {
127 message: String,
129 },
130}
131
132impl XApiError {
133 pub fn is_retryable(&self) -> bool {
139 match self {
140 XApiError::RateLimited { .. } => true,
142 XApiError::Network { .. } => true,
143 XApiError::ScraperTransportUnavailable { .. } => true,
144 XApiError::ApiError { status, .. } => *status >= 500,
146 XApiError::AuthExpired => false,
148 XApiError::AccountRestricted { .. } => false,
149 XApiError::Forbidden { .. } => false,
150 XApiError::ScopeInsufficient { .. } => false,
151 XApiError::FeatureRequiresAuth { .. } => false,
152 XApiError::ScraperMutationBlocked { .. } => false,
153 XApiError::MediaUploadError { .. } => false,
154 XApiError::MediaProcessingTimeout { .. } => false,
155 }
156 }
157}
158
159#[derive(Debug, thiserror::Error)]
161pub enum LlmError {
162 #[error("LLM HTTP request failed: {0}")]
164 Request(#[from] reqwest::Error),
165
166 #[error("LLM API error (status {status}): {message}")]
168 Api {
169 status: u16,
171 message: String,
173 },
174
175 #[error("LLM rate limited, retry after {retry_after_secs} seconds")]
177 RateLimited {
178 retry_after_secs: u64,
180 },
181
182 #[error("failed to parse LLM response: {0}")]
184 Parse(String),
185
186 #[error("no LLM provider configured")]
188 NotConfigured,
189
190 #[error("content generation failed: {0}")]
192 GenerationFailed(String),
193}
194
195#[derive(Debug, thiserror::Error)]
197pub enum StorageError {
198 #[error("database connection error: {source}")]
200 Connection {
201 #[source]
203 source: sqlx::Error,
204 },
205
206 #[error("database migration error: {source}")]
208 Migration {
209 #[source]
211 source: sqlx::migrate::MigrateError,
212 },
213
214 #[error("database query error: {source}")]
216 Query {
217 #[source]
219 source: sqlx::Error,
220 },
221
222 #[error("item {id} has already been reviewed (current status: {current_status})")]
224 AlreadyReviewed {
225 id: i64,
227 current_status: String,
229 },
230}
231
232#[derive(Debug, thiserror::Error)]
234pub enum ScoringError {
235 #[error("invalid tweet data for scoring: {message}")]
237 InvalidTweetData {
238 message: String,
240 },
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 #[test]
248 fn config_error_missing_field_message() {
249 let err = ConfigError::MissingField {
250 field: "business.product_name".to_string(),
251 };
252 assert_eq!(
253 err.to_string(),
254 "missing required config field: business.product_name"
255 );
256 }
257
258 #[test]
259 fn config_error_invalid_value_message() {
260 let err = ConfigError::InvalidValue {
261 field: "llm.provider".to_string(),
262 message: "must be openai, anthropic, or ollama".to_string(),
263 };
264 assert_eq!(
265 err.to_string(),
266 "invalid value for config field 'llm.provider': must be openai, anthropic, or ollama"
267 );
268 }
269
270 #[test]
271 fn config_error_file_not_found_message() {
272 let err = ConfigError::FileNotFound {
273 path: "/home/user/.tuitbot/config.toml".to_string(),
274 };
275 assert_eq!(
276 err.to_string(),
277 "config file not found: /home/user/.tuitbot/config.toml"
278 );
279 }
280
281 #[test]
282 fn x_api_error_rate_limited_with_retry() {
283 let err = XApiError::RateLimited {
284 retry_after: Some(30),
285 };
286 assert_eq!(err.to_string(), "X API rate limited, retry after 30s");
287 }
288
289 #[test]
290 fn x_api_error_rate_limited_without_retry() {
291 let err = XApiError::RateLimited { retry_after: None };
292 assert_eq!(err.to_string(), "X API rate limited");
293 }
294
295 #[test]
296 fn x_api_error_auth_expired_message() {
297 let err = XApiError::AuthExpired;
298 assert_eq!(
299 err.to_string(),
300 "X API authentication expired, re-authentication required"
301 );
302 }
303
304 #[test]
305 fn x_api_error_api_error_message() {
306 let err = XApiError::ApiError {
307 status: 403,
308 message: "Forbidden".to_string(),
309 };
310 assert_eq!(err.to_string(), "X API error (HTTP 403): Forbidden");
311 }
312
313 #[test]
314 fn x_api_error_scope_insufficient_message() {
315 let err = XApiError::ScopeInsufficient {
316 message: "missing tweet.write".to_string(),
317 };
318 assert_eq!(
319 err.to_string(),
320 "X API scope insufficient: missing tweet.write"
321 );
322 }
323
324 #[test]
325 fn llm_error_not_configured_message() {
326 let err = LlmError::NotConfigured;
327 assert_eq!(err.to_string(), "no LLM provider configured");
328 }
329
330 #[test]
331 fn llm_error_rate_limited_message() {
332 let err = LlmError::RateLimited {
333 retry_after_secs: 30,
334 };
335 assert_eq!(err.to_string(), "LLM rate limited, retry after 30 seconds");
336 }
337
338 #[test]
339 fn llm_error_parse_failure_message() {
340 let err = LlmError::Parse("unexpected JSON structure".to_string());
341 assert_eq!(
342 err.to_string(),
343 "failed to parse LLM response: unexpected JSON structure"
344 );
345 }
346
347 #[test]
348 fn llm_error_api_error_message() {
349 let err = LlmError::Api {
350 status: 401,
351 message: "Invalid API key".to_string(),
352 };
353 assert_eq!(
354 err.to_string(),
355 "LLM API error (status 401): Invalid API key"
356 );
357 }
358
359 #[test]
360 fn storage_error_already_reviewed_message() {
361 let err = StorageError::AlreadyReviewed {
362 id: 42,
363 current_status: "approved".to_string(),
364 };
365 assert_eq!(
366 err.to_string(),
367 "item 42 has already been reviewed (current status: approved)"
368 );
369 }
370
371 #[test]
372 fn scoring_error_invalid_tweet_data_message() {
373 let err = ScoringError::InvalidTweetData {
374 message: "missing author_id".to_string(),
375 };
376 assert_eq!(
377 err.to_string(),
378 "invalid tweet data for scoring: missing author_id"
379 );
380 }
381
382 #[test]
383 fn x_api_error_media_upload_message() {
384 let err = XApiError::MediaUploadError {
385 message: "file too large".to_string(),
386 };
387 assert_eq!(err.to_string(), "media upload failed: file too large");
388 }
389
390 #[test]
391 fn x_api_error_media_processing_timeout_message() {
392 let err = XApiError::MediaProcessingTimeout { seconds: 300 };
393 assert_eq!(err.to_string(), "media processing timed out after 300s");
394 }
395
396 #[test]
397 fn x_api_error_scraper_mutation_blocked_message() {
398 let err = XApiError::ScraperMutationBlocked {
399 message: "post_tweet".to_string(),
400 };
401 assert_eq!(
402 err.to_string(),
403 "scraper mutation blocked: post_tweet. Enable scraper_allow_mutations in config or switch to provider_backend = \"x_api\""
404 );
405 }
406
407 #[test]
408 fn x_api_error_scraper_transport_unavailable_message() {
409 let err = XApiError::ScraperTransportUnavailable {
410 message: "search_tweets: scraper transport not yet implemented".to_string(),
411 };
412 assert_eq!(
413 err.to_string(),
414 "scraper transport unavailable: search_tweets: scraper transport not yet implemented"
415 );
416 }
417
418 #[test]
419 fn x_api_error_feature_requires_auth_message() {
420 let err = XApiError::FeatureRequiresAuth {
421 message: "get_me requires authenticated API access".to_string(),
422 };
423 assert_eq!(
424 err.to_string(),
425 "feature requires X API authentication: get_me requires authenticated API access. Switch to provider_backend = \"x_api\" to use this feature"
426 );
427 }
428
429 #[test]
430 fn llm_error_generation_failed_message() {
431 let err = LlmError::GenerationFailed("max retries exceeded".to_string());
432 assert_eq!(
433 err.to_string(),
434 "content generation failed: max retries exceeded"
435 );
436 }
437
438 #[test]
439 fn x_api_error_account_restricted_message() {
440 let err = XApiError::AccountRestricted {
441 message: "Account suspended".to_string(),
442 };
443 assert_eq!(
444 err.to_string(),
445 "X API account restricted: Account suspended"
446 );
447 }
448
449 #[test]
450 fn x_api_error_forbidden_message() {
451 let err = XApiError::Forbidden {
452 message: "Basic tier cannot access this endpoint".to_string(),
453 };
454 assert_eq!(
455 err.to_string(),
456 "X API forbidden: Basic tier cannot access this endpoint"
457 );
458 }
459
460 #[test]
461 fn storage_error_connection_message() {
462 let err = StorageError::Connection {
463 source: sqlx::Error::Configuration("test config error".into()),
464 };
465 let msg = err.to_string();
466 assert!(msg.contains("database connection error"));
467 }
468
469 #[test]
470 fn storage_error_query_message() {
471 let err = StorageError::Query {
472 source: sqlx::Error::ColumnNotFound("missing_col".to_string()),
473 };
474 let msg = err.to_string();
475 assert!(msg.contains("database query error"));
476 }
477
478 #[test]
479 fn config_error_is_debug() {
480 let err = ConfigError::MissingField {
481 field: "test".to_string(),
482 };
483 let debug = format!("{err:?}");
484 assert!(debug.contains("MissingField"));
485 }
486
487 #[test]
488 fn x_api_error_is_debug() {
489 let err = XApiError::AuthExpired;
490 let debug = format!("{err:?}");
491 assert!(debug.contains("AuthExpired"));
492 }
493
494 #[test]
495 fn llm_error_is_debug() {
496 let err = LlmError::NotConfigured;
497 let debug = format!("{err:?}");
498 assert!(debug.contains("NotConfigured"));
499 }
500
501 #[test]
502 fn storage_error_is_debug() {
503 let err = StorageError::AlreadyReviewed {
504 id: 1,
505 current_status: "approved".to_string(),
506 };
507 let debug = format!("{err:?}");
508 assert!(debug.contains("AlreadyReviewed"));
509 }
510
511 #[test]
512 fn scoring_error_is_debug() {
513 let err = ScoringError::InvalidTweetData {
514 message: "test".to_string(),
515 };
516 let debug = format!("{err:?}");
517 assert!(debug.contains("InvalidTweetData"));
518 }
519
520 #[test]
521 fn x_api_error_rate_limited_with_retry_formats_seconds() {
522 let err = XApiError::RateLimited {
523 retry_after: Some(120),
524 };
525 assert!(err.to_string().contains("120s"));
526 }
527
528 #[test]
529 fn llm_error_api_error_includes_status_code() {
530 let err = LlmError::Api {
531 status: 500,
532 message: "Internal server error".to_string(),
533 };
534 let msg = err.to_string();
535 assert!(msg.contains("500"));
536 assert!(msg.contains("Internal server error"));
537 }
538}