Skip to main content

tuitbot_core/
error.rs

1//! Error types for the Tuitbot core library.
2//!
3//! Each module has its own error enum to provide clear error boundaries.
4//! The library uses `thiserror` for structured, typed errors.
5
6/// Errors related to configuration loading, parsing, and validation.
7#[derive(Debug, thiserror::Error)]
8pub enum ConfigError {
9    /// A required configuration field is absent.
10    #[error("missing required config field: {field}")]
11    MissingField {
12        /// The name of the missing field.
13        field: String,
14    },
15
16    /// A configuration field has an unacceptable value.
17    #[error("invalid value for config field '{field}': {message}")]
18    InvalidValue {
19        /// The name of the invalid field.
20        field: String,
21        /// A description of why the value is invalid.
22        message: String,
23    },
24
25    /// The configuration file does not exist at the specified path.
26    #[error("config file not found: {path}")]
27    FileNotFound {
28        /// The path that was searched.
29        path: String,
30    },
31
32    /// TOML deserialization failed.
33    #[error("failed to parse config file: {source}")]
34    ParseError {
35        /// The underlying TOML parse error.
36        #[source]
37        source: toml::de::Error,
38    },
39}
40
41/// Errors from interacting with the X (Twitter) API.
42#[derive(Debug, thiserror::Error)]
43pub enum XApiError {
44    /// X API returned HTTP 429 (rate limited).
45    #[error("X API rate limited{}", match .retry_after {
46        Some(secs) => format!(", retry after {secs}s"),
47        None => String::new(),
48    })]
49    RateLimited {
50        /// Seconds to wait before retrying, if provided by the API.
51        retry_after: Option<u64>,
52    },
53
54    /// OAuth token is expired and refresh failed.
55    #[error("X API authentication expired, re-authentication required")]
56    AuthExpired,
57
58    /// Account is suspended or limited.
59    #[error("X API account restricted: {message}")]
60    AccountRestricted {
61        /// Details about the restriction.
62        message: String,
63    },
64
65    /// X API returned HTTP 403 (forbidden / tier restriction).
66    #[error("X API forbidden: {message}")]
67    Forbidden {
68        /// Details about why access is forbidden.
69        message: String,
70    },
71
72    /// OAuth scope insufficient for the requested operation.
73    #[error("X API scope insufficient: {message}")]
74    ScopeInsufficient {
75        /// Details about missing or insufficient scopes.
76        message: String,
77    },
78
79    /// Network-level failure communicating with X API.
80    #[error("X API network error: {source}")]
81    Network {
82        /// The underlying HTTP client error.
83        #[source]
84        source: reqwest::Error,
85    },
86
87    /// Any other X API error response.
88    #[error("X API error (HTTP {status}): {message}")]
89    ApiError {
90        /// The HTTP status code.
91        status: u16,
92        /// The error message from the API.
93        message: String,
94    },
95
96    /// Media upload failed.
97    #[error("media upload failed: {message}")]
98    MediaUploadError {
99        /// Details about the upload failure.
100        message: String,
101    },
102
103    /// Media processing timed out after waiting for the specified duration.
104    #[error("media processing timed out after {seconds}s")]
105    MediaProcessingTimeout {
106        /// Number of seconds waited before timing out.
107        seconds: u64,
108    },
109
110    /// Mutation blocked because scraper backend has mutations disabled.
111    #[error("scraper mutation blocked: {message}. Enable scraper_allow_mutations in config or switch to provider_backend = \"x_api\"")]
112    ScraperMutationBlocked {
113        /// The operation that was blocked.
114        message: String,
115    },
116
117    /// Scraper transport is unavailable or not yet implemented.
118    #[error("scraper transport unavailable: {message}")]
119    ScraperTransportUnavailable {
120        /// Details about the transport issue.
121        message: String,
122    },
123
124    /// Feature requires authenticated X API access (not available in scraper mode).
125    #[error("feature requires X API authentication: {message}. Switch to provider_backend = \"x_api\" to use this feature")]
126    FeatureRequiresAuth {
127        /// The feature or method that requires authentication.
128        message: String,
129    },
130}
131
132impl XApiError {
133    /// Returns `true` for transient errors where a retry may succeed.
134    ///
135    /// Non-retryable: auth failures (401/403), scope issues, permanent
136    /// client errors. Retryable: network errors, server errors (5xx),
137    /// rate limits, and scraper transport issues.
138    pub fn is_retryable(&self) -> bool {
139        match self {
140            // Transient: retry after a delay.
141            XApiError::RateLimited { .. } => true,
142            XApiError::Network { .. } => true,
143            XApiError::ScraperTransportUnavailable { .. } => true,
144            // Retryable server errors (5xx); non-retryable client errors (4xx).
145            XApiError::ApiError { status, .. } => *status >= 500,
146            // Non-retryable: credentials, permissions, or permanent client errors.
147            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/// Errors from interacting with LLM providers (OpenAI, Anthropic, Ollama).
160#[derive(Debug, thiserror::Error)]
161pub enum LlmError {
162    /// HTTP request to the LLM endpoint failed.
163    #[error("LLM HTTP request failed: {0}")]
164    Request(#[from] reqwest::Error),
165
166    /// LLM API returned an error response.
167    #[error("LLM API error (status {status}): {message}")]
168    Api {
169        /// The HTTP status code.
170        status: u16,
171        /// The error message from the API.
172        message: String,
173    },
174
175    /// LLM provider rate limit hit.
176    #[error("LLM rate limited, retry after {retry_after_secs} seconds")]
177    RateLimited {
178        /// Seconds to wait before retrying.
179        retry_after_secs: u64,
180    },
181
182    /// LLM response could not be parsed.
183    #[error("failed to parse LLM response: {0}")]
184    Parse(String),
185
186    /// No LLM provider configured.
187    #[error("no LLM provider configured")]
188    NotConfigured,
189
190    /// Content generation failed after retries.
191    #[error("content generation failed: {0}")]
192    GenerationFailed(String),
193}
194
195/// Errors from SQLite storage operations.
196#[derive(Debug, thiserror::Error)]
197pub enum StorageError {
198    /// Failed to connect to SQLite database.
199    #[error("database connection error: {source}")]
200    Connection {
201        /// The underlying SQLx error.
202        #[source]
203        source: sqlx::Error,
204    },
205
206    /// Database migration failed.
207    #[error("database migration error: {source}")]
208    Migration {
209        /// The underlying migration error.
210        #[source]
211        source: sqlx::migrate::MigrateError,
212    },
213
214    /// A database query failed.
215    #[error("database query error: {source}")]
216    Query {
217        /// The underlying SQLx error.
218        #[source]
219        source: sqlx::Error,
220    },
221
222    /// An approval item has already been reviewed and cannot be re-reviewed.
223    #[error("item {id} has already been reviewed (current status: {current_status})")]
224    AlreadyReviewed {
225        /// The approval queue item ID.
226        id: i64,
227        /// The current status of the item.
228        current_status: String,
229    },
230}
231
232/// Errors from the tweet scoring engine.
233#[derive(Debug, thiserror::Error)]
234pub enum ScoringError {
235    /// Tweet data is missing or malformed for scoring.
236    #[error("invalid tweet data for scoring: {message}")]
237    InvalidTweetData {
238        /// Details about what is missing or malformed.
239        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}