1use thiserror::Error;
7
8#[derive(Error, Debug)]
10#[non_exhaustive]
11pub enum MktError {
12 #[error("Provider '{provider}' not found. Available: {available}")]
14 ProviderNotFound {
15 provider: String,
17 available: String,
19 },
20
21 #[error("API error from {provider}: {status} — {message}")]
23 ApiError {
24 provider: String,
26 status: u16,
28 message: String,
30 retry_after: Option<u64>,
32 },
33
34 #[error("Authentication failed for {provider}: {reason}")]
36 AuthError {
37 provider: String,
39 reason: String,
41 },
42
43 #[error("Configuration error: {0}")]
45 ConfigError(String),
46
47 #[error("Rate limit exceeded for {provider}. Retry after {retry_after_secs}s")]
49 RateLimited {
50 provider: String,
52 retry_after_secs: u64,
54 },
55
56 #[error("Validation error: {field} — {message}")]
58 ValidationError {
59 field: String,
61 message: String,
63 },
64
65 #[error("{provider} does not support '{feature}'")]
67 NotSupported {
68 provider: String,
70 feature: String,
72 },
73
74 #[error(transparent)]
76 Http(#[from] reqwest::Error),
77
78 #[error(transparent)]
80 Io(#[from] std::io::Error),
81
82 #[error(transparent)]
84 SerdeJson(#[from] serde_json::Error),
85
86 #[error(transparent)]
88 Toml(#[from] toml::de::Error),
89
90 #[error(transparent)]
92 Csv(#[from] csv::Error),
93}
94
95impl MktError {
96 pub fn not_supported(provider: &str, feature: &str) -> Self {
98 Self::NotSupported {
99 provider: provider.to_string(),
100 feature: feature.to_string(),
101 }
102 }
103
104 pub fn auth_error(provider: &str, reason: &str) -> Self {
106 Self::AuthError {
107 provider: provider.to_string(),
108 reason: reason.to_string(),
109 }
110 }
111
112 #[must_use]
128 pub const fn exit_code(&self) -> u8 {
129 match self {
130 Self::ValidationError { .. } | Self::ConfigError(_) => 2,
131 Self::AuthError { .. } => 3,
132 Self::ProviderNotFound { .. } => 4,
133 Self::RateLimited { .. } => 5,
134 Self::NotSupported { .. } => 6,
135 Self::ApiError { status, .. } => match status {
136 401 | 403 => 3,
137 404 => 4,
138 429 => 5,
139 _ => 7,
140 },
141 _ => 1,
142 }
143 }
144
145 #[must_use]
150 pub const fn error_type(&self) -> &'static str {
151 match self {
152 Self::ProviderNotFound { .. } => "provider_not_found",
153 Self::ApiError { .. } => "api_error",
154 Self::AuthError { .. } => "auth_error",
155 Self::ConfigError(_) => "config_error",
156 Self::RateLimited { .. } => "rate_limited",
157 Self::ValidationError { .. } => "validation_error",
158 Self::NotSupported { .. } => "not_supported",
159 Self::Http(_) => "http_error",
160 Self::Io(_) => "io_error",
161 Self::SerdeJson(_) => "serde_error",
162 Self::Toml(_) => "toml_error",
163 Self::Csv(_) => "csv_error",
164 }
165 }
166
167 #[must_use]
169 pub const fn is_transient(&self) -> bool {
170 match self {
171 Self::RateLimited { .. } | Self::Http(_) => true,
172 Self::ApiError { status, .. } => *status == 429 || *status >= 500,
173 _ => false,
174 }
175 }
176
177 #[must_use]
179 pub fn suggestion(&self) -> Option<String> {
180 match self {
181 Self::AuthError { provider, .. } => Some(format!(
182 "Run 'mkt doctor' and check the MKT_{}_ACCESS_TOKEN environment \
183 variable or the profile config.",
184 provider.to_uppercase()
185 )),
186 Self::ApiError { status, .. } if *status == 401 || *status == 403 => {
187 Some("Run 'mkt doctor' to verify credentials and permissions.".to_string())
188 }
189 Self::NotSupported { .. } => {
190 Some("Run 'mkt providers' to see each provider's capabilities.".to_string())
191 }
192 Self::RateLimited {
193 retry_after_secs, ..
194 } => Some(format!("Retry after {retry_after_secs} seconds.")),
195 Self::ApiError {
196 retry_after: Some(secs),
197 ..
198 } => Some(format!("Retry after {secs} seconds.")),
199 Self::ProviderNotFound { available, .. } => {
200 Some(format!("Available providers: {available}."))
201 }
202 Self::ConfigError(_) => {
203 Some("Run 'mkt doctor' to validate the configuration.".to_string())
204 }
205 _ => None,
206 }
207 }
208}
209
210pub type Result<T> = std::result::Result<T, MktError>;
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216
217 #[test]
218 fn display_provider_not_found() {
219 let err = MktError::ProviderNotFound {
220 provider: "twitter".into(),
221 available: "meta, google".into(),
222 };
223 assert_eq!(
224 err.to_string(),
225 "Provider 'twitter' not found. Available: meta, google"
226 );
227 }
228
229 #[test]
230 fn display_api_error() {
231 let err = MktError::ApiError {
232 provider: "meta".into(),
233 status: 400,
234 message: "Invalid objective".into(),
235 retry_after: None,
236 };
237 assert_eq!(
238 err.to_string(),
239 "API error from meta: 400 — Invalid objective"
240 );
241 }
242
243 #[test]
244 fn display_not_supported() {
245 let err = MktError::not_supported("tiktok", "dark_posts");
246 assert_eq!(err.to_string(), "tiktok does not support 'dark_posts'");
247 }
248
249 #[test]
250 fn display_rate_limited() {
251 let err = MktError::RateLimited {
252 provider: "meta".into(),
253 retry_after_secs: 30,
254 };
255 assert_eq!(
256 err.to_string(),
257 "Rate limit exceeded for meta. Retry after 30s"
258 );
259 }
260
261 #[test]
262 fn display_auth_error() {
263 let err = MktError::auth_error("google", "token expired");
264 assert_eq!(
265 err.to_string(),
266 "Authentication failed for google: token expired"
267 );
268 }
269
270 #[test]
271 fn from_io_error() {
272 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
273 let mkt_err: MktError = io_err.into();
274 assert!(matches!(mkt_err, MktError::Io(_)));
275 }
276
277 #[test]
278 #[allow(clippy::panic)]
279 fn from_serde_json_error() {
280 let Err(json_err) = serde_json::from_str::<serde_json::Value>("not json") else {
281 panic!("expected JSON parse error");
282 };
283 let mkt_err: MktError = json_err.into();
284 assert!(matches!(mkt_err, MktError::SerdeJson(_)));
285 }
286
287 #[test]
288 fn display_config_error() {
289 let err = MktError::ConfigError("missing profile".into());
290 assert_eq!(err.to_string(), "Configuration error: missing profile");
291 }
292
293 #[test]
294 fn display_validation_error() {
295 let err = MktError::ValidationError {
296 field: "budget".into(),
297 message: "must be positive".into(),
298 };
299 assert_eq!(
300 err.to_string(),
301 "Validation error: budget — must be positive"
302 );
303 }
304
305 #[test]
308 fn exit_codes_follow_documented_contract() {
309 assert_eq!(
310 MktError::ValidationError {
311 field: "x".into(),
312 message: "y".into()
313 }
314 .exit_code(),
315 2
316 );
317 assert_eq!(MktError::ConfigError("bad".into()).exit_code(), 2);
318 assert_eq!(MktError::auth_error("meta", "expired").exit_code(), 3);
319 assert_eq!(
320 MktError::ProviderNotFound {
321 provider: "x".into(),
322 available: "meta".into()
323 }
324 .exit_code(),
325 4
326 );
327 assert_eq!(
328 MktError::RateLimited {
329 provider: "meta".into(),
330 retry_after_secs: 10
331 }
332 .exit_code(),
333 5
334 );
335 assert_eq!(MktError::not_supported("meta", "x").exit_code(), 6);
336 }
337
338 #[test]
339 fn api_error_exit_code_depends_on_status() {
340 let not_found = MktError::ApiError {
341 provider: "meta".into(),
342 status: 404,
343 message: "missing".into(),
344 retry_after: None,
345 };
346 assert_eq!(not_found.exit_code(), 4);
347
348 let rate_limited = MktError::ApiError {
349 provider: "meta".into(),
350 status: 429,
351 message: "slow down".into(),
352 retry_after: Some(30),
353 };
354 assert_eq!(rate_limited.exit_code(), 5);
355
356 let auth = MktError::ApiError {
357 provider: "meta".into(),
358 status: 401,
359 message: "bad token".into(),
360 retry_after: None,
361 };
362 assert_eq!(auth.exit_code(), 3);
363
364 let other = MktError::ApiError {
365 provider: "meta".into(),
366 status: 400,
367 message: "bad request".into(),
368 retry_after: None,
369 };
370 assert_eq!(other.exit_code(), 7);
371 }
372
373 #[test]
374 fn generic_errors_exit_code_is_one() {
375 let io_err: MktError =
376 std::io::Error::new(std::io::ErrorKind::NotFound, "file missing").into();
377 assert_eq!(io_err.exit_code(), 1);
378 }
379
380 #[test]
381 fn error_type_is_stable_snake_case() {
382 assert_eq!(
383 MktError::ValidationError {
384 field: "x".into(),
385 message: "y".into()
386 }
387 .error_type(),
388 "validation_error"
389 );
390 assert_eq!(MktError::auth_error("m", "r").error_type(), "auth_error");
391 assert_eq!(
392 MktError::RateLimited {
393 provider: "m".into(),
394 retry_after_secs: 1
395 }
396 .error_type(),
397 "rate_limited"
398 );
399 assert_eq!(
400 MktError::not_supported("m", "f").error_type(),
401 "not_supported"
402 );
403 }
404
405 #[test]
406 fn transient_errors_are_flagged_for_retry() {
407 assert!(
408 MktError::RateLimited {
409 provider: "m".into(),
410 retry_after_secs: 1
411 }
412 .is_transient()
413 );
414 let server_err = MktError::ApiError {
415 provider: "m".into(),
416 status: 503,
417 message: "unavailable".into(),
418 retry_after: None,
419 };
420 assert!(server_err.is_transient());
421 assert!(
422 !MktError::ValidationError {
423 field: "x".into(),
424 message: "y".into()
425 }
426 .is_transient()
427 );
428 }
429
430 #[test]
431 #[allow(clippy::expect_used)]
432 fn suggestions_guide_recovery() {
433 let auth = MktError::auth_error("meta", "expired");
434 let suggestion = auth.suggestion().expect("auth errors carry a suggestion");
435 assert!(suggestion.contains("doctor"), "got: {suggestion}");
436
437 let unsupported = MktError::not_supported("meta", "x");
438 assert!(
439 unsupported
440 .suggestion()
441 .expect("not_supported carries a suggestion")
442 .contains("providers")
443 );
444 }
445}