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
132#[derive(Debug, thiserror::Error)]
134pub enum LlmError {
135 #[error("LLM HTTP request failed: {0}")]
137 Request(#[from] reqwest::Error),
138
139 #[error("LLM API error (status {status}): {message}")]
141 Api {
142 status: u16,
144 message: String,
146 },
147
148 #[error("LLM rate limited, retry after {retry_after_secs} seconds")]
150 RateLimited {
151 retry_after_secs: u64,
153 },
154
155 #[error("failed to parse LLM response: {0}")]
157 Parse(String),
158
159 #[error("no LLM provider configured")]
161 NotConfigured,
162
163 #[error("content generation failed: {0}")]
165 GenerationFailed(String),
166}
167
168#[derive(Debug, thiserror::Error)]
170pub enum StorageError {
171 #[error("database connection error: {source}")]
173 Connection {
174 #[source]
176 source: sqlx::Error,
177 },
178
179 #[error("database migration error: {source}")]
181 Migration {
182 #[source]
184 source: sqlx::migrate::MigrateError,
185 },
186
187 #[error("database query error: {source}")]
189 Query {
190 #[source]
192 source: sqlx::Error,
193 },
194}
195
196#[derive(Debug, thiserror::Error)]
198pub enum ScoringError {
199 #[error("invalid tweet data for scoring: {message}")]
201 InvalidTweetData {
202 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}