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 network error: {source}")]
74 Network {
75 #[source]
77 source: reqwest::Error,
78 },
79
80 #[error("X API error (HTTP {status}): {message}")]
82 ApiError {
83 status: u16,
85 message: String,
87 },
88}
89
90#[derive(Debug, thiserror::Error)]
92pub enum LlmError {
93 #[error("LLM HTTP request failed: {0}")]
95 Request(#[from] reqwest::Error),
96
97 #[error("LLM API error (status {status}): {message}")]
99 Api {
100 status: u16,
102 message: String,
104 },
105
106 #[error("LLM rate limited, retry after {retry_after_secs} seconds")]
108 RateLimited {
109 retry_after_secs: u64,
111 },
112
113 #[error("failed to parse LLM response: {0}")]
115 Parse(String),
116
117 #[error("no LLM provider configured")]
119 NotConfigured,
120
121 #[error("content generation failed: {0}")]
123 GenerationFailed(String),
124}
125
126#[derive(Debug, thiserror::Error)]
128pub enum StorageError {
129 #[error("database connection error: {source}")]
131 Connection {
132 #[source]
134 source: sqlx::Error,
135 },
136
137 #[error("database migration error: {source}")]
139 Migration {
140 #[source]
142 source: sqlx::migrate::MigrateError,
143 },
144
145 #[error("database query error: {source}")]
147 Query {
148 #[source]
150 source: sqlx::Error,
151 },
152}
153
154#[derive(Debug, thiserror::Error)]
156pub enum ScoringError {
157 #[error("invalid tweet data for scoring: {message}")]
159 InvalidTweetData {
160 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}