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(
31        "Environment variable '{env_var}' required for authentication '{scheme_name}' is not set"
32    )]
33    SecretNotSet {
34        scheme_name: String,
35        env_var: String,
36    },
37    #[error("Invalid header format '{header}'. Expected 'Name: Value'")]
38    InvalidHeaderFormat { header: String },
39    #[error("Invalid header name '{name}': {reason}")]
40    InvalidHeaderName { name: String, reason: String },
41    #[error("Invalid header value for '{name}': {reason}")]
42    InvalidHeaderValue { name: String, reason: String },
43    #[error("EDITOR environment variable not set")]
44    EditorNotSet,
45    #[error("Editor command failed for spec '{name}'")]
46    EditorFailed { name: String },
47    #[error("Invalid HTTP method: {method}")]
48    InvalidHttpMethod { method: String },
49    #[error("Missing path parameter: {name}")]
50    MissingPathParameter { name: String },
51    #[error("Unsupported HTTP authentication scheme: {scheme}")]
52    UnsupportedAuthScheme { scheme: String },
53    #[error("Unsupported security scheme type: {scheme_type}")]
54    UnsupportedSecurityScheme { scheme_type: String },
55    #[error("Failed to serialize cached spec: {reason}")]
56    SerializationError { reason: String },
57    #[error("Invalid config.toml: {reason}")]
58    InvalidConfig { reason: String },
59    #[error("Could not determine home directory")]
60    HomeDirectoryNotFound,
61    #[error("Invalid JSON body: {reason}")]
62    InvalidJsonBody { reason: String },
63    #[error("Request failed: {reason}")]
64    RequestFailed { reason: String },
65    #[error("Failed to read response: {reason}")]
66    ResponseReadError { reason: String },
67    #[error("Request failed with status {status}: {body}")]
68    HttpError { status: u16, body: String },
69    #[error("Invalid command for API '{context}': {reason}")]
70    InvalidCommand { context: String, reason: String },
71    #[error("Could not find operation from command path")]
72    OperationNotFound,
73    #[error("Invalid idempotency key")]
74    InvalidIdempotencyKey,
75    #[error("Header name cannot be empty")]
76    EmptyHeaderName,
77
78    #[error(transparent)]
79    Anyhow(#[from] anyhow::Error),
80}
81
82/// JSON representation of an error for structured output
83#[derive(Debug, Serialize, Deserialize)]
84pub struct JsonError {
85    pub error_type: String,
86    pub message: String,
87    pub context: Option<String>,
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub details: Option<serde_json::Value>,
90}
91
92impl Error {
93    /// Add context to an error for better user messaging
94    #[must_use]
95    pub fn with_context(self, context: &str) -> Self {
96        match self {
97            Self::Network(e) => Self::Config(format!("{context}: {e}")),
98            Self::Io(e) => Self::Config(format!("{context}: {e}")),
99            _ => self,
100        }
101    }
102
103    /// Convert error to JSON representation for structured output
104    #[must_use]
105    #[allow(clippy::too_many_lines)]
106    pub fn to_json(&self) -> JsonError {
107        use serde_json::json;
108
109        let (error_type, message, context, details) = match self {
110            Self::Config(msg) => ("Configuration", msg.clone(), None, None),
111            Self::Io(io_err) => {
112                let context = match io_err.kind() {
113                    std::io::ErrorKind::NotFound => {
114                        Some("Check that the file path is correct and the file exists.")
115                    }
116                    std::io::ErrorKind::PermissionDenied => {
117                        Some("Check file permissions or run with appropriate privileges.")
118                    }
119                    _ => None,
120                };
121                (
122                    "FileSystem",
123                    io_err.to_string(),
124                    context.map(str::to_string),
125                    None,
126                )
127            }
128            Self::Network(req_err) => {
129                let context = if req_err.is_connect() {
130                    Some("Check that the API server is running and accessible.")
131                } else if req_err.is_timeout() {
132                    Some("The API server may be slow or unresponsive. Try again later.")
133                } else if req_err.is_status() {
134                    req_err.status().and_then(|status| match status.as_u16() {
135                        401 => Some("Check your API credentials and authentication configuration."),
136                        403 => Some(
137                            "Your credentials may be valid but lack permission for this operation.",
138                        ),
139                        404 => Some("Check that the API endpoint and parameters are correct."),
140                        429 => {
141                            Some("You're making requests too quickly. Wait before trying again.")
142                        }
143                        500..=599 => {
144                            Some("The API server is experiencing issues. Try again later.")
145                        }
146                        _ => None,
147                    })
148                } else {
149                    None
150                };
151                ("Network", req_err.to_string(), context.map(str::to_string), None)
152            }
153            Self::Yaml(yaml_err) => (
154                "YAMLParsing",
155                yaml_err.to_string(),
156                Some("Check that your OpenAPI specification is valid YAML syntax.".to_string()),
157                None,
158            ),
159            Self::Json(json_err) => (
160                "JSONParsing",
161                json_err.to_string(),
162                Some("Check that your request body or response contains valid JSON.".to_string()),
163                None,
164            ),
165            Self::Validation(msg) => (
166                "Validation",
167                msg.clone(),
168                Some(
169                    "Check that your OpenAPI specification follows the required format."
170                        .to_string(),
171                ),
172                None,
173            ),
174            Self::Toml(toml_err) => (
175                "TOMLParsing",
176                toml_err.to_string(),
177                Some("Check that your configuration file is valid TOML syntax.".to_string()),
178                None,
179            ),
180            Self::SpecNotFound { name } => (
181                "SpecNotFound",
182                format!("API specification '{name}' not found"),
183                Some("Use 'aperture config list' to see available specifications.".to_string()),
184                Some(json!({ "spec_name": name })),
185            ),
186            Self::SpecAlreadyExists { name } => (
187                "SpecAlreadyExists",
188                format!("API specification '{name}' already exists. Use --force to overwrite"),
189                None,
190                Some(json!({ "spec_name": name })),
191            ),
192            Self::CachedSpecNotFound { name } => (
193                "CachedSpecNotFound",
194                format!("No cached spec found for '{name}'. Run 'aperture config add {name}' first"),
195                None,
196                Some(json!({ "spec_name": name })),
197            ),
198            Self::CachedSpecCorrupted { name, reason } => (
199                "CachedSpecCorrupted",
200                format!("Failed to deserialize cached spec '{name}': {reason}. The cache may be corrupted"),
201                Some("Try removing and re-adding the specification.".to_string()),
202                Some(json!({ "spec_name": name, "corruption_reason": reason })),
203            ),
204            Self::SecretNotSet { scheme_name, env_var } => (
205                "SecretNotSet",
206                format!("Environment variable '{env_var}' required for authentication '{scheme_name}' is not set"),
207                Some(format!("Set the environment variable: export {env_var}=<your-secret>")),
208                Some(json!({ "scheme_name": scheme_name, "env_var": env_var })),
209            ),
210            Self::InvalidHeaderFormat { header } => (
211                "InvalidHeaderFormat",
212                format!("Invalid header format '{header}'. Expected 'Name: Value'"),
213                None,
214                Some(json!({ "header": header })),
215            ),
216            Self::InvalidHeaderName { name, reason } => (
217                "InvalidHeaderName",
218                format!("Invalid header name '{name}': {reason}"),
219                None,
220                Some(json!({ "name": name, "reason": reason })),
221            ),
222            Self::InvalidHeaderValue { name, reason } => (
223                "InvalidHeaderValue",
224                format!("Invalid header value for '{name}': {reason}"),
225                None,
226                Some(json!({ "name": name, "reason": reason })),
227            ),
228            Self::EditorNotSet => (
229                "EditorNotSet",
230                "EDITOR environment variable not set".to_string(),
231                Some("Set your preferred editor: export EDITOR=vim".to_string()),
232                None,
233            ),
234            Self::EditorFailed { name } => (
235                "EditorFailed",
236                format!("Editor command failed for spec '{name}'"),
237                None,
238                Some(json!({ "spec_name": name })),
239            ),
240            Self::InvalidHttpMethod { method } => (
241                "InvalidHttpMethod",
242                format!("Invalid HTTP method: {method}"),
243                None,
244                Some(json!({ "method": method })),
245            ),
246            Self::MissingPathParameter { name } => (
247                "MissingPathParameter",
248                format!("Missing path parameter: {name}"),
249                None,
250                Some(json!({ "parameter_name": name })),
251            ),
252            Self::UnsupportedAuthScheme { scheme } => (
253                "UnsupportedAuthScheme",
254                format!("Unsupported HTTP authentication scheme: {scheme}"),
255                Some("Only 'bearer' and 'basic' schemes are supported.".to_string()),
256                Some(json!({ "scheme": scheme })),
257            ),
258            Self::UnsupportedSecurityScheme { scheme_type } => (
259                "UnsupportedSecurityScheme",
260                format!("Unsupported security scheme type: {scheme_type}"),
261                Some("Only 'apiKey' and 'http' security schemes are supported.".to_string()),
262                Some(json!({ "scheme_type": scheme_type })),
263            ),
264            Self::SerializationError { reason } => (
265                "SerializationError",
266                format!("Failed to serialize cached spec: {reason}"),
267                None,
268                Some(json!({ "reason": reason })),
269            ),
270            Self::InvalidConfig { reason } => (
271                "InvalidConfig",
272                format!("Invalid config.toml: {reason}"),
273                Some("Check the TOML syntax in your configuration file.".to_string()),
274                Some(json!({ "reason": reason })),
275            ),
276            Self::HomeDirectoryNotFound => (
277                "HomeDirectoryNotFound",
278                "Could not determine home directory".to_string(),
279                Some("Ensure HOME environment variable is set.".to_string()),
280                None,
281            ),
282            Self::InvalidJsonBody { reason } => (
283                "InvalidJsonBody",
284                format!("Invalid JSON body: {reason}"),
285                Some("Check your JSON syntax and ensure all quotes are properly escaped.".to_string()),
286                Some(json!({ "reason": reason })),
287            ),
288            Self::RequestFailed { reason } => (
289                "RequestFailed",
290                format!("Request failed: {reason}"),
291                None,
292                Some(json!({ "reason": reason })),
293            ),
294            Self::ResponseReadError { reason } => (
295                "ResponseReadError",
296                format!("Failed to read response: {reason}"),
297                None,
298                Some(json!({ "reason": reason })),
299            ),
300            Self::HttpError { status, body } => (
301                "HttpError",
302                format!("Request failed with status {status}: {body}"),
303                None,
304                Some(json!({ "status": status, "body": body })),
305            ),
306            Self::InvalidCommand { context, reason } => (
307                "InvalidCommand",
308                format!("Invalid command for API '{context}': {reason}"),
309                Some("Use --help to see available commands.".to_string()),
310                Some(json!({ "context": context, "reason": reason })),
311            ),
312            Self::OperationNotFound => (
313                "OperationNotFound",
314                "Could not find operation from command path".to_string(),
315                Some("Check that the command matches an available operation.".to_string()),
316                None,
317            ),
318            Self::InvalidIdempotencyKey => (
319                "InvalidIdempotencyKey",
320                "Invalid idempotency key".to_string(),
321                Some("Idempotency key must be a valid header value.".to_string()),
322                None,
323            ),
324            Self::EmptyHeaderName => (
325                "EmptyHeaderName",
326                "Header name cannot be empty".to_string(),
327                None,
328                None,
329            ),
330            Self::Anyhow(err) => (
331                "Unexpected",
332                err.to_string(),
333                Some(
334                    "This may be a bug. Please report it with the command you were running."
335                        .to_string(),
336                ),
337                None,
338            ),
339        };
340
341        JsonError {
342            error_type: error_type.to_string(),
343            message,
344            context,
345            details,
346        }
347    }
348}