1use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
17pub struct ErrorDetail {
18 pub category: ErrorCategory,
20
21 pub code: ErrorCode,
23
24 #[serde(skip_serializing_if = "Option::is_none")]
26 pub provider: Option<String>,
27
28 pub message: String,
30
31 #[serde(skip_serializing_if = "Option::is_none")]
33 pub raw_error: Option<String>,
34
35 pub recoverable: bool,
37
38 #[serde(skip_serializing_if = "Option::is_none")]
40 pub suggestion: Option<String>,
41
42 #[serde(skip_serializing_if = "Option::is_none")]
44 pub doc_url: Option<String>,
45
46 #[serde(skip_serializing_if = "Option::is_none")]
52 pub source_chain: Option<Vec<String>>,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum ErrorCategory {
61 Auth,
63 Flag,
65 Provider,
67 Network,
69 Unsupported,
71}
72
73impl ErrorCategory {
74 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(rename_all = "snake_case")]
89pub enum ErrorCode {
90 AuthMissing,
93 AuthExpired,
95 AccessDenied,
97
98 QuerySyntax,
101 InvalidTimeRange,
103 MissingRequired,
105 InvalidFlag,
107 ConfigError,
109
110 BackendError,
113 RateLimited,
115 NotFound,
117 Timeout,
119
120 DnsError,
123 TlsError,
125 ConnectionError,
127
128 NotSupported,
131}
132
133#[derive(Debug, thiserror::Error)]
139pub enum ObzError {
140 #[error("{message}")]
142 Provider {
143 code: ErrorCode,
145 message: String,
147 raw_error: Option<String>,
149 recoverable: bool,
151 suggestion: Option<String>,
153 doc_url: Option<String>,
155 },
156
157 #[error("authentication error: {message}")]
159 Auth {
160 code: ErrorCode,
162 message: String,
164 recoverable: bool,
171 suggestion: Option<String>,
173 },
174
175 #[error("invalid argument: {message}")]
177 InvalidArgument {
178 code: ErrorCode,
180 message: String,
182 suggestion: Option<String>,
184 },
185
186 #[error("network error: {message}")]
188 Network {
189 code: ErrorCode,
191 message: String,
193 recoverable: bool,
195 source_chain: Option<Vec<String>>,
197 },
198
199 #[error("{message}")]
201 Unsupported {
202 message: String,
204 provider: Option<String>,
206 suggestion: Option<String>,
208 },
209}
210
211impl ObzError {
212 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 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}