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    #[error(transparent)]
95    Anyhow(#[from] anyhow::Error),
96}
97
98/// JSON representation of an error for structured output
99#[derive(Debug, Serialize, Deserialize)]
100pub struct JsonError {
101    pub error_type: String,
102    pub message: String,
103    pub context: Option<String>,
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub details: Option<serde_json::Value>,
106}
107
108impl Error {
109    /// Add context to an error for better user messaging
110    #[must_use]
111    pub fn with_context(self, context: &str) -> Self {
112        match self {
113            Self::Network(e) => Self::Config(format!("{context}: {e}")),
114            Self::Io(e) => Self::Config(format!("{context}: {e}")),
115            _ => self,
116        }
117    }
118
119    /// Convert error to JSON representation for structured output
120    #[must_use]
121    #[allow(clippy::too_many_lines)]
122    pub fn to_json(&self) -> JsonError {
123        use serde_json::json;
124
125        let (error_type, message, context, details) = match self {
126            Self::Config(msg) => ("Configuration", msg.clone(), None, None),
127            Self::Io(io_err) => {
128                let context = match io_err.kind() {
129                    std::io::ErrorKind::NotFound => {
130                        Some("Check that the file path is correct and the file exists.")
131                    }
132                    std::io::ErrorKind::PermissionDenied => {
133                        Some("Check file permissions or run with appropriate privileges.")
134                    }
135                    _ => None,
136                };
137                (
138                    "FileSystem",
139                    io_err.to_string(),
140                    context.map(str::to_string),
141                    None,
142                )
143            }
144            Self::Network(req_err) => {
145                let context = if req_err.is_connect() {
146                    Some("Check that the API server is running and accessible.")
147                } else if req_err.is_timeout() {
148                    Some("The API server may be slow or unresponsive. Try again later.")
149                } else if req_err.is_status() {
150                    req_err.status().and_then(|status| match status.as_u16() {
151                        401 => Some("Check your API credentials and authentication configuration."),
152                        403 => Some(
153                            "Your credentials may be valid but lack permission for this operation.",
154                        ),
155                        404 => Some("Check that the API endpoint and parameters are correct."),
156                        429 => {
157                            Some("You're making requests too quickly. Wait before trying again.")
158                        }
159                        500..=599 => {
160                            Some("The API server is experiencing issues. Try again later.")
161                        }
162                        _ => None,
163                    })
164                } else {
165                    None
166                };
167                ("Network", req_err.to_string(), context.map(str::to_string), None)
168            }
169            Self::Yaml(yaml_err) => (
170                "YAMLParsing",
171                yaml_err.to_string(),
172                Some("Check that your OpenAPI specification is valid YAML syntax.".to_string()),
173                None,
174            ),
175            Self::Json(json_err) => (
176                "JSONParsing",
177                json_err.to_string(),
178                Some("Check that your request body or response contains valid JSON.".to_string()),
179                None,
180            ),
181            Self::Validation(msg) => (
182                "Validation",
183                msg.clone(),
184                Some(
185                    "Check that your OpenAPI specification follows the required format."
186                        .to_string(),
187                ),
188                None,
189            ),
190            Self::Toml(toml_err) => (
191                "TOMLParsing",
192                toml_err.to_string(),
193                Some("Check that your configuration file is valid TOML syntax.".to_string()),
194                None,
195            ),
196            Self::SpecNotFound { name } => (
197                "SpecNotFound",
198                format!("API specification '{name}' not found"),
199                Some("Use 'aperture config list' to see available specifications.".to_string()),
200                Some(json!({ "spec_name": name })),
201            ),
202            Self::SpecAlreadyExists { name } => (
203                "SpecAlreadyExists",
204                format!("API specification '{name}' already exists. Use --force to overwrite"),
205                None,
206                Some(json!({ "spec_name": name })),
207            ),
208            Self::CachedSpecNotFound { name } => (
209                "CachedSpecNotFound",
210                format!("No cached spec found for '{name}'. Run 'aperture config add {name}' first"),
211                None,
212                Some(json!({ "spec_name": name })),
213            ),
214            Self::CachedSpecCorrupted { name, reason } => (
215                "CachedSpecCorrupted",
216                format!("Failed to deserialize cached spec '{name}': {reason}. The cache may be corrupted"),
217                Some("Try removing and re-adding the specification.".to_string()),
218                Some(json!({ "spec_name": name, "corruption_reason": reason })),
219            ),
220            Self::CacheVersionMismatch { name, found, expected } => (
221                "CacheVersionMismatch",
222                format!("Cache format version mismatch for '{name}': found v{found}, expected v{expected}"),
223                Some("Run 'aperture config reinit' to regenerate the cache.".to_string()),
224                Some(json!({ "spec_name": name, "found_version": found, "expected_version": expected })),
225            ),
226            Self::SecretNotSet { scheme_name, env_var } => (
227                "SecretNotSet",
228                format!("Environment variable '{env_var}' required for authentication '{scheme_name}' is not set"),
229                Some(format!("Set the environment variable: export {env_var}=<your-secret>")),
230                Some(json!({ "scheme_name": scheme_name, "env_var": env_var })),
231            ),
232            Self::InvalidHeaderFormat { header } => (
233                "InvalidHeaderFormat",
234                format!("Invalid header format '{header}'. Expected 'Name: Value'"),
235                None,
236                Some(json!({ "header": header })),
237            ),
238            Self::InvalidHeaderName { name, reason } => (
239                "InvalidHeaderName",
240                format!("Invalid header name '{name}': {reason}"),
241                None,
242                Some(json!({ "name": name, "reason": reason })),
243            ),
244            Self::InvalidHeaderValue { name, reason } => (
245                "InvalidHeaderValue",
246                format!("Invalid header value for '{name}': {reason}"),
247                None,
248                Some(json!({ "name": name, "reason": reason })),
249            ),
250            Self::EditorNotSet => (
251                "EditorNotSet",
252                "EDITOR environment variable not set".to_string(),
253                Some("Set your preferred editor: export EDITOR=vim".to_string()),
254                None,
255            ),
256            Self::EditorFailed { name } => (
257                "EditorFailed",
258                format!("Editor command failed for spec '{name}'"),
259                None,
260                Some(json!({ "spec_name": name })),
261            ),
262            Self::InvalidHttpMethod { method } => (
263                "InvalidHttpMethod",
264                format!("Invalid HTTP method: {method}"),
265                None,
266                Some(json!({ "method": method })),
267            ),
268            Self::MissingPathParameter { name } => (
269                "MissingPathParameter",
270                format!("Missing path parameter: {name}"),
271                None,
272                Some(json!({ "parameter_name": name })),
273            ),
274            Self::UnsupportedAuthScheme { scheme } => (
275                "UnsupportedAuthScheme",
276                format!("Unsupported HTTP authentication scheme: {scheme}"),
277                Some("Only 'bearer' and 'basic' schemes are supported.".to_string()),
278                Some(json!({ "scheme": scheme })),
279            ),
280            Self::UnsupportedSecurityScheme { scheme_type } => (
281                "UnsupportedSecurityScheme",
282                format!("Unsupported security scheme type: {scheme_type}"),
283                Some("Only 'apiKey' and 'http' security schemes are supported.".to_string()),
284                Some(json!({ "scheme_type": scheme_type })),
285            ),
286            Self::SerializationError { reason } => (
287                "SerializationError",
288                format!("Failed to serialize cached spec: {reason}"),
289                None,
290                Some(json!({ "reason": reason })),
291            ),
292            Self::InvalidConfig { reason } => (
293                "InvalidConfig",
294                format!("Invalid config.toml: {reason}"),
295                Some("Check the TOML syntax in your configuration file.".to_string()),
296                Some(json!({ "reason": reason })),
297            ),
298            Self::HomeDirectoryNotFound => (
299                "HomeDirectoryNotFound",
300                "Could not determine home directory".to_string(),
301                Some("Ensure HOME environment variable is set.".to_string()),
302                None,
303            ),
304            Self::InvalidJsonBody { reason } => (
305                "InvalidJsonBody",
306                format!("Invalid JSON body: {reason}"),
307                Some("Check your JSON syntax and ensure all quotes are properly escaped.".to_string()),
308                Some(json!({ "reason": reason })),
309            ),
310            Self::RequestFailed { reason } => (
311                "RequestFailed",
312                format!("Request failed: {reason}"),
313                None,
314                Some(json!({ "reason": reason })),
315            ),
316            Self::ResponseReadError { reason } => (
317                "ResponseReadError",
318                format!("Failed to read response: {reason}"),
319                None,
320                Some(json!({ "reason": reason })),
321            ),
322            Self::HttpErrorWithContext { status, body, api_name, operation_id, security_schemes } => {
323                let context_hint = match status {
324                    401 => {
325                        if security_schemes.is_empty() {
326                            Some("Check your API credentials and authentication configuration.".to_string())
327                        } else {
328                            let env_vars: Vec<String> = security_schemes.iter()
329                                .map(|scheme| format!("Check environment variable for '{scheme}' authentication"))
330                                .collect();
331                            Some(env_vars.join("; "))
332                        }
333                    },
334                    403 => Some("Your credentials may be valid but lack permission for this operation.".to_string()),
335                    404 => Some("Check that the API endpoint and parameters are correct.".to_string()),
336                    429 => Some("You're making requests too quickly. Wait before trying again.".to_string()),
337                    500..=599 => Some("The API server is experiencing issues. Try again later.".to_string()),
338                    _ => None,
339                };
340                (
341                    "HttpError",
342                    format!("Request failed with status {status}: {body}"),
343                    context_hint,
344                    Some(json!({
345                        "status": status,
346                        "body": body,
347                        "api_name": api_name,
348                        "operation_id": operation_id,
349                        "security_schemes": security_schemes
350                    })),
351                )
352            },
353            Self::InvalidCommand { context, reason } => (
354                "InvalidCommand",
355                format!("Invalid command for API '{context}': {reason}"),
356                Some("Use --help to see available commands.".to_string()),
357                Some(json!({ "context": context, "reason": reason })),
358            ),
359            Self::OperationNotFound => (
360                "OperationNotFound",
361                "Could not find operation from command path".to_string(),
362                Some("Check that the command matches an available operation.".to_string()),
363                None,
364            ),
365            Self::InvalidIdempotencyKey => (
366                "InvalidIdempotencyKey",
367                "Invalid idempotency key".to_string(),
368                Some("Idempotency key must be a valid header value.".to_string()),
369                None,
370            ),
371            Self::EmptyHeaderName => (
372                "EmptyHeaderName",
373                "Header name cannot be empty".to_string(),
374                None,
375                None,
376            ),
377            Self::JqFilterError { reason } => (
378                "JqFilterError",
379                format!("JQ filter error: {reason}"),
380                Some("Check your JQ filter syntax. Common examples: '.name', '.[] | select(.active)'".to_string()),
381                Some(json!({ "reason": reason })),
382            ),
383            Self::InvalidPath { path, reason } => (
384                "InvalidPath",
385                format!("Invalid path '{path}': {reason}"),
386                Some("Check that the path is valid and properly formatted.".to_string()),
387                Some(json!({ "path": path, "reason": reason })),
388            ),
389            Self::Anyhow(err) => (
390                "Unexpected",
391                err.to_string(),
392                Some(
393                    "This may be a bug. Please report it with the command you were running."
394                        .to_string(),
395                ),
396                None,
397            ),
398        };
399
400        JsonError {
401            error_type: error_type.to_string(),
402            message,
403            context,
404            details,
405        }
406    }
407}