1use serde::{Deserialize, Serialize};
2use thiserror::Error;
3
4#[derive(Error, Debug)]
5pub enum Error {
6 #[error("I/O error: {0}")]
7 Io(#[from] std::io::Error),
8 #[error("Network error: {0}")]
9 Network(#[from] reqwest::Error),
10 #[error("YAML parsing error: {0}")]
11 Yaml(#[from] serde_yaml::Error),
12 #[error("JSON parsing error: {0}")]
13 Json(#[from] serde_json::Error),
14 #[error("TOML parsing error: {0}")]
15 Toml(#[from] toml::de::Error),
16 #[error("Configuration error: {0}")]
17 Config(String),
18 #[error("Validation error: {0}")]
19 Validation(String),
20
21 #[error("API specification '{name}' not found")]
23 SpecNotFound { name: String },
24 #[error("API specification '{name}' already exists. Use --force to overwrite")]
25 SpecAlreadyExists { name: String },
26 #[error("No cached spec found for '{name}'. Run 'aperture config add {name}' first")]
27 CachedSpecNotFound { name: String },
28 #[error("Failed to deserialize cached spec '{name}': {reason}. The cache may be corrupted")]
29 CachedSpecCorrupted { name: String, reason: String },
30 #[error("Cache format version mismatch for '{name}': found v{found}, expected v{expected}")]
31 CacheVersionMismatch {
32 name: String,
33 found: u32,
34 expected: u32,
35 },
36 #[error(
37 "Environment variable '{env_var}' required for authentication '{scheme_name}' is not set"
38 )]
39 SecretNotSet {
40 scheme_name: String,
41 env_var: String,
42 },
43 #[error("Invalid header format '{header}'. Expected 'Name: Value'")]
44 InvalidHeaderFormat { header: String },
45 #[error("Invalid header name '{name}': {reason}")]
46 InvalidHeaderName { name: String, reason: String },
47 #[error("Invalid header value for '{name}': {reason}")]
48 InvalidHeaderValue { name: String, reason: String },
49 #[error("EDITOR environment variable not set")]
50 EditorNotSet,
51 #[error("Editor command failed for spec '{name}'")]
52 EditorFailed { name: String },
53 #[error("Invalid HTTP method: {method}")]
54 InvalidHttpMethod { method: String },
55 #[error("Missing path parameter: {name}")]
56 MissingPathParameter { name: String },
57 #[error("Unsupported HTTP authentication scheme: {scheme}")]
58 UnsupportedAuthScheme { scheme: String },
59 #[error("Unsupported security scheme type: {scheme_type}")]
60 UnsupportedSecurityScheme { scheme_type: String },
61 #[error("Failed to serialize cached spec: {reason}")]
62 SerializationError { reason: String },
63 #[error("Invalid config.toml: {reason}")]
64 InvalidConfig { reason: String },
65 #[error("Could not determine home directory")]
66 HomeDirectoryNotFound,
67 #[error("Invalid JSON body: {reason}")]
68 InvalidJsonBody { reason: String },
69 #[error("Request failed: {reason}")]
70 RequestFailed { reason: String },
71 #[error("Failed to read response: {reason}")]
72 ResponseReadError { reason: String },
73 #[error("Request failed with status {status}: {body}")]
74 HttpErrorWithContext {
75 status: u16,
76 body: String,
77 api_name: String,
78 operation_id: Option<String>,
79 security_schemes: Vec<String>,
80 },
81 #[error("Invalid command for API '{context}': {reason}")]
82 InvalidCommand { context: String, reason: String },
83 #[error("Could not find operation from command path")]
84 OperationNotFound,
85 #[error("Invalid idempotency key")]
86 InvalidIdempotencyKey,
87 #[error("Header name cannot be empty")]
88 EmptyHeaderName,
89 #[error("JQ filter error: {reason}")]
90 JqFilterError { reason: String },
91 #[error("Invalid path '{path}': {reason}")]
92 InvalidPath { path: String, reason: String },
93
94 #[error("Input too long: {provided} characters (max: {max}). {suggestion}")]
96 InteractiveInputTooLong {
97 provided: usize,
98 max: usize,
99 suggestion: String,
100 },
101 #[error("Input contains invalid characters: {invalid_chars}. {suggestion}")]
102 InteractiveInvalidCharacters {
103 invalid_chars: String,
104 suggestion: String,
105 },
106 #[error("Interactive operation timed out after {timeout_secs} seconds. {suggestion}")]
107 InteractiveTimeout {
108 timeout_secs: u64,
109 suggestion: String,
110 },
111 #[error("Maximum retry attempts ({max_attempts}) exceeded. Last error: {last_error}")]
112 InteractiveRetriesExhausted {
113 max_attempts: usize,
114 last_error: String,
115 suggestions: Vec<String>,
116 },
117 #[error("Environment variable name '{name}' is invalid: {reason}. {suggestion}")]
118 InvalidEnvironmentVariableName {
119 name: String,
120 reason: String,
121 suggestion: String,
122 },
123
124 #[error("Request timed out after {attempts} retries (max timeout: {timeout_ms}ms)")]
126 RequestTimeout { attempts: usize, timeout_ms: u64 },
127 #[error("Retry limit exceeded: {attempts} attempts failed over {duration_ms}ms. Last error: {last_error}")]
128 RetryLimitExceeded {
129 attempts: usize,
130 duration_ms: u64,
131 last_error: String,
132 },
133 #[error("Transient network error - request can be retried: {reason}")]
134 TransientNetworkError { reason: String, retryable: bool },
135
136 #[error("Missing required server variable '{name}' with no default value")]
138 MissingServerVariable { name: String },
139 #[error("Unknown server variable '{name}'. Available variables: {available:?}")]
140 UnknownServerVariable {
141 name: String,
142 available: Vec<String>,
143 },
144 #[error("Invalid server variable format '{arg}': {reason}")]
145 InvalidServerVarFormat { arg: String, reason: String },
146 #[error(
147 "Invalid value '{value}' for server variable '{name}'. Allowed values: {allowed_values:?}"
148 )]
149 InvalidServerVarValue {
150 name: String,
151 value: String,
152 allowed_values: Vec<String>,
153 },
154 #[error("Unresolved template variable '{name}' in URL '{url}'")]
155 UnresolvedTemplateVariable { name: String, url: String },
156
157 #[error(transparent)]
158 Anyhow(#[from] anyhow::Error),
159}
160
161#[derive(Debug, Serialize, Deserialize)]
163pub struct JsonError {
164 pub error_type: String,
165 pub message: String,
166 pub context: Option<String>,
167 #[serde(skip_serializing_if = "Option::is_none")]
168 pub details: Option<serde_json::Value>,
169}
170
171impl Error {
172 #[must_use]
174 pub fn with_context(self, context: &str) -> Self {
175 match self {
176 Self::Network(e) => Self::Config(format!("{context}: {e}")),
177 Self::Io(e) => Self::Config(format!("{context}: {e}")),
178 Self::Config(msg) => Self::Config(format!("{context}: {msg}")),
179 Self::Validation(msg) => Self::Validation(format!("{context}: {msg}")),
180 _ => self,
181 }
182 }
183
184 #[must_use]
186 pub fn with_operation_context(self, operation: &str, api: &str) -> Self {
187 match self {
188 Self::RequestFailed { reason } => Self::RequestFailed {
189 reason: format!("Operation '{operation}' on API '{api}': {reason}"),
190 },
191 Self::ResponseReadError { reason } => Self::ResponseReadError {
192 reason: format!("Operation '{operation}' on API '{api}': {reason}"),
193 },
194 Self::Network(e) => {
195 Self::Config(format!("Operation '{operation}' on API '{api}': {e}"))
196 }
197 _ => self,
198 }
199 }
200
201 #[must_use]
203 pub fn with_suggestion(self, suggestion: &str) -> Self {
204 match self {
205 Self::Config(msg) => Self::Config(format!("{msg}. Suggestion: {suggestion}")),
206 Self::Validation(msg) => Self::Validation(format!("{msg}. Suggestion: {suggestion}")),
207 Self::InvalidConfig { reason } => Self::InvalidConfig {
208 reason: format!("{reason}. Suggestion: {suggestion}"),
209 },
210 _ => self,
211 }
212 }
213
214 #[must_use]
216 #[allow(clippy::too_many_lines)]
217 pub fn to_json(&self) -> JsonError {
218 use serde_json::json;
219
220 let (error_type, message, context, details) = match self {
221 Self::Config(msg) => ("Configuration", msg.clone(), None, None),
222 Self::Io(io_err) => {
223 let context = match io_err.kind() {
224 std::io::ErrorKind::NotFound => {
225 Some("Check that the file path is correct and the file exists.")
226 }
227 std::io::ErrorKind::PermissionDenied => {
228 Some("Check file permissions or run with appropriate privileges.")
229 }
230 _ => None,
231 };
232 (
233 "FileSystem",
234 io_err.to_string(),
235 context.map(str::to_string),
236 None,
237 )
238 }
239 Self::Network(req_err) => {
240 let context = if req_err.is_connect() {
241 Some("Check that the API server is running and accessible.")
242 } else if req_err.is_timeout() {
243 Some("The API server may be slow or unresponsive. Try again later.")
244 } else if req_err.is_status() {
245 req_err.status().and_then(|status| match status.as_u16() {
246 401 => Some("Check your API credentials and authentication configuration."),
247 403 => Some(
248 "Your credentials may be valid but lack permission for this operation.",
249 ),
250 404 => Some("Check that the API endpoint and parameters are correct."),
251 429 => {
252 Some("You're making requests too quickly. Wait before trying again.")
253 }
254 500..=599 => {
255 Some("The API server is experiencing issues. Try again later.")
256 }
257 _ => None,
258 })
259 } else {
260 None
261 };
262 ("Network", req_err.to_string(), context.map(str::to_string), None)
263 }
264 Self::Yaml(yaml_err) => (
265 "YAMLParsing",
266 yaml_err.to_string(),
267 Some("Check that your OpenAPI specification is valid YAML syntax.".to_string()),
268 None,
269 ),
270 Self::Json(json_err) => (
271 "JSONParsing",
272 json_err.to_string(),
273 Some("Check that your request body or response contains valid JSON.".to_string()),
274 None,
275 ),
276 Self::Validation(msg) => (
277 "Validation",
278 msg.clone(),
279 Some(
280 "Check that your OpenAPI specification follows the required format."
281 .to_string(),
282 ),
283 None,
284 ),
285 Self::Toml(toml_err) => (
286 "TOMLParsing",
287 toml_err.to_string(),
288 Some("Check that your configuration file is valid TOML syntax.".to_string()),
289 None,
290 ),
291 Self::SpecNotFound { name } => (
292 "SpecNotFound",
293 format!("API specification '{name}' not found"),
294 Some("Use 'aperture config list' to see available specifications.".to_string()),
295 Some(json!({ "spec_name": name })),
296 ),
297 Self::SpecAlreadyExists { name } => (
298 "SpecAlreadyExists",
299 format!("API specification '{name}' already exists. Use --force to overwrite"),
300 None,
301 Some(json!({ "spec_name": name })),
302 ),
303 Self::CachedSpecNotFound { name } => (
304 "CachedSpecNotFound",
305 format!("No cached spec found for '{name}'. Run 'aperture config add {name}' first"),
306 None,
307 Some(json!({ "spec_name": name })),
308 ),
309 Self::CachedSpecCorrupted { name, reason } => (
310 "CachedSpecCorrupted",
311 format!("Failed to deserialize cached spec '{name}': {reason}. The cache may be corrupted"),
312 Some("Try removing and re-adding the specification.".to_string()),
313 Some(json!({ "spec_name": name, "corruption_reason": reason })),
314 ),
315 Self::CacheVersionMismatch { name, found, expected } => (
316 "CacheVersionMismatch",
317 format!("Cache format version mismatch for '{name}': found v{found}, expected v{expected}"),
318 Some("Run 'aperture config reinit' to regenerate the cache.".to_string()),
319 Some(json!({ "spec_name": name, "found_version": found, "expected_version": expected })),
320 ),
321 Self::SecretNotSet { scheme_name, env_var } => (
322 "SecretNotSet",
323 format!("Environment variable '{env_var}' required for authentication '{scheme_name}' is not set"),
324 Some(format!("Set the environment variable: export {env_var}=<your-secret>")),
325 Some(json!({ "scheme_name": scheme_name, "env_var": env_var })),
326 ),
327 Self::InvalidHeaderFormat { header } => (
328 "InvalidHeaderFormat",
329 format!("Invalid header format '{header}'. Expected 'Name: Value'"),
330 None,
331 Some(json!({ "header": header })),
332 ),
333 Self::InvalidHeaderName { name, reason } => (
334 "InvalidHeaderName",
335 format!("Invalid header name '{name}': {reason}"),
336 None,
337 Some(json!({ "name": name, "reason": reason })),
338 ),
339 Self::InvalidHeaderValue { name, reason } => (
340 "InvalidHeaderValue",
341 format!("Invalid header value for '{name}': {reason}"),
342 None,
343 Some(json!({ "name": name, "reason": reason })),
344 ),
345 Self::EditorNotSet => (
346 "EditorNotSet",
347 "EDITOR environment variable not set".to_string(),
348 Some("Set your preferred editor: export EDITOR=vim".to_string()),
349 None,
350 ),
351 Self::EditorFailed { name } => (
352 "EditorFailed",
353 format!("Editor command failed for spec '{name}'"),
354 None,
355 Some(json!({ "spec_name": name })),
356 ),
357 Self::InvalidHttpMethod { method } => (
358 "InvalidHttpMethod",
359 format!("Invalid HTTP method: {method}"),
360 None,
361 Some(json!({ "method": method })),
362 ),
363 Self::MissingPathParameter { name } => (
364 "MissingPathParameter",
365 format!("Missing path parameter: {name}"),
366 None,
367 Some(json!({ "parameter_name": name })),
368 ),
369 Self::UnsupportedAuthScheme { scheme } => (
370 "UnsupportedAuthScheme",
371 format!("Unsupported HTTP authentication scheme: {scheme}"),
372 Some("Only 'bearer' and 'basic' schemes are supported.".to_string()),
373 Some(json!({ "scheme": scheme })),
374 ),
375 Self::UnsupportedSecurityScheme { scheme_type } => (
376 "UnsupportedSecurityScheme",
377 format!("Unsupported security scheme type: {scheme_type}"),
378 Some("Only 'apiKey' and 'http' security schemes are supported.".to_string()),
379 Some(json!({ "scheme_type": scheme_type })),
380 ),
381 Self::SerializationError { reason } => (
382 "SerializationError",
383 format!("Failed to serialize cached spec: {reason}"),
384 None,
385 Some(json!({ "reason": reason })),
386 ),
387 Self::InvalidConfig { reason } => (
388 "InvalidConfig",
389 format!("Invalid config.toml: {reason}"),
390 Some("Check the TOML syntax in your configuration file.".to_string()),
391 Some(json!({ "reason": reason })),
392 ),
393 Self::HomeDirectoryNotFound => (
394 "HomeDirectoryNotFound",
395 "Could not determine home directory".to_string(),
396 Some("Ensure HOME environment variable is set.".to_string()),
397 None,
398 ),
399 Self::InvalidJsonBody { reason } => (
400 "InvalidJsonBody",
401 format!("Invalid JSON body: {reason}"),
402 Some("Check your JSON syntax and ensure all quotes are properly escaped.".to_string()),
403 Some(json!({ "reason": reason })),
404 ),
405 Self::RequestFailed { reason } => (
406 "RequestFailed",
407 format!("Request failed: {reason}"),
408 None,
409 Some(json!({ "reason": reason })),
410 ),
411 Self::ResponseReadError { reason } => (
412 "ResponseReadError",
413 format!("Failed to read response: {reason}"),
414 None,
415 Some(json!({ "reason": reason })),
416 ),
417 Self::HttpErrorWithContext { status, body, api_name, operation_id, security_schemes } => {
418 let context_hint = match status {
419 401 => {
420 if security_schemes.is_empty() {
421 Some("Check your API credentials and authentication configuration.".to_string())
422 } else {
423 let env_vars: Vec<String> = security_schemes.iter()
424 .map(|scheme| format!("Check environment variable for '{scheme}' authentication"))
425 .collect();
426 Some(env_vars.join("; "))
427 }
428 },
429 403 => Some("Your credentials may be valid but lack permission for this operation.".to_string()),
430 404 => Some("Check that the API endpoint and parameters are correct.".to_string()),
431 429 => Some("You're making requests too quickly. Wait before trying again.".to_string()),
432 500..=599 => Some("The API server is experiencing issues. Try again later.".to_string()),
433 _ => None,
434 };
435 (
436 "HttpError",
437 format!("Request failed with status {status}: {body}"),
438 context_hint,
439 Some(json!({
440 "status": status,
441 "body": body,
442 "api_name": api_name,
443 "operation_id": operation_id,
444 "security_schemes": security_schemes
445 })),
446 )
447 },
448 Self::InvalidCommand { context, reason } => (
449 "InvalidCommand",
450 format!("Invalid command for API '{context}': {reason}"),
451 Some("Use --help to see available commands.".to_string()),
452 Some(json!({ "context": context, "reason": reason })),
453 ),
454 Self::OperationNotFound => (
455 "OperationNotFound",
456 "Could not find operation from command path".to_string(),
457 Some("Check that the command matches an available operation.".to_string()),
458 None,
459 ),
460 Self::InvalidIdempotencyKey => (
461 "InvalidIdempotencyKey",
462 "Invalid idempotency key".to_string(),
463 Some("Idempotency key must be a valid header value.".to_string()),
464 None,
465 ),
466 Self::EmptyHeaderName => (
467 "EmptyHeaderName",
468 "Header name cannot be empty".to_string(),
469 None,
470 None,
471 ),
472 Self::JqFilterError { reason } => (
473 "JqFilterError",
474 format!("JQ filter error: {reason}"),
475 Some("Check your JQ filter syntax. Common examples: '.name', '.[] | select(.active)'".to_string()),
476 Some(json!({ "reason": reason })),
477 ),
478 Self::InvalidPath { path, reason } => (
479 "InvalidPath",
480 format!("Invalid path '{path}': {reason}"),
481 Some("Check that the path is valid and properly formatted.".to_string()),
482 Some(json!({ "path": path, "reason": reason })),
483 ),
484 Self::InteractiveInputTooLong { provided, max, suggestion } => (
485 "InteractiveInputTooLong",
486 format!("Input too long: {provided} characters (max: {max}). {suggestion}"),
487 Some("Consider shortening your input or breaking it into multiple parts.".to_string()),
488 Some(json!({ "provided_length": provided, "max_length": max, "suggestion": suggestion })),
489 ),
490 Self::InteractiveInvalidCharacters { invalid_chars, suggestion } => (
491 "InteractiveInvalidCharacters",
492 format!("Input contains invalid characters: {invalid_chars}. {suggestion}"),
493 Some("Use only alphanumeric characters, underscores, and hyphens.".to_string()),
494 Some(json!({ "invalid_characters": invalid_chars, "suggestion": suggestion })),
495 ),
496 Self::InteractiveTimeout { timeout_secs, suggestion } => (
497 "InteractiveTimeout",
498 format!("Interactive operation timed out after {timeout_secs} seconds. {suggestion}"),
499 Some("Try again with a faster response or increase the timeout.".to_string()),
500 Some(json!({ "timeout_seconds": timeout_secs, "suggestion": suggestion })),
501 ),
502 Self::InteractiveRetriesExhausted { max_attempts, last_error, suggestions } => (
503 "InteractiveRetriesExhausted",
504 format!("Maximum retry attempts ({max_attempts}) exceeded. Last error: {last_error}"),
505 Some(suggestions.join("; ")),
506 Some(json!({ "max_attempts": max_attempts, "last_error": last_error, "suggestions": suggestions })),
507 ),
508 Self::InvalidEnvironmentVariableName { name, reason, suggestion } => (
509 "InvalidEnvironmentVariableName",
510 format!("Environment variable name '{name}' is invalid: {reason}. {suggestion}"),
511 Some("Use uppercase letters, numbers, and underscores only.".to_string()),
512 Some(json!({ "variable_name": name, "reason": reason, "suggestion": suggestion })),
513 ),
514 Self::RequestTimeout { attempts, timeout_ms } => (
515 "RequestTimeout",
516 format!("Request timed out after {attempts} retries (max timeout: {timeout_ms}ms)"),
517 Some("The server may be slow or unresponsive. Try again later or increase timeout.".to_string()),
518 Some(json!({ "retry_attempts": attempts, "timeout_ms": timeout_ms })),
519 ),
520 Self::RetryLimitExceeded { attempts, duration_ms, last_error } => (
521 "RetryLimitExceeded",
522 format!("Retry limit exceeded: {attempts} attempts failed over {duration_ms}ms. Last error: {last_error}"),
523 Some("The service may be experiencing issues. Check API status or try again later.".to_string()),
524 Some(json!({ "retry_attempts": attempts, "duration_ms": duration_ms, "last_error": last_error })),
525 ),
526 Self::TransientNetworkError { reason, retryable } => (
527 "TransientNetworkError",
528 format!("Transient network error - request can be retried: {reason}"),
529 if *retryable { Some("This error is retryable. The request will be automatically retried.".to_string()) }
530 else { Some("This error is not retryable. Check your network connection and API configuration.".to_string()) },
531 Some(json!({ "reason": reason, "retryable": retryable })),
532 ),
533 Self::MissingServerVariable { name } => (
534 "MissingServerVariable",
535 format!("Missing required server variable '{name}' with no default value"),
536 Some("Provide the missing server variable using --server-var name=value".to_string()),
537 Some(json!({ "variable_name": name })),
538 ),
539 Self::UnknownServerVariable { name, available } => (
540 "UnknownServerVariable",
541 format!("Unknown server variable '{name}'. Available variables: {available:?}"),
542 Some(format!("Use one of the available variables: {}", available.join(", "))),
543 Some(json!({ "variable_name": name, "available_variables": available })),
544 ),
545 Self::InvalidServerVarFormat { arg, reason } => (
546 "InvalidServerVarFormat",
547 format!("Invalid server variable format '{arg}': {reason}"),
548 Some("Use the format --server-var key=value".to_string()),
549 Some(json!({ "argument": arg, "reason": reason })),
550 ),
551 Self::InvalidServerVarValue { name, value, allowed_values } => (
552 "InvalidServerVarValue",
553 format!("Invalid value '{value}' for server variable '{name}'. Allowed values: {allowed_values:?}"),
554 Some(format!("Use one of the allowed values: {}", allowed_values.join(", "))),
555 Some(json!({ "variable_name": name, "provided_value": value, "allowed_values": allowed_values })),
556 ),
557 Self::UnresolvedTemplateVariable { name, url } => (
558 "UnresolvedTemplateVariable",
559 format!("Unresolved template variable '{name}' in URL '{url}'"),
560 Some("Ensure all template variables are provided with --server-var".to_string()),
561 Some(json!({ "variable_name": name, "template_url": url })),
562 ),
563 Self::Anyhow(err) => (
564 "Unexpected",
565 err.to_string(),
566 Some(
567 "This may be a bug. Please report it with the command you were running."
568 .to_string(),
569 ),
570 None,
571 ),
572 };
573
574 JsonError {
575 error_type: error_type.to_string(),
576 message,
577 context,
578 details,
579 }
580 }
581}