aperture_cli/
error.rs

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    // Specific error variants for better error handling
22    #[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    // Interactive error handling enhancements
95    #[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    // Network resilience error handling
125    #[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    // Server variable resolution errors
137    #[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/// JSON representation of an error for structured output
162#[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    /// Add context to an error for better user messaging
173    #[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    /// Add operation context to an error for better debugging
185    #[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    /// Add suggestions to error messages for better user guidance
202    #[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    /// Convert error to JSON representation for structured output
215    #[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}