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    /// Network-level failure communicating with X API.
73    #[error("X API network error: {source}")]
74    Network {
75        /// The underlying HTTP client error.
76        #[source]
77        source: reqwest::Error,
78    },
79
80    /// Any other X API error response.
81    #[error("X API error (HTTP {status}): {message}")]
82    ApiError {
83        /// The HTTP status code.
84        status: u16,
85        /// The error message from the API.
86        message: String,
87    },
88}
89
90/// Errors from interacting with LLM providers (OpenAI, Anthropic, Ollama).
91#[derive(Debug, thiserror::Error)]
92pub enum LlmError {
93    /// HTTP request to the LLM endpoint failed.
94    #[error("LLM HTTP request failed: {0}")]
95    Request(#[from] reqwest::Error),
96
97    /// LLM API returned an error response.
98    #[error("LLM API error (status {status}): {message}")]
99    Api {
100        /// The HTTP status code.
101        status: u16,
102        /// The error message from the API.
103        message: String,
104    },
105
106    /// LLM provider rate limit hit.
107    #[error("LLM rate limited, retry after {retry_after_secs} seconds")]
108    RateLimited {
109        /// Seconds to wait before retrying.
110        retry_after_secs: u64,
111    },
112
113    /// LLM response could not be parsed.
114    #[error("failed to parse LLM response: {0}")]
115    Parse(String),
116
117    /// No LLM provider configured.
118    #[error("no LLM provider configured")]
119    NotConfigured,
120
121    /// Content generation failed after retries.
122    #[error("content generation failed: {0}")]
123    GenerationFailed(String),
124}
125
126/// Errors from SQLite storage operations.
127#[derive(Debug, thiserror::Error)]
128pub enum StorageError {
129    /// Failed to connect to SQLite database.
130    #[error("database connection error: {source}")]
131    Connection {
132        /// The underlying SQLx error.
133        #[source]
134        source: sqlx::Error,
135    },
136
137    /// Database migration failed.
138    #[error("database migration error: {source}")]
139    Migration {
140        /// The underlying migration error.
141        #[source]
142        source: sqlx::migrate::MigrateError,
143    },
144
145    /// A database query failed.
146    #[error("database query error: {source}")]
147    Query {
148        /// The underlying SQLx error.
149        #[source]
150        source: sqlx::Error,
151    },
152}
153
154/// Errors from the tweet scoring engine.
155#[derive(Debug, thiserror::Error)]
156pub enum ScoringError {
157    /// Tweet data is missing or malformed for scoring.
158    #[error("invalid tweet data for scoring: {message}")]
159    InvalidTweetData {
160        /// Details about what is missing or malformed.
161        message: String,
162    },
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn config_error_missing_field_message() {
171        let err = ConfigError::MissingField {
172            field: "business.product_name".to_string(),
173        };
174        assert_eq!(
175            err.to_string(),
176            "missing required config field: business.product_name"
177        );
178    }
179
180    #[test]
181    fn config_error_invalid_value_message() {
182        let err = ConfigError::InvalidValue {
183            field: "llm.provider".to_string(),
184            message: "must be openai, anthropic, or ollama".to_string(),
185        };
186        assert_eq!(
187            err.to_string(),
188            "invalid value for config field 'llm.provider': must be openai, anthropic, or ollama"
189        );
190    }
191
192    #[test]
193    fn config_error_file_not_found_message() {
194        let err = ConfigError::FileNotFound {
195            path: "/home/user/.tuitbot/config.toml".to_string(),
196        };
197        assert_eq!(
198            err.to_string(),
199            "config file not found: /home/user/.tuitbot/config.toml"
200        );
201    }
202
203    #[test]
204    fn x_api_error_rate_limited_with_retry() {
205        let err = XApiError::RateLimited {
206            retry_after: Some(30),
207        };
208        assert_eq!(err.to_string(), "X API rate limited, retry after 30s");
209    }
210
211    #[test]
212    fn x_api_error_rate_limited_without_retry() {
213        let err = XApiError::RateLimited { retry_after: None };
214        assert_eq!(err.to_string(), "X API rate limited");
215    }
216
217    #[test]
218    fn x_api_error_auth_expired_message() {
219        let err = XApiError::AuthExpired;
220        assert_eq!(
221            err.to_string(),
222            "X API authentication expired, re-authentication required"
223        );
224    }
225
226    #[test]
227    fn x_api_error_api_error_message() {
228        let err = XApiError::ApiError {
229            status: 403,
230            message: "Forbidden".to_string(),
231        };
232        assert_eq!(err.to_string(), "X API error (HTTP 403): Forbidden");
233    }
234
235    #[test]
236    fn llm_error_not_configured_message() {
237        let err = LlmError::NotConfigured;
238        assert_eq!(err.to_string(), "no LLM provider configured");
239    }
240
241    #[test]
242    fn llm_error_rate_limited_message() {
243        let err = LlmError::RateLimited {
244            retry_after_secs: 30,
245        };
246        assert_eq!(err.to_string(), "LLM rate limited, retry after 30 seconds");
247    }
248
249    #[test]
250    fn llm_error_parse_failure_message() {
251        let err = LlmError::Parse("unexpected JSON structure".to_string());
252        assert_eq!(
253            err.to_string(),
254            "failed to parse LLM response: unexpected JSON structure"
255        );
256    }
257
258    #[test]
259    fn llm_error_api_error_message() {
260        let err = LlmError::Api {
261            status: 401,
262            message: "Invalid API key".to_string(),
263        };
264        assert_eq!(
265            err.to_string(),
266            "LLM API error (status 401): Invalid API key"
267        );
268    }
269
270    #[test]
271    fn scoring_error_invalid_tweet_data_message() {
272        let err = ScoringError::InvalidTweetData {
273            message: "missing author_id".to_string(),
274        };
275        assert_eq!(
276            err.to_string(),
277            "invalid tweet data for scoring: missing author_id"
278        );
279    }
280}