Skip to main content

obz_core/model/
error.rs

1//! Error types for the obz response envelope.
2//!
3//! Each error carries a category, code, human-readable message, and
4//! optional fields for provider context, raw backend response,
5//! recoverability flag, suggested fix, documentation URL, and source
6//! chain. This enables AI Agents to programmatically classify errors,
7//! decide whether to retry, and take corrective action.
8
9use serde::{Deserialize, Serialize};
10
11/// Structured error detail for the response envelope.
12///
13/// Provides machine-readable category/code, human-readable message,
14/// provider context, recoverability flag, suggested fix, and an
15/// optional source chain for debugging.
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
17pub struct ErrorDetail {
18    /// Error category for broad classification.
19    pub category: ErrorCategory,
20
21    /// Machine-readable error code.
22    pub code: ErrorCode,
23
24    /// The provider that triggered the error (if applicable).
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub provider: Option<String>,
27
28    /// Human-readable error description.
29    pub message: String,
30
31    /// Raw error from the backend API (for debugging).
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub raw_error: Option<String>,
34
35    /// Whether the error is recoverable (retry or adjust parameters).
36    pub recoverable: bool,
37
38    /// Suggested fix for the error.
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub suggestion: Option<String>,
41
42    /// Documentation URL for more information.
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub doc_url: Option<String>,
45
46    /// Error source chain for debugging (e.g. underlying TLS/DNS/IO errors).
47    ///
48    /// Each entry is the `Display` output of a successive `.source()` in the
49    /// original error chain.  Only populated when the error wraps an external
50    /// library error (e.g. `reqwest`, `serde_json`).
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub source_chain: Option<Vec<String>>,
53}
54
55/// Error category for broad classification.
56///
57/// Maps to exit codes: auth=1, flag=2, provider=3, network=4, unsupported=5.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum ErrorCategory {
61    /// Authentication/authorization error (exit code 1).
62    Auth,
63    /// Invalid CLI arguments or flags (exit code 2).
64    Flag,
65    /// Backend provider error (4xx/5xx) (exit code 3).
66    Provider,
67    /// Network error (DNS, timeout, TLS) (exit code 4).
68    Network,
69    /// Operation not supported by this provider (exit code 5).
70    Unsupported,
71}
72
73impl ErrorCategory {
74    /// Return the process exit code for this error category.
75    pub fn exit_code(self) -> i32 {
76        match self {
77            Self::Auth => 1,
78            Self::Flag => 2,
79            Self::Provider => 3,
80            Self::Network => 4,
81            Self::Unsupported => 5,
82        }
83    }
84}
85
86/// Machine-readable error code.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(rename_all = "snake_case")]
89pub enum ErrorCode {
90    // Auth errors
91    /// Authentication credentials are missing.
92    AuthMissing,
93    /// Authentication credentials have expired.
94    AuthExpired,
95    /// Access denied (insufficient permissions).
96    AccessDenied,
97
98    // Flag errors
99    /// Invalid query syntax.
100    QuerySyntax,
101    /// Invalid time range or format.
102    InvalidTimeRange,
103    /// Missing required flag.
104    MissingRequired,
105    /// Invalid flag value.
106    InvalidFlag,
107    /// Configuration file read or parse error.
108    ConfigError,
109
110    // Provider errors
111    /// Backend returned an error response.
112    BackendError,
113    /// Rate limited by the backend.
114    RateLimited,
115    /// Requested resource not found.
116    NotFound,
117    /// Request timed out.
118    Timeout,
119
120    // Network errors
121    /// DNS resolution failed.
122    DnsError,
123    /// TLS/SSL handshake failed.
124    TlsError,
125    /// Connection refused or reset.
126    ConnectionError,
127
128    // Unsupported
129    /// This operation is not supported by the provider.
130    NotSupported,
131}
132
133/// The core error type for obz-core operations.
134///
135/// Uses `thiserror` for ergonomic error handling. Each variant carries
136/// enough information to produce a structured [`ErrorDetail`] for the
137/// response envelope.
138#[derive(Debug, thiserror::Error)]
139pub enum ObzError {
140    /// Provider returned an error.
141    #[error("{message}")]
142    Provider {
143        /// Machine-readable error code.
144        code: ErrorCode,
145        /// Human-readable message.
146        message: String,
147        /// Raw backend error response.
148        raw_error: Option<String>,
149        /// Whether a retry might succeed.
150        recoverable: bool,
151        /// Suggested fix.
152        suggestion: Option<String>,
153        /// Documentation URL.
154        doc_url: Option<String>,
155    },
156
157    /// Authentication error.
158    #[error("authentication error: {message}")]
159    Auth {
160        /// Machine-readable error code.
161        code: ErrorCode,
162        /// Human-readable message.
163        message: String,
164        /// Whether the caller can recover by retrying the same request.
165        ///
166        /// Set `true` **only** when the underlying cause has already been
167        /// resolved (e.g. credential-process refreshed expired credentials).
168        /// All other auth errors should use `false` — downstream consumers
169        /// (AI Agents) may auto-retry based on this flag.
170        recoverable: bool,
171        /// Suggested fix.
172        suggestion: Option<String>,
173    },
174
175    /// Invalid CLI arguments.
176    #[error("invalid argument: {message}")]
177    InvalidArgument {
178        /// Machine-readable error code.
179        code: ErrorCode,
180        /// Human-readable message.
181        message: String,
182        /// Suggested fix.
183        suggestion: Option<String>,
184    },
185
186    /// Network error.
187    #[error("network error: {message}")]
188    Network {
189        /// Machine-readable error code.
190        code: ErrorCode,
191        /// Human-readable message.
192        message: String,
193        /// Whether a retry might succeed.
194        recoverable: bool,
195        /// Error source chain from the underlying library error.
196        source_chain: Option<Vec<String>>,
197    },
198
199    /// Operation not supported by this provider.
200    #[error("{message}")]
201    Unsupported {
202        /// Human-readable message.
203        message: String,
204        /// The provider that doesn't support this operation.
205        provider: Option<String>,
206        /// Suggested fix.
207        suggestion: Option<String>,
208    },
209}
210
211impl ObzError {
212    /// Convert this error into a structured [`ErrorDetail`] for JSON output.
213    ///
214    /// `provider` is injected into `ErrorDetail.provider` only when the
215    /// variant does not already carry one (e.g. [`Unsupported`] sets its
216    /// own provider, which takes precedence over the caller-supplied value).
217    pub fn to_error_detail(&self, provider: Option<&str>) -> ErrorDetail {
218        let mut detail = match self {
219            Self::Provider {
220                code,
221                message,
222                raw_error,
223                recoverable,
224                suggestion,
225                doc_url,
226            } => ErrorDetail {
227                category: ErrorCategory::Provider,
228                code: *code,
229                provider: None,
230                message: message.clone(),
231                raw_error: raw_error.clone(),
232                recoverable: *recoverable,
233                suggestion: suggestion.clone(),
234                doc_url: doc_url.clone(),
235                source_chain: None,
236            },
237            Self::Auth {
238                code,
239                message,
240                recoverable,
241                suggestion,
242            } => ErrorDetail {
243                category: ErrorCategory::Auth,
244                code: *code,
245                provider: None,
246                message: message.clone(),
247                raw_error: None,
248                recoverable: *recoverable,
249                suggestion: suggestion.clone(),
250                doc_url: None,
251                source_chain: None,
252            },
253            Self::InvalidArgument {
254                code,
255                message,
256                suggestion,
257            } => ErrorDetail {
258                category: ErrorCategory::Flag,
259                code: *code,
260                provider: None,
261                message: message.clone(),
262                raw_error: None,
263                recoverable: false,
264                suggestion: suggestion.clone(),
265                doc_url: None,
266                source_chain: None,
267            },
268            Self::Network {
269                code,
270                message,
271                recoverable,
272                source_chain,
273            } => ErrorDetail {
274                category: ErrorCategory::Network,
275                code: *code,
276                provider: None,
277                message: message.clone(),
278                raw_error: None,
279                recoverable: *recoverable,
280                suggestion: None,
281                doc_url: None,
282                source_chain: source_chain.clone(),
283            },
284            Self::Unsupported {
285                message,
286                provider,
287                suggestion,
288            } => ErrorDetail {
289                category: ErrorCategory::Unsupported,
290                code: ErrorCode::NotSupported,
291                provider: provider.clone(),
292                message: message.clone(),
293                raw_error: None,
294                recoverable: false,
295                suggestion: suggestion.clone(),
296                doc_url: None,
297                source_chain: None,
298            },
299        };
300
301        // Inject provider from caller context if variant didn't set one.
302        if detail.provider.is_none() {
303            detail.provider = provider.map(str::to_string);
304        }
305
306        detail
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    #[test]
315    fn test_error_category_exit_codes() {
316        assert_eq!(ErrorCategory::Auth.exit_code(), 1);
317        assert_eq!(ErrorCategory::Flag.exit_code(), 2);
318        assert_eq!(ErrorCategory::Provider.exit_code(), 3);
319        assert_eq!(ErrorCategory::Network.exit_code(), 4);
320        assert_eq!(ErrorCategory::Unsupported.exit_code(), 5);
321    }
322
323    #[test]
324    fn test_error_category_serialization() {
325        assert_eq!(
326            serde_json::to_string(&ErrorCategory::Auth).unwrap(),
327            r#""auth""#
328        );
329        assert_eq!(
330            serde_json::to_string(&ErrorCategory::Provider).unwrap(),
331            r#""provider""#
332        );
333        assert_eq!(
334            serde_json::to_string(&ErrorCategory::Unsupported).unwrap(),
335            r#""unsupported""#
336        );
337    }
338
339    #[test]
340    fn test_error_code_config_error_serialization() {
341        assert_eq!(
342            serde_json::to_string(&ErrorCode::ConfigError).unwrap(),
343            r#""config_error""#
344        );
345    }
346
347    #[test]
348    fn test_config_error_to_detail() {
349        let err = ObzError::InvalidArgument {
350            code: ErrorCode::ConfigError,
351            message: "failed to parse config.yaml".to_string(),
352            suggestion: None,
353        };
354        let detail = err.to_error_detail(None);
355        assert_eq!(detail.category, ErrorCategory::Flag);
356        assert_eq!(detail.code, ErrorCode::ConfigError);
357        assert!(!detail.recoverable);
358        assert!(detail.source_chain.is_none());
359    }
360
361    #[test]
362    fn test_obz_error_to_detail() {
363        let err = ObzError::Provider {
364            code: ErrorCode::QuerySyntax,
365            message: "invalid expression".to_string(),
366            raw_error: Some("bad_data".to_string()),
367            recoverable: false,
368            suggestion: Some("Check your PromQL syntax".to_string()),
369            doc_url: None,
370        };
371
372        let detail = err.to_error_detail(None);
373        assert_eq!(detail.category, ErrorCategory::Provider);
374        assert_eq!(detail.code, ErrorCode::QuerySyntax);
375        assert!(!detail.recoverable);
376    }
377
378    #[test]
379    fn test_network_error_preserves_source_chain() {
380        let err = ObzError::Network {
381            code: ErrorCode::TlsError,
382            message: "TLS error: certificate verify failed".to_string(),
383            recoverable: false,
384            source_chain: Some(vec![
385                "rustls::Error::InvalidCertificate(UnknownIssuer)".to_string(),
386                "certificate not trusted: CA not in trust store".to_string(),
387            ]),
388        };
389        let detail = err.to_error_detail(None);
390        assert_eq!(detail.category, ErrorCategory::Network);
391        assert_eq!(detail.code, ErrorCode::TlsError);
392        let chain = detail.source_chain.unwrap();
393        assert_eq!(chain.len(), 2);
394        assert!(chain[0].contains("UnknownIssuer"));
395    }
396
397    #[test]
398    fn test_source_chain_none_omitted_from_json() {
399        let detail = ErrorDetail {
400            category: ErrorCategory::Flag,
401            code: ErrorCode::MissingRequired,
402            provider: None,
403            message: "missing --provider".to_string(),
404            raw_error: None,
405            recoverable: false,
406            suggestion: None,
407            doc_url: None,
408            source_chain: None,
409        };
410        let json = serde_json::to_string(&detail).unwrap();
411        assert!(
412            !json.contains("source_chain"),
413            "None source_chain should be omitted: {json}"
414        );
415    }
416
417    #[test]
418    fn test_auth_recoverable_true_propagated() {
419        let err = ObzError::Auth {
420            code: ErrorCode::AuthExpired,
421            message: "token expired".to_string(),
422            recoverable: true,
423            suggestion: Some("Retry the command".to_string()),
424        };
425        let detail = err.to_error_detail(None);
426        assert!(detail.recoverable);
427        assert_eq!(detail.suggestion.as_deref(), Some("Retry the command"));
428    }
429
430    #[test]
431    fn test_auth_recoverable_false_propagated() {
432        let err = ObzError::Auth {
433            code: ErrorCode::AuthMissing,
434            message: "no credentials".to_string(),
435            recoverable: false,
436            suggestion: None,
437        };
438        let detail = err.to_error_detail(None);
439        assert!(!detail.recoverable);
440    }
441
442    #[test]
443    fn test_invalid_argument_suggestion_propagated() {
444        let err = ObzError::InvalidArgument {
445            code: ErrorCode::MissingRequired,
446            message: "--provider is required".to_string(),
447            suggestion: Some("Set default_provider in config.yaml".to_string()),
448        };
449        let detail = err.to_error_detail(None);
450        assert_eq!(
451            detail.suggestion.as_deref(),
452            Some("Set default_provider in config.yaml")
453        );
454    }
455
456    #[test]
457    fn test_invalid_argument_suggestion_none() {
458        let err = ObzError::InvalidArgument {
459            code: ErrorCode::InvalidTimeRange,
460            message: "invalid time".to_string(),
461            suggestion: None,
462        };
463        let detail = err.to_error_detail(None);
464        assert!(detail.suggestion.is_none());
465    }
466
467    #[test]
468    fn test_to_error_detail_injects_provider() {
469        let err = ObzError::Provider {
470            code: ErrorCode::BackendError,
471            message: "HTTP 500".to_string(),
472            raw_error: None,
473            recoverable: true,
474            suggestion: None,
475            doc_url: None,
476        };
477        let detail = err.to_error_detail(Some("my-vm"));
478        assert_eq!(detail.provider.as_deref(), Some("my-vm"));
479    }
480
481    #[test]
482    fn test_to_error_detail_does_not_override_existing_provider() {
483        let err = ObzError::Unsupported {
484            message: "not supported".to_string(),
485            provider: Some("existing-provider".to_string()),
486            suggestion: None,
487        };
488        let detail = err.to_error_detail(Some("caller-provider"));
489        assert_eq!(detail.provider.as_deref(), Some("existing-provider"));
490    }
491
492    #[test]
493    fn test_source_chain_some_included_in_json() {
494        let detail = ErrorDetail {
495            category: ErrorCategory::Network,
496            code: ErrorCode::TlsError,
497            provider: None,
498            message: "TLS error".to_string(),
499            raw_error: None,
500            recoverable: false,
501            suggestion: None,
502            doc_url: None,
503            source_chain: Some(vec!["cause1".to_string(), "cause2".to_string()]),
504        };
505        let json = serde_json::to_value(&detail).unwrap();
506        let chain = json["source_chain"].as_array().unwrap();
507        assert_eq!(chain.len(), 2);
508        assert_eq!(chain[0], "cause1");
509        assert_eq!(chain[1], "cause2");
510    }
511}