Skip to main content

mkt_core/
error.rs

1//! Unified error types for the `mkt` workspace.
2//!
3//! All provider crates convert their specific errors into [`MktError`]
4//! variants. The CLI boundary uses `anyhow` for final error reporting.
5
6use thiserror::Error;
7
8/// The unified error type for the `mkt` workspace.
9#[derive(Error, Debug)]
10#[non_exhaustive]
11pub enum MktError {
12    /// The requested provider is not registered or not available.
13    #[error("Provider '{provider}' not found. Available: {available}")]
14    ProviderNotFound {
15        /// The provider name that was requested.
16        provider: String,
17        /// Comma-separated list of available provider names.
18        available: String,
19    },
20
21    /// An API request returned an error response.
22    #[error("API error from {provider}: {status} — {message}")]
23    ApiError {
24        /// Which provider returned the error.
25        provider: String,
26        /// HTTP status code.
27        status: u16,
28        /// Human-readable error message from the API.
29        message: String,
30        /// Optional retry-after hint in seconds.
31        retry_after: Option<u64>,
32    },
33
34    /// Authentication failed for a provider.
35    #[error("Authentication failed for {provider}: {reason}")]
36    AuthError {
37        /// Which provider failed authentication.
38        provider: String,
39        /// Human-readable reason for the failure.
40        reason: String,
41    },
42
43    /// A configuration error occurred.
44    #[error("Configuration error: {0}")]
45    ConfigError(String),
46
47    /// The provider's rate limit has been exceeded.
48    #[error("Rate limit exceeded for {provider}. Retry after {retry_after_secs}s")]
49    RateLimited {
50        /// Which provider hit the rate limit.
51        provider: String,
52        /// How many seconds to wait before retrying.
53        retry_after_secs: u64,
54    },
55
56    /// A validation error on user input.
57    #[error("Validation error: {field} — {message}")]
58    ValidationError {
59        /// The field that failed validation.
60        field: String,
61        /// What went wrong.
62        message: String,
63    },
64
65    /// The provider does not support the requested feature.
66    #[error("{provider} does not support '{feature}'")]
67    NotSupported {
68        /// Which provider lacks the feature.
69        provider: String,
70        /// The feature that is not supported.
71        feature: String,
72    },
73
74    /// An HTTP transport error from `reqwest`.
75    #[error(transparent)]
76    Http(#[from] reqwest::Error),
77
78    /// A filesystem I/O error.
79    #[error(transparent)]
80    Io(#[from] std::io::Error),
81
82    /// A JSON serialization/deserialization error.
83    #[error(transparent)]
84    SerdeJson(#[from] serde_json::Error),
85
86    /// A TOML parsing error.
87    #[error(transparent)]
88    Toml(#[from] toml::de::Error),
89
90    /// A CSV writing error.
91    #[error(transparent)]
92    Csv(#[from] csv::Error),
93}
94
95impl MktError {
96    /// Convenience constructor for `NotSupported` errors.
97    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    /// Convenience constructor for `AuthError`.
105    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    /// Process exit code for this error.
113    ///
114    /// The contract is stable and documented in `AGENTS.md` so scripts and
115    /// coding agents can branch on it:
116    ///
117    /// | Code | Meaning                                  |
118    /// |------|------------------------------------------|
119    /// | 0    | success                                  |
120    /// | 1    | unexpected error (I/O, transport, bug)   |
121    /// | 2    | invalid input or configuration           |
122    /// | 3    | authentication failed                    |
123    /// | 4    | resource or provider not found           |
124    /// | 5    | rate limited (transient — retry)         |
125    /// | 6    | feature not supported by the provider    |
126    /// | 7    | provider API rejected the request        |
127    #[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    /// Stable machine-readable error identifier (`snake_case`).
146    ///
147    /// Emitted in structured error output so agents can match on the kind
148    /// of failure without parsing human-readable messages.
149    #[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    /// Whether retrying the same request later may succeed.
168    #[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    /// A recovery hint for the user or agent, when one exists.
178    #[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
210/// A `Result` type alias that uses [`MktError`].
211pub 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    // ── Exit code contract (stable, documented in AGENTS.md) ──
306
307    #[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}