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
111#[derive(Debug, thiserror::Error)]
113pub enum LlmError {
114 #[error("LLM HTTP request failed: {0}")]
116 Request(#[from] reqwest::Error),
117
118 #[error("LLM API error (status {status}): {message}")]
120 Api {
121 status: u16,
123 message: String,
125 },
126
127 #[error("LLM rate limited, retry after {retry_after_secs} seconds")]
129 RateLimited {
130 retry_after_secs: u64,
132 },
133
134 #[error("failed to parse LLM response: {0}")]
136 Parse(String),
137
138 #[error("no LLM provider configured")]
140 NotConfigured,
141
142 #[error("content generation failed: {0}")]
144 GenerationFailed(String),
145}
146
147#[derive(Debug, thiserror::Error)]
149pub enum StorageError {
150 #[error("database connection error: {source}")]
152 Connection {
153 #[source]
155 source: sqlx::Error,
156 },
157
158 #[error("database migration error: {source}")]
160 Migration {
161 #[source]
163 source: sqlx::migrate::MigrateError,
164 },
165
166 #[error("database query error: {source}")]
168 Query {
169 #[source]
171 source: sqlx::Error,
172 },
173}
174
175#[derive(Debug, thiserror::Error)]
177pub enum ScoringError {
178 #[error("invalid tweet data for scoring: {message}")]
180 InvalidTweetData {
181 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}