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
111/// Errors from interacting with LLM providers (OpenAI, Anthropic, Ollama).
112#[derive(Debug, thiserror::Error)]
113pub enum LlmError {
114    /// HTTP request to the LLM endpoint failed.
115    #[error("LLM HTTP request failed: {0}")]
116    Request(#[from] reqwest::Error),
117
118    /// LLM API returned an error response.
119    #[error("LLM API error (status {status}): {message}")]
120    Api {
121        /// The HTTP status code.
122        status: u16,
123        /// The error message from the API.
124        message: String,
125    },
126
127    /// LLM provider rate limit hit.
128    #[error("LLM rate limited, retry after {retry_after_secs} seconds")]
129    RateLimited {
130        /// Seconds to wait before retrying.
131        retry_after_secs: u64,
132    },
133
134    /// LLM response could not be parsed.
135    #[error("failed to parse LLM response: {0}")]
136    Parse(String),
137
138    /// No LLM provider configured.
139    #[error("no LLM provider configured")]
140    NotConfigured,
141
142    /// Content generation failed after retries.
143    #[error("content generation failed: {0}")]
144    GenerationFailed(String),
145}
146
147/// Errors from SQLite storage operations.
148#[derive(Debug, thiserror::Error)]
149pub enum StorageError {
150    /// Failed to connect to SQLite database.
151    #[error("database connection error: {source}")]
152    Connection {
153        /// The underlying SQLx error.
154        #[source]
155        source: sqlx::Error,
156    },
157
158    /// Database migration failed.
159    #[error("database migration error: {source}")]
160    Migration {
161        /// The underlying migration error.
162        #[source]
163        source: sqlx::migrate::MigrateError,
164    },
165
166    /// A database query failed.
167    #[error("database query error: {source}")]
168    Query {
169        /// The underlying SQLx error.
170        #[source]
171        source: sqlx::Error,
172    },
173}
174
175/// Errors from the tweet scoring engine.
176#[derive(Debug, thiserror::Error)]
177pub enum ScoringError {
178    /// Tweet data is missing or malformed for scoring.
179    #[error("invalid tweet data for scoring: {message}")]
180    InvalidTweetData {
181        /// Details about what is missing or malformed.
182        message: String,
183    },
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn config_error_missing_field_message() {
192        let err = ConfigError::MissingField {
193            field: "business.product_name".to_string(),
194        };
195        assert_eq!(
196            err.to_string(),
197            "missing required config field: business.product_name"
198        );
199    }
200
201    #[test]
202    fn config_error_invalid_value_message() {
203        let err = ConfigError::InvalidValue {
204            field: "llm.provider".to_string(),
205            message: "must be openai, anthropic, or ollama".to_string(),
206        };
207        assert_eq!(
208            err.to_string(),
209            "invalid value for config field 'llm.provider': must be openai, anthropic, or ollama"
210        );
211    }
212
213    #[test]
214    fn config_error_file_not_found_message() {
215        let err = ConfigError::FileNotFound {
216            path: "/home/user/.tuitbot/config.toml".to_string(),
217        };
218        assert_eq!(
219            err.to_string(),
220            "config file not found: /home/user/.tuitbot/config.toml"
221        );
222    }
223
224    #[test]
225    fn x_api_error_rate_limited_with_retry() {
226        let err = XApiError::RateLimited {
227            retry_after: Some(30),
228        };
229        assert_eq!(err.to_string(), "X API rate limited, retry after 30s");
230    }
231
232    #[test]
233    fn x_api_error_rate_limited_without_retry() {
234        let err = XApiError::RateLimited { retry_after: None };
235        assert_eq!(err.to_string(), "X API rate limited");
236    }
237
238    #[test]
239    fn x_api_error_auth_expired_message() {
240        let err = XApiError::AuthExpired;
241        assert_eq!(
242            err.to_string(),
243            "X API authentication expired, re-authentication required"
244        );
245    }
246
247    #[test]
248    fn x_api_error_api_error_message() {
249        let err = XApiError::ApiError {
250            status: 403,
251            message: "Forbidden".to_string(),
252        };
253        assert_eq!(err.to_string(), "X API error (HTTP 403): Forbidden");
254    }
255
256    #[test]
257    fn x_api_error_scope_insufficient_message() {
258        let err = XApiError::ScopeInsufficient {
259            message: "missing tweet.write".to_string(),
260        };
261        assert_eq!(
262            err.to_string(),
263            "X API scope insufficient: missing tweet.write"
264        );
265    }
266
267    #[test]
268    fn llm_error_not_configured_message() {
269        let err = LlmError::NotConfigured;
270        assert_eq!(err.to_string(), "no LLM provider configured");
271    }
272
273    #[test]
274    fn llm_error_rate_limited_message() {
275        let err = LlmError::RateLimited {
276            retry_after_secs: 30,
277        };
278        assert_eq!(err.to_string(), "LLM rate limited, retry after 30 seconds");
279    }
280
281    #[test]
282    fn llm_error_parse_failure_message() {
283        let err = LlmError::Parse("unexpected JSON structure".to_string());
284        assert_eq!(
285            err.to_string(),
286            "failed to parse LLM response: unexpected JSON structure"
287        );
288    }
289
290    #[test]
291    fn llm_error_api_error_message() {
292        let err = LlmError::Api {
293            status: 401,
294            message: "Invalid API key".to_string(),
295        };
296        assert_eq!(
297            err.to_string(),
298            "LLM API error (status 401): Invalid API key"
299        );
300    }
301
302    #[test]
303    fn scoring_error_invalid_tweet_data_message() {
304        let err = ScoringError::InvalidTweetData {
305            message: "missing author_id".to_string(),
306        };
307        assert_eq!(
308            err.to_string(),
309            "invalid tweet data for scoring: missing author_id"
310        );
311    }
312
313    #[test]
314    fn x_api_error_media_upload_message() {
315        let err = XApiError::MediaUploadError {
316            message: "file too large".to_string(),
317        };
318        assert_eq!(err.to_string(), "media upload failed: file too large");
319    }
320
321    #[test]
322    fn x_api_error_media_processing_timeout_message() {
323        let err = XApiError::MediaProcessingTimeout { seconds: 300 };
324        assert_eq!(err.to_string(), "media processing timed out after 300s");
325    }
326}