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
132/// Errors from interacting with LLM providers (OpenAI, Anthropic, Ollama).
133#[derive(Debug, thiserror::Error)]
134pub enum LlmError {
135    /// HTTP request to the LLM endpoint failed.
136    #[error("LLM HTTP request failed: {0}")]
137    Request(#[from] reqwest::Error),
138
139    /// LLM API returned an error response.
140    #[error("LLM API error (status {status}): {message}")]
141    Api {
142        /// The HTTP status code.
143        status: u16,
144        /// The error message from the API.
145        message: String,
146    },
147
148    /// LLM provider rate limit hit.
149    #[error("LLM rate limited, retry after {retry_after_secs} seconds")]
150    RateLimited {
151        /// Seconds to wait before retrying.
152        retry_after_secs: u64,
153    },
154
155    /// LLM response could not be parsed.
156    #[error("failed to parse LLM response: {0}")]
157    Parse(String),
158
159    /// No LLM provider configured.
160    #[error("no LLM provider configured")]
161    NotConfigured,
162
163    /// Content generation failed after retries.
164    #[error("content generation failed: {0}")]
165    GenerationFailed(String),
166}
167
168/// Errors from SQLite storage operations.
169#[derive(Debug, thiserror::Error)]
170pub enum StorageError {
171    /// Failed to connect to SQLite database.
172    #[error("database connection error: {source}")]
173    Connection {
174        /// The underlying SQLx error.
175        #[source]
176        source: sqlx::Error,
177    },
178
179    /// Database migration failed.
180    #[error("database migration error: {source}")]
181    Migration {
182        /// The underlying migration error.
183        #[source]
184        source: sqlx::migrate::MigrateError,
185    },
186
187    /// A database query failed.
188    #[error("database query error: {source}")]
189    Query {
190        /// The underlying SQLx error.
191        #[source]
192        source: sqlx::Error,
193    },
194}
195
196/// Errors from the tweet scoring engine.
197#[derive(Debug, thiserror::Error)]
198pub enum ScoringError {
199    /// Tweet data is missing or malformed for scoring.
200    #[error("invalid tweet data for scoring: {message}")]
201    InvalidTweetData {
202        /// Details about what is missing or malformed.
203        message: String,
204    },
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn config_error_missing_field_message() {
213        let err = ConfigError::MissingField {
214            field: "business.product_name".to_string(),
215        };
216        assert_eq!(
217            err.to_string(),
218            "missing required config field: business.product_name"
219        );
220    }
221
222    #[test]
223    fn config_error_invalid_value_message() {
224        let err = ConfigError::InvalidValue {
225            field: "llm.provider".to_string(),
226            message: "must be openai, anthropic, or ollama".to_string(),
227        };
228        assert_eq!(
229            err.to_string(),
230            "invalid value for config field 'llm.provider': must be openai, anthropic, or ollama"
231        );
232    }
233
234    #[test]
235    fn config_error_file_not_found_message() {
236        let err = ConfigError::FileNotFound {
237            path: "/home/user/.tuitbot/config.toml".to_string(),
238        };
239        assert_eq!(
240            err.to_string(),
241            "config file not found: /home/user/.tuitbot/config.toml"
242        );
243    }
244
245    #[test]
246    fn x_api_error_rate_limited_with_retry() {
247        let err = XApiError::RateLimited {
248            retry_after: Some(30),
249        };
250        assert_eq!(err.to_string(), "X API rate limited, retry after 30s");
251    }
252
253    #[test]
254    fn x_api_error_rate_limited_without_retry() {
255        let err = XApiError::RateLimited { retry_after: None };
256        assert_eq!(err.to_string(), "X API rate limited");
257    }
258
259    #[test]
260    fn x_api_error_auth_expired_message() {
261        let err = XApiError::AuthExpired;
262        assert_eq!(
263            err.to_string(),
264            "X API authentication expired, re-authentication required"
265        );
266    }
267
268    #[test]
269    fn x_api_error_api_error_message() {
270        let err = XApiError::ApiError {
271            status: 403,
272            message: "Forbidden".to_string(),
273        };
274        assert_eq!(err.to_string(), "X API error (HTTP 403): Forbidden");
275    }
276
277    #[test]
278    fn x_api_error_scope_insufficient_message() {
279        let err = XApiError::ScopeInsufficient {
280            message: "missing tweet.write".to_string(),
281        };
282        assert_eq!(
283            err.to_string(),
284            "X API scope insufficient: missing tweet.write"
285        );
286    }
287
288    #[test]
289    fn llm_error_not_configured_message() {
290        let err = LlmError::NotConfigured;
291        assert_eq!(err.to_string(), "no LLM provider configured");
292    }
293
294    #[test]
295    fn llm_error_rate_limited_message() {
296        let err = LlmError::RateLimited {
297            retry_after_secs: 30,
298        };
299        assert_eq!(err.to_string(), "LLM rate limited, retry after 30 seconds");
300    }
301
302    #[test]
303    fn llm_error_parse_failure_message() {
304        let err = LlmError::Parse("unexpected JSON structure".to_string());
305        assert_eq!(
306            err.to_string(),
307            "failed to parse LLM response: unexpected JSON structure"
308        );
309    }
310
311    #[test]
312    fn llm_error_api_error_message() {
313        let err = LlmError::Api {
314            status: 401,
315            message: "Invalid API key".to_string(),
316        };
317        assert_eq!(
318            err.to_string(),
319            "LLM API error (status 401): Invalid API key"
320        );
321    }
322
323    #[test]
324    fn scoring_error_invalid_tweet_data_message() {
325        let err = ScoringError::InvalidTweetData {
326            message: "missing author_id".to_string(),
327        };
328        assert_eq!(
329            err.to_string(),
330            "invalid tweet data for scoring: missing author_id"
331        );
332    }
333
334    #[test]
335    fn x_api_error_media_upload_message() {
336        let err = XApiError::MediaUploadError {
337            message: "file too large".to_string(),
338        };
339        assert_eq!(err.to_string(), "media upload failed: file too large");
340    }
341
342    #[test]
343    fn x_api_error_media_processing_timeout_message() {
344        let err = XApiError::MediaProcessingTimeout { seconds: 300 };
345        assert_eq!(err.to_string(), "media processing timed out after 300s");
346    }
347
348    #[test]
349    fn x_api_error_scraper_mutation_blocked_message() {
350        let err = XApiError::ScraperMutationBlocked {
351            message: "post_tweet".to_string(),
352        };
353        assert_eq!(
354            err.to_string(),
355            "scraper mutation blocked: post_tweet. Enable scraper_allow_mutations in config or switch to provider_backend = \"x_api\""
356        );
357    }
358
359    #[test]
360    fn x_api_error_scraper_transport_unavailable_message() {
361        let err = XApiError::ScraperTransportUnavailable {
362            message: "search_tweets: scraper transport not yet implemented".to_string(),
363        };
364        assert_eq!(
365            err.to_string(),
366            "scraper transport unavailable: search_tweets: scraper transport not yet implemented"
367        );
368    }
369
370    #[test]
371    fn x_api_error_feature_requires_auth_message() {
372        let err = XApiError::FeatureRequiresAuth {
373            message: "get_me requires authenticated API access".to_string(),
374        };
375        assert_eq!(
376            err.to_string(),
377            "feature requires X API authentication: get_me requires authenticated API access. Switch to provider_backend = \"x_api\" to use this feature"
378        );
379    }
380}