Skip to main content

aperture_cli/
error.rs

1//! Error handling module for Aperture CLI
2//!
3//! This module provides a consolidated error handling system that categorizes
4//! all application errors into 9 distinct kinds. The design follows these principles:
5//!
6//! 1. **Error Consolidation**: All errors are mapped to one of 9 `ErrorKind` categories
7//! 2. **Structured Context**: Each error can include structured JSON details and suggestions
8//! 3. **Builder Pattern**: `ErrorContext` provides fluent builder methods for error construction
9//! 4. **JSON Support**: All errors can be serialized to JSON for programmatic consumption
10
11use crate::constants;
12use serde::{Deserialize, Serialize};
13use serde_json::json;
14use std::borrow::Cow;
15use thiserror::Error;
16
17#[derive(Error, Debug)]
18pub enum Error {
19    // Keep essential external errors that can't be consolidated
20    #[error("I/O error: {0}")]
21    Io(#[from] std::io::Error),
22    #[error("Network error: {0}")]
23    Network(#[from] reqwest::Error),
24    #[error("YAML parsing error: {0}")]
25    Yaml(#[from] serde_yaml::Error),
26    #[error("JSON parsing error: {0}")]
27    Json(#[from] serde_json::Error),
28    #[error("TOML parsing error: {0}")]
29    Toml(#[from] toml::de::Error),
30
31    // Consolidated error variant using new infrastructure
32    #[error("{kind}: {message}")]
33    Internal {
34        kind: ErrorKind,
35        message: Cow<'static, str>,
36        context: Option<ErrorContext>,
37    },
38
39    #[error(transparent)]
40    Anyhow(#[from] anyhow::Error),
41}
42
43/// Error categories for consolidated error handling
44///
45/// This enum represents the 8 primary error categories used throughout
46/// the application. All internal errors are mapped to one of these categories
47/// to provide consistent error handling and reporting.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum ErrorKind {
50    /// Specification-related errors (not found, already exists, cache issues)
51    Specification,
52    /// Authentication and authorization errors
53    Authentication,
54    /// Input validation and configuration errors
55    Validation,
56    /// Network connectivity and transport errors (connection, DNS, timeouts)
57    Network,
58    /// HTTP request/response errors (status codes, API errors)
59    HttpRequest,
60    /// Header processing errors
61    Headers,
62    /// Interactive input errors
63    Interactive,
64    /// Server variable resolution errors
65    ServerVariable,
66    /// Runtime operation errors
67    Runtime,
68}
69
70/// Additional context for consolidated errors
71#[derive(Debug, Clone)]
72pub struct ErrorContext {
73    /// Structured details for programmatic access
74    pub details: Option<serde_json::Value>,
75    /// Human-readable suggestion for resolving the error
76    pub suggestion: Option<Cow<'static, str>>,
77}
78
79impl ErrorContext {
80    /// Create a new error context with details and suggestion
81    #[must_use]
82    pub const fn new(
83        details: Option<serde_json::Value>,
84        suggestion: Option<Cow<'static, str>>,
85    ) -> Self {
86        Self {
87            details,
88            suggestion,
89        }
90    }
91
92    /// Create error context with only details
93    #[must_use]
94    pub const fn with_details(details: serde_json::Value) -> Self {
95        Self {
96            details: Some(details),
97            suggestion: None,
98        }
99    }
100
101    /// Create error context with only suggestion
102    #[must_use]
103    pub const fn with_suggestion(suggestion: Cow<'static, str>) -> Self {
104        Self {
105            details: None,
106            suggestion: Some(suggestion),
107        }
108    }
109
110    /// Builder method to add a single detail field
111    #[must_use]
112    pub fn with_detail(key: &str, value: impl serde::Serialize) -> Self {
113        Self {
114            details: Some(json!({ key: value })),
115            suggestion: None,
116        }
117    }
118
119    /// Builder method to add name and reason details
120    #[must_use]
121    pub fn with_name_reason(name_field: &str, name: &str, reason: &str) -> Self {
122        Self {
123            details: Some(json!({ name_field: name, "reason": reason })),
124            suggestion: None,
125        }
126    }
127
128    /// Add suggestion to existing context
129    #[must_use]
130    pub fn and_suggestion(mut self, suggestion: impl Into<String>) -> Self {
131        self.suggestion = Some(Cow::Owned(suggestion.into()));
132        self
133    }
134}
135
136impl ErrorKind {
137    /// Get the string identifier for this error kind
138    #[must_use]
139    pub const fn as_str(&self) -> &'static str {
140        match self {
141            Self::Specification => "Specification",
142            Self::Authentication => "Authentication",
143            Self::Validation => "Validation",
144            Self::Network => "Network",
145            Self::HttpRequest => "HttpError",
146            Self::Headers => "Headers",
147            Self::Interactive => "Interactive",
148            Self::ServerVariable => "ServerVariable",
149            Self::Runtime => "Runtime",
150        }
151    }
152}
153
154impl std::fmt::Display for ErrorKind {
155    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156        write!(f, "{}", self.as_str())
157    }
158}
159
160/// JSON representation of an error for structured output
161#[derive(Debug, Serialize, Deserialize)]
162pub struct JsonError {
163    pub error_type: Cow<'static, str>,
164    pub message: String,
165    pub context: Option<Cow<'static, str>>,
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub details: Option<serde_json::Value>,
168}
169
170impl Error {
171    /// Add context to an error for better user messaging
172    #[must_use]
173    pub fn with_context(self, context: &str) -> Self {
174        match self {
175            Self::Network(e) => Self::invalid_config(format!("{context}: {e}")),
176            Self::Io(e) => Self::invalid_config(format!("{context}: {e}")),
177            Self::Internal {
178                kind,
179                message,
180                context: ctx,
181            } => Self::Internal {
182                kind,
183                message: Cow::Owned(format!("{context}: {message}")),
184                context: ctx,
185            },
186            _ => self,
187        }
188    }
189
190    /// Add operation context to an error for better debugging
191    #[must_use]
192    pub fn with_operation_context(self, operation: &str, api: &str) -> Self {
193        match self {
194            Self::Internal {
195                kind,
196                message,
197                context,
198            } => Self::Internal {
199                kind,
200                message: Cow::Owned(format!("Operation '{operation}' on API '{api}': {message}")),
201                context,
202            },
203            Self::Network(e) => {
204                Self::invalid_config(format!("Operation '{operation}' on API '{api}': {e}"))
205            }
206            _ => self,
207        }
208    }
209
210    /// Add suggestions to error messages for better user guidance
211    #[must_use]
212    pub fn with_suggestion(self, suggestion: &str) -> Self {
213        match self {
214            Self::Internal {
215                kind,
216                message,
217                context,
218            } => Self::Internal {
219                kind,
220                message,
221                context: context.map_or_else(
222                    || {
223                        Some(ErrorContext::with_suggestion(Cow::Owned(
224                            suggestion.to_string(),
225                        )))
226                    },
227                    |mut ctx| {
228                        ctx.suggestion = Some(Cow::Owned(suggestion.to_string()));
229                        Some(ctx)
230                    },
231                ),
232            },
233            _ => self,
234        }
235    }
236
237    /// Convert error to JSON representation for structured output
238    #[must_use]
239    pub fn to_json(&self) -> JsonError {
240        let (error_type, message, context, details): (
241            &str,
242            String,
243            Option<Cow<'static, str>>,
244            Option<serde_json::Value>,
245        ) = match self {
246            Self::Io(io_err) => {
247                let context = match io_err.kind() {
248                    std::io::ErrorKind::NotFound => {
249                        Some(Cow::Borrowed(constants::ERR_FILE_NOT_FOUND))
250                    }
251                    std::io::ErrorKind::PermissionDenied => {
252                        Some(Cow::Borrowed(constants::ERR_PERMISSION))
253                    }
254                    _ => None,
255                };
256                ("FileSystem", io_err.to_string(), context, None)
257            }
258            Self::Network(req_err) => {
259                let context = match () {
260                    () if req_err.is_connect() => Some(Cow::Borrowed(constants::ERR_CONNECTION)),
261                    () if req_err.is_timeout() => Some(Cow::Borrowed(constants::ERR_TIMEOUT)),
262                    () if req_err.is_status() => {
263                        req_err.status().and_then(|status| match status.as_u16() {
264                            401 => Some(Cow::Borrowed(constants::ERR_API_CREDENTIALS)),
265                            403 => Some(Cow::Borrowed(constants::ERR_PERMISSION_DENIED)),
266                            404 => Some(Cow::Borrowed(constants::ERR_ENDPOINT_NOT_FOUND)),
267                            429 => Some(Cow::Borrowed(constants::ERR_RATE_LIMITED)),
268                            500..=599 => Some(Cow::Borrowed(constants::ERR_SERVER_ERROR)),
269                            _ => None,
270                        })
271                    }
272                    () => None,
273                };
274                ("Network", req_err.to_string(), context, None)
275            }
276            Self::Yaml(yaml_err) => (
277                "YAMLParsing",
278                yaml_err.to_string(),
279                Some(Cow::Borrowed(constants::ERR_YAML_SYNTAX)),
280                None,
281            ),
282            Self::Json(json_err) => (
283                "JSONParsing",
284                json_err.to_string(),
285                Some(Cow::Borrowed(constants::ERR_JSON_SYNTAX)),
286                None,
287            ),
288            Self::Toml(toml_err) => (
289                "TOMLParsing",
290                toml_err.to_string(),
291                Some(Cow::Borrowed(constants::ERR_TOML_SYNTAX)),
292                None,
293            ),
294            Self::Internal {
295                kind,
296                message,
297                context: ctx,
298            } => {
299                let context = ctx.as_ref().and_then(|c| c.suggestion.clone());
300                let details = ctx.as_ref().and_then(|c| c.details.clone());
301                (kind.as_str(), message.to_string(), context, details)
302            }
303            Self::Anyhow(anyhow_err) => ("Unknown", anyhow_err.to_string(), None, None),
304        };
305
306        JsonError {
307            error_type: Cow::Borrowed(error_type),
308            message,
309            context,
310            details,
311        }
312    }
313}
314
315impl Error {
316    /// Create a specification not found error
317    pub fn spec_not_found(name: impl Into<String>) -> Self {
318        let name = name.into();
319        Self::Internal {
320            kind: ErrorKind::Specification,
321            message: Cow::Owned(format!("API specification '{name}' not found")),
322            context: Some(
323                ErrorContext::with_detail("spec_name", &name)
324                    .and_suggestion(constants::MSG_USE_CONFIG_LIST),
325            ),
326        }
327    }
328
329    /// Create a specification already exists error
330    pub fn spec_already_exists(name: impl Into<String>) -> Self {
331        let name = name.into();
332        Self::Internal {
333            kind: ErrorKind::Specification,
334            message: Cow::Owned(format!(
335                "API specification '{name}' already exists. Use --force to overwrite"
336            )),
337            context: Some(ErrorContext::with_detail("spec_name", &name)),
338        }
339    }
340
341    /// Create a cache stale error when the spec file has been modified since caching
342    pub fn cache_stale(name: impl Into<String>) -> Self {
343        let name = name.into();
344        Self::Internal {
345            kind: ErrorKind::Specification,
346            message: Cow::Owned(format!(
347                "Cache for '{name}' is stale — the spec file has been modified since it was cached"
348            )),
349            context: Some(ErrorContext::new(
350                Some(json!({ "spec_name": name })),
351                Some(Cow::Owned(format!(
352                    "Run 'aperture config reinit {name}' to regenerate the cache."
353                ))),
354            )),
355        }
356    }
357
358    /// Create a cached spec not found error
359    pub fn cached_spec_not_found(name: impl Into<String>) -> Self {
360        let name = name.into();
361        Self::Internal {
362            kind: ErrorKind::Specification,
363            message: Cow::Owned(format!(
364                "No cached spec found for '{name}'. Run 'aperture config add {name}' first"
365            )),
366            context: Some(ErrorContext::with_detail("spec_name", &name)),
367        }
368    }
369
370    /// Create a cached spec corrupted error
371    pub fn cached_spec_corrupted(name: impl Into<String>, reason: impl Into<String>) -> Self {
372        let name = name.into();
373        let reason = reason.into();
374        Self::Internal {
375            kind: ErrorKind::Specification,
376            message: Cow::Owned(format!(
377                "Failed to deserialize cached spec '{name}': {reason}. The cache may be corrupted"
378            )),
379            context: Some(ErrorContext::new(
380                Some(json!({ "spec_name": name, "corruption_reason": reason })),
381                Some(Cow::Borrowed(
382                    "Try removing and re-adding the specification.",
383                )),
384            )),
385        }
386    }
387
388    /// Create a cache version mismatch error
389    pub fn cache_version_mismatch(name: impl Into<String>, found: u32, expected: u32) -> Self {
390        let name = name.into();
391        Self::Internal {
392            kind: ErrorKind::Specification,
393            message: Cow::Owned(format!(
394                "Cache format version mismatch for '{name}': found v{found}, expected v{expected}"
395            )),
396            context: Some(ErrorContext::new(
397                Some(
398                    json!({ "spec_name": name, "found_version": found, "expected_version": expected }),
399                ),
400                Some(Cow::Borrowed(
401                    "Run 'aperture config reinit' to regenerate the cache.",
402                )),
403            )),
404        }
405    }
406
407    /// Create a secret not set error
408    pub fn secret_not_set(scheme_name: impl Into<String>, env_var: impl Into<String>) -> Self {
409        let scheme_name = scheme_name.into();
410        let env_var = env_var.into();
411        let suggestion = crate::suggestions::suggest_auth_fix(&scheme_name, Some(&env_var));
412        Self::Internal {
413            kind: ErrorKind::Authentication,
414            message: Cow::Owned(format!(
415                "Environment variable '{env_var}' required for authentication '{scheme_name}' is not set"
416            )),
417            context: Some(ErrorContext::new(
418                Some(json!({ "scheme_name": scheme_name, "env_var": env_var })),
419                Some(Cow::Owned(suggestion)),
420            )),
421        }
422    }
423
424    /// Create an unsupported auth scheme error
425    pub fn unsupported_auth_scheme(scheme: impl Into<String>) -> Self {
426        let scheme = scheme.into();
427        Self::Internal {
428            kind: ErrorKind::Authentication,
429            message: Cow::Owned(format!("Unsupported HTTP authentication scheme: {scheme}")),
430            context: Some(ErrorContext::new(
431                Some(json!({ "scheme": scheme })),
432                Some(Cow::Borrowed(
433                    "Only 'bearer' and 'basic' schemes are supported.",
434                )),
435            )),
436        }
437    }
438
439    /// Create an unsupported security scheme error
440    pub fn unsupported_security_scheme(scheme_type: impl Into<String>) -> Self {
441        let scheme_type = scheme_type.into();
442        Self::Internal {
443            kind: ErrorKind::Authentication,
444            message: Cow::Owned(format!("Unsupported security scheme type: {scheme_type}")),
445            context: Some(ErrorContext::new(
446                Some(json!({ "scheme_type": scheme_type })),
447                Some(Cow::Borrowed(
448                    "Only 'apiKey' and 'http' security schemes are supported.",
449                )),
450            )),
451        }
452    }
453
454    /// Create a generic validation error
455    pub fn validation_error(message: impl Into<String>) -> Self {
456        let message = message.into();
457        Self::Internal {
458            kind: ErrorKind::Validation,
459            message: Cow::Owned(format!("Validation error: {message}")),
460            context: None,
461        }
462    }
463
464    /// Create an invalid configuration error
465    pub fn invalid_config(reason: impl Into<String>) -> Self {
466        let reason = reason.into();
467        Self::Internal {
468            kind: ErrorKind::Validation,
469            message: Cow::Owned(format!("Invalid configuration: {reason}")),
470            context: Some(
471                ErrorContext::with_detail("reason", &reason)
472                    .and_suggestion("Check the configuration file syntax and structure."),
473            ),
474        }
475    }
476
477    /// Create an invalid JSON body error
478    pub fn invalid_json_body(reason: impl Into<String>) -> Self {
479        let reason = reason.into();
480        Self::Internal {
481            kind: ErrorKind::Validation,
482            message: Cow::Owned(format!("Invalid JSON body: {reason}")),
483            context: Some(ErrorContext::new(
484                Some(json!({ "reason": reason })),
485                Some(Cow::Borrowed(
486                    "Check that the JSON body is properly formatted.",
487                )),
488            )),
489        }
490    }
491
492    /// Create an invalid path error
493    pub fn invalid_path(path: impl Into<String>, reason: impl Into<String>) -> Self {
494        let path = path.into();
495        let reason = reason.into();
496        Self::Internal {
497            kind: ErrorKind::Validation,
498            message: Cow::Owned(format!("Invalid path '{path}': {reason}")),
499            context: Some(ErrorContext::new(
500                Some(json!({ "path": path, "reason": reason })),
501                Some(Cow::Borrowed("Check the path format and ensure it exists.")),
502            )),
503        }
504    }
505
506    /// Create a request failed error
507    pub fn request_failed(status: reqwest::StatusCode, reason: impl Into<String>) -> Self {
508        let reason = reason.into();
509        Self::Internal {
510            kind: ErrorKind::HttpRequest,
511            message: Cow::Owned(format!("Request failed with status {status}: {reason}")),
512            context: Some(ErrorContext::new(
513                Some(json!({ "status_code": status.as_u16(), "reason": reason })),
514                Some(Cow::Borrowed(
515                    "Check the API endpoint, parameters, and authentication.",
516                )),
517            )),
518        }
519    }
520
521    /// Create a response read error
522    pub fn response_read_error(reason: impl Into<String>) -> Self {
523        let reason = reason.into();
524        Self::Internal {
525            kind: ErrorKind::HttpRequest,
526            message: Cow::Owned(format!("Failed to read response: {reason}")),
527            context: Some(ErrorContext::new(
528                Some(json!({ "reason": reason })),
529                Some(Cow::Borrowed(
530                    "Check network connectivity and server status.",
531                )),
532            )),
533        }
534    }
535
536    /// Create an invalid HTTP method error
537    pub fn invalid_http_method(method: impl Into<String>) -> Self {
538        let method = method.into();
539        Self::Internal {
540            kind: ErrorKind::HttpRequest,
541            message: Cow::Owned(format!("Invalid HTTP method: {method}")),
542            context: Some(ErrorContext::new(
543                Some(json!({ "method": method })),
544                Some(Cow::Borrowed(
545                    "Valid HTTP methods are: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS.",
546                )),
547            )),
548        }
549    }
550
551    // ---- Header Errors ----
552
553    /// Create an invalid header name error
554    pub fn invalid_header_name(name: impl Into<String>, reason: impl Into<String>) -> Self {
555        let name = name.into();
556        let reason = reason.into();
557        Self::Internal {
558            kind: ErrorKind::Headers,
559            message: Cow::Owned(format!("Invalid header name '{name}': {reason}")),
560            context: Some(ErrorContext::new(
561                Some(json!({ "header_name": name, "reason": reason })),
562                Some(Cow::Borrowed(
563                    "Header names must contain only valid HTTP header characters.",
564                )),
565            )),
566        }
567    }
568
569    /// Create an invalid header value error
570    pub fn invalid_header_value(name: impl Into<String>, reason: impl Into<String>) -> Self {
571        let name = name.into();
572        let reason = reason.into();
573        Self::Internal {
574            kind: ErrorKind::Headers,
575            message: Cow::Owned(format!("Invalid header value for '{name}': {reason}")),
576            context: Some(ErrorContext::new(
577                Some(json!({ "header_name": name, "reason": reason })),
578                Some(Cow::Borrowed(
579                    "Header values must contain only valid HTTP header characters.",
580                )),
581            )),
582        }
583    }
584
585    /// Create an invalid header format error
586    pub fn invalid_header_format(header: impl Into<String>) -> Self {
587        let header = header.into();
588        Self::Internal {
589            kind: ErrorKind::Headers,
590            message: Cow::Owned(format!(
591                "Invalid header format '{header}'. Expected 'Name: Value'"
592            )),
593            context: Some(ErrorContext::new(
594                Some(json!({ "header": header })),
595                Some(Cow::Borrowed("Headers must be in 'Name: Value' format.")),
596            )),
597        }
598    }
599
600    /// Create an empty header name error
601    #[must_use]
602    pub const fn empty_header_name() -> Self {
603        Self::Internal {
604            kind: ErrorKind::Headers,
605            message: Cow::Borrowed("Header name cannot be empty"),
606            context: Some(ErrorContext::with_suggestion(Cow::Borrowed(
607                "Provide a valid header name before the colon.",
608            ))),
609        }
610    }
611
612    // ---- Interactive Errors ----
613
614    /// Create an interactive input too long error
615    #[must_use]
616    pub fn interactive_input_too_long(max_length: usize) -> Self {
617        Self::Internal {
618            kind: ErrorKind::Interactive,
619            message: Cow::Owned(format!("Input too long (maximum {max_length} characters)")),
620            context: Some(
621                ErrorContext::with_detail("max_length", max_length)
622                    .and_suggestion("Please provide a shorter input."),
623            ),
624        }
625    }
626
627    /// Create an interactive invalid characters error
628    pub fn interactive_invalid_characters(
629        invalid_chars: impl Into<String>,
630        suggestion: impl Into<String>,
631    ) -> Self {
632        let invalid_chars = invalid_chars.into();
633        Self::Internal {
634            kind: ErrorKind::Interactive,
635            message: Cow::Owned(format!("Invalid characters found: {invalid_chars}")),
636            context: Some(ErrorContext::new(
637                Some(json!({ "invalid_characters": invalid_chars })),
638                Some(Cow::Owned(suggestion.into())),
639            )),
640        }
641    }
642
643    /// Create an interactive timeout error
644    #[must_use]
645    pub const fn interactive_timeout() -> Self {
646        Self::Internal {
647            kind: ErrorKind::Interactive,
648            message: Cow::Borrowed("Input timeout - no response received"),
649            context: Some(ErrorContext::with_suggestion(Cow::Borrowed(
650                "Please respond within the timeout period.",
651            ))),
652        }
653    }
654
655    /// Create an interactive retries exhausted error
656    pub fn interactive_retries_exhausted(
657        max_retries: usize,
658        last_error: impl Into<String>,
659        suggestions: &[String],
660    ) -> Self {
661        let last_error = last_error.into();
662        Self::Internal {
663            kind: ErrorKind::Interactive,
664            message: Cow::Owned(format!(
665                "Maximum retry attempts ({max_retries}) exceeded: {last_error}"
666            )),
667            context: Some(ErrorContext::new(
668                Some(
669                    json!({ "max_attempts": max_retries, "last_error": last_error, "suggestions": suggestions }),
670                ),
671                Some(Cow::Owned(format!(
672                    "Suggestions: {}",
673                    suggestions.join("; ")
674                ))),
675            )),
676        }
677    }
678
679    // ---- Server Variable Errors ----
680
681    /// Create a missing server variable error
682    pub fn missing_server_variable(name: impl Into<String>) -> Self {
683        let name = name.into();
684        Self::Internal {
685            kind: ErrorKind::ServerVariable,
686            message: Cow::Owned(format!("Required server variable '{name}' is not provided")),
687            context: Some(
688                ErrorContext::with_detail("variable_name", &name).and_suggestion(format!(
689                    "Provide the variable with --server-var {name}=<value>"
690                )),
691            ),
692        }
693    }
694
695    /// Create an unknown server variable error
696    pub fn unknown_server_variable(name: impl Into<String>, available: &[String]) -> Self {
697        let name = name.into();
698        let available_list = available.join(", ");
699        Self::Internal {
700            kind: ErrorKind::ServerVariable,
701            message: Cow::Owned(format!(
702                "Unknown server variable '{name}'. Available variables: {available_list}"
703            )),
704            context: Some(ErrorContext::new(
705                Some(json!({ "variable_name": name, "available_variables": available })),
706                Some(Cow::Owned(format!("Use one of: {available_list}"))),
707            )),
708        }
709    }
710
711    /// Create an unresolved template variable error
712    pub fn unresolved_template_variable(name: impl Into<String>, url: impl Into<String>) -> Self {
713        let name = name.into();
714        let url = url.into();
715        Self::Internal {
716            kind: ErrorKind::ServerVariable,
717            message: Cow::Owned(format!(
718                "Unresolved template variable '{name}' in URL '{url}'"
719            )),
720            context: Some(ErrorContext::new(
721                Some(json!({ "variable_name": name, "template_url": url })),
722                Some(Cow::Borrowed(
723                    "Ensure all template variables are provided with --server-var",
724                )),
725            )),
726        }
727    }
728
729    /// Create an invalid environment variable name error with suggestion
730    pub fn invalid_environment_variable_name(
731        name: impl Into<String>,
732        reason: impl Into<String>,
733        suggestion: impl Into<String>,
734    ) -> Self {
735        let name = name.into();
736        let reason = reason.into();
737        Self::Internal {
738            kind: ErrorKind::Interactive,
739            message: Cow::Owned(format!(
740                "Invalid environment variable name '{name}': {reason}"
741            )),
742            context: Some(
743                ErrorContext::with_name_reason("variable_name", &name, &reason)
744                    .and_suggestion(suggestion),
745            ),
746        }
747    }
748
749    /// Create an invalid server variable format error
750    pub fn invalid_server_var_format(arg: impl Into<String>, reason: impl Into<String>) -> Self {
751        let arg = arg.into();
752        let reason = reason.into();
753        Self::Internal {
754            kind: ErrorKind::ServerVariable,
755            message: Cow::Owned(format!(
756                "Invalid server variable format in '{arg}': {reason}"
757            )),
758            context: Some(ErrorContext::new(
759                Some(json!({ "argument": arg, "reason": reason })),
760                Some(Cow::Borrowed(
761                    "Server variables must be in 'key=value' format.",
762                )),
763            )),
764        }
765    }
766
767    /// Create an invalid server variable value error
768    pub fn invalid_server_var_value(
769        name: impl Into<String>,
770        value: impl Into<String>,
771        allowed_values: &[String],
772    ) -> Self {
773        let name = name.into();
774        let value = value.into();
775        Self::Internal {
776            kind: ErrorKind::ServerVariable,
777            message: Cow::Owned(format!(
778                "Invalid value '{value}' for server variable '{name}'"
779            )),
780            context: Some(ErrorContext::new(
781                Some(
782                    json!({ "variable_name": name, "provided_value": value, "allowed_values": allowed_values }),
783                ),
784                Some(Cow::Owned(format!(
785                    "Allowed values: {}",
786                    allowed_values.join(", ")
787                ))),
788            )),
789        }
790    }
791
792    // ---- Runtime Errors ----
793
794    /// Create an operation not found error
795    pub fn operation_not_found(operation: impl Into<String>) -> Self {
796        let operation = operation.into();
797        Self::Internal {
798            kind: ErrorKind::Runtime,
799            message: Cow::Owned(format!("Operation '{operation}' not found")),
800            context: Some(ErrorContext::new(
801                Some(json!({ "operation": operation })),
802                Some(Cow::Borrowed(
803                    "Check available operations with --help or --describe-json",
804                )),
805            )),
806        }
807    }
808
809    /// Create an operation not found error with suggestions
810    pub fn operation_not_found_with_suggestions(
811        operation: impl Into<String>,
812        suggestions: &[String],
813    ) -> Self {
814        let operation = operation.into();
815        let suggestion_text = if suggestions.is_empty() {
816            "Check available operations with --help or --describe-json".to_string()
817        } else {
818            format!("Did you mean one of these?\n{}", suggestions.join("\n"))
819        };
820
821        Self::Internal {
822            kind: ErrorKind::Validation,
823            message: Cow::Owned(format!("Operation '{operation}' not found")),
824            context: Some(ErrorContext::new(
825                Some(json!({
826                    "operation": operation,
827                    "suggestions": suggestions
828                })),
829                Some(Cow::Owned(suggestion_text)),
830            )),
831        }
832    }
833
834    /// Create a network request failed error
835    pub fn network_request_failed(reason: impl Into<String>) -> Self {
836        let reason = reason.into();
837        Self::Internal {
838            kind: ErrorKind::Network,
839            message: Cow::Owned(format!("Network request failed: {reason}")),
840            context: Some(
841                ErrorContext::with_detail("reason", &reason)
842                    .and_suggestion("Check network connectivity and URL validity"),
843            ),
844        }
845    }
846
847    /// Create a serialization error
848    pub fn serialization_error(reason: impl Into<String>) -> Self {
849        let reason = reason.into();
850        Self::Internal {
851            kind: ErrorKind::Validation,
852            message: Cow::Owned(format!("Serialization failed: {reason}")),
853            context: Some(
854                ErrorContext::with_detail("reason", &reason)
855                    .and_suggestion("Check data structure validity"),
856            ),
857        }
858    }
859
860    /// Create a home directory not found error
861    #[must_use]
862    pub fn home_directory_not_found() -> Self {
863        Self::Internal {
864            kind: ErrorKind::Runtime,
865            message: Cow::Borrowed("Home directory not found"),
866            context: Some(ErrorContext::new(
867                Some(serde_json::json!({})),
868                Some(Cow::Borrowed("Ensure HOME environment variable is set")),
869            )),
870        }
871    }
872
873    /// Create an invalid command error
874    pub fn invalid_command(context: impl Into<String>, reason: impl Into<String>) -> Self {
875        let context = context.into();
876        let reason = reason.into();
877        Self::Internal {
878            kind: ErrorKind::Validation,
879            message: Cow::Owned(format!("Invalid command for '{context}': {reason}")),
880            context: Some(
881                ErrorContext::with_name_reason("context", &context, &reason)
882                    .and_suggestion("Check available commands with --help or --describe-json"),
883            ),
884        }
885    }
886
887    /// Create an HTTP error with context
888    pub fn http_error_with_context(
889        status: u16,
890        body: impl Into<String>,
891        api_name: impl Into<String>,
892        operation_id: Option<impl Into<String>>,
893        security_schemes: &[String],
894    ) -> Self {
895        let body = body.into();
896        let api_name = api_name.into();
897        let operation_id = operation_id.map(std::convert::Into::into);
898
899        // Include important parts of response body in message for backward compatibility
900        let message = if body.len() <= 200 && !body.is_empty() {
901            format!("HTTP {status} error for '{api_name}': {body}")
902        } else {
903            format!("HTTP {status} error for '{api_name}'")
904        };
905
906        Self::Internal {
907            kind: ErrorKind::HttpRequest,
908            message: Cow::Owned(message),
909            context: Some(ErrorContext::new(
910                Some(json!({
911                    "status": status,
912                    "response_body": body,
913                    "api_name": api_name,
914                    "operation_id": operation_id,
915                    "security_schemes": security_schemes
916                })),
917                Some(Cow::Borrowed(
918                    "Check the API endpoint, parameters, and authentication.",
919                )),
920            )),
921        }
922    }
923
924    /// Create a JQ filter error
925    pub fn jq_filter_error(filter: impl Into<String>, reason: impl Into<String>) -> Self {
926        let filter = filter.into();
927        let reason = reason.into();
928        Self::Internal {
929            kind: ErrorKind::Validation,
930            message: Cow::Owned(format!("JQ filter error in '{filter}': {reason}")),
931            context: Some(
932                ErrorContext::with_name_reason("filter", &filter, &reason)
933                    .and_suggestion("Check JQ filter syntax and data structure compatibility"),
934            ),
935        }
936    }
937
938    /// Create a transient network error
939    pub fn transient_network_error(reason: impl Into<String>, retryable: bool) -> Self {
940        let reason = reason.into();
941        Self::Internal {
942            kind: ErrorKind::Network,
943            message: Cow::Owned(format!("Transient network error: {reason}")),
944            context: Some(ErrorContext::new(
945                Some(serde_json::json!({
946                    "reason": reason,
947                    "retryable": retryable
948                })),
949                Some(Cow::Borrowed(if retryable {
950                    "This error may be temporary and could succeed on retry"
951                } else {
952                    "This error is not retryable"
953                })),
954            )),
955        }
956    }
957
958    /// Create a retry limit exceeded error
959    pub fn retry_limit_exceeded(max_attempts: u32, last_error: impl Into<String>) -> Self {
960        let last_error = last_error.into();
961        Self::Internal {
962            kind: ErrorKind::Network,
963            message: Cow::Owned(format!(
964                "Retry limit exceeded after {max_attempts} attempts: {last_error}"
965            )),
966            context: Some(ErrorContext::new(
967                Some(serde_json::json!({
968                    "max_attempts": max_attempts,
969                    "last_error": last_error
970                })),
971                Some(Cow::Borrowed(
972                    "Consider checking network connectivity or increasing retry limits",
973                )),
974            )),
975        }
976    }
977
978    /// Create a retry limit exceeded error with detailed retry information
979    #[allow(clippy::too_many_arguments)]
980    pub fn retry_limit_exceeded_detailed(
981        max_attempts: u32,
982        attempts_made: u32,
983        last_error: impl Into<String>,
984        initial_delay_ms: u64,
985        max_delay_ms: u64,
986        last_status_code: Option<u16>,
987        operation_id: impl Into<String>,
988    ) -> Self {
989        let last_error = last_error.into();
990        let operation_id = operation_id.into();
991        Self::Internal {
992            kind: ErrorKind::Network,
993            message: Cow::Owned(format!(
994                "Retry limit exceeded after {attempts_made}/{max_attempts} attempts for {operation_id}: {last_error}"
995            )),
996            context: Some(ErrorContext::new(
997                Some(serde_json::json!({
998                    "retry_info": {
999                        "max_attempts": max_attempts,
1000                        "attempts_made": attempts_made,
1001                        "initial_delay_ms": initial_delay_ms,
1002                        "max_delay_ms": max_delay_ms,
1003                        "last_status_code": last_status_code,
1004                        "operation_id": operation_id
1005                    },
1006                    "last_error": last_error
1007                })),
1008                Some(Cow::Borrowed(
1009                    "Consider checking network connectivity, API availability, or increasing retry limits",
1010                )),
1011            )),
1012        }
1013    }
1014
1015    /// Create a request timeout error
1016    #[must_use]
1017    pub fn request_timeout(timeout_seconds: u64) -> Self {
1018        Self::Internal {
1019            kind: ErrorKind::Network,
1020            message: Cow::Owned(format!("Request timed out after {timeout_seconds} seconds")),
1021            context: Some(ErrorContext::new(
1022                Some(serde_json::json!({
1023                    "timeout_seconds": timeout_seconds
1024                })),
1025                Some(Cow::Borrowed(
1026                    "Consider increasing the timeout or checking network connectivity",
1027                )),
1028            )),
1029        }
1030    }
1031
1032    /// Create a missing path parameter error
1033    pub fn missing_path_parameter(name: impl Into<String>) -> Self {
1034        let name = name.into();
1035        Self::Internal {
1036            kind: ErrorKind::Validation,
1037            message: Cow::Owned(format!("Missing required path parameter: {name}")),
1038            context: Some(
1039                ErrorContext::with_detail("parameter_name", &name)
1040                    .and_suggestion("Provide a value for this required path parameter"),
1041            ),
1042        }
1043    }
1044
1045    /// Create a general I/O error
1046    pub fn io_error(message: impl Into<String>) -> Self {
1047        let message = message.into();
1048        Self::Internal {
1049            kind: ErrorKind::Runtime,
1050            message: Cow::Owned(message),
1051            context: None,
1052        }
1053    }
1054
1055    /// Create an invalid idempotency key error
1056    #[must_use]
1057    pub const fn invalid_idempotency_key() -> Self {
1058        Self::Internal {
1059            kind: ErrorKind::Headers,
1060            message: Cow::Borrowed("Invalid idempotency key format"),
1061            context: Some(ErrorContext::new(
1062                None,
1063                Some(Cow::Borrowed(
1064                    "Ensure the idempotency key contains only valid header characters",
1065                )),
1066            )),
1067        }
1068    }
1069
1070    /// Create an editor not set error
1071    #[must_use]
1072    pub const fn editor_not_set() -> Self {
1073        Self::Internal {
1074            kind: ErrorKind::Interactive,
1075            message: Cow::Borrowed("EDITOR environment variable not set"),
1076            context: Some(ErrorContext::new(
1077                None,
1078                Some(Cow::Borrowed(
1079                    "Set your preferred editor: export EDITOR=vim",
1080                )),
1081            )),
1082        }
1083    }
1084
1085    /// Create an editor failed error
1086    pub fn editor_failed(name: impl Into<String>) -> Self {
1087        let name = name.into();
1088        Self::Internal {
1089            kind: ErrorKind::Interactive,
1090            message: Cow::Owned(format!("Editor '{name}' failed to complete")),
1091            context: Some(ErrorContext::new(
1092                Some(serde_json::json!({ "editor": name })),
1093                Some(Cow::Borrowed(
1094                    "Check if the editor is properly installed and configured",
1095                )),
1096            )),
1097        }
1098    }
1099
1100    // ---- API Context Name Errors ----
1101
1102    /// Create an invalid API context name error
1103    pub fn invalid_api_context_name(name: impl Into<String>, reason: impl Into<String>) -> Self {
1104        let name = name.into();
1105        let reason = reason.into();
1106        Self::Internal {
1107            kind: ErrorKind::Validation,
1108            message: Cow::Owned(format!("Invalid API context name '{name}': {reason}")),
1109            context: Some(ErrorContext::new(
1110                Some(json!({ "name": name, "reason": reason })),
1111                Some(Cow::Borrowed(
1112                    "API names must start with a letter or digit and contain only letters, digits, dots, hyphens, or underscores (max 64 chars).",
1113                )),
1114            )),
1115        }
1116    }
1117
1118    // ---- Settings Errors ----
1119
1120    /// Create an unknown setting key error
1121    pub fn unknown_setting_key(key: impl Into<String>) -> Self {
1122        let key = key.into();
1123        Self::Internal {
1124            kind: ErrorKind::Validation,
1125            message: Cow::Owned(format!("Unknown setting key: '{key}'")),
1126            context: Some(ErrorContext::new(
1127                Some(json!({ "key": key })),
1128                Some(Cow::Borrowed(
1129                    "Run 'aperture config settings' to see available settings.",
1130                )),
1131            )),
1132        }
1133    }
1134
1135    /// Create an invalid setting value error
1136    pub fn invalid_setting_value(
1137        key: crate::config::settings::SettingKey,
1138        value: impl Into<String>,
1139    ) -> Self {
1140        let value = value.into();
1141        let expected_type = key.type_name();
1142        Self::Internal {
1143            kind: ErrorKind::Validation,
1144            message: Cow::Owned(format!(
1145                "Invalid value for '{key}': expected {expected_type}, got '{value}'"
1146            )),
1147            context: Some(ErrorContext::new(
1148                Some(json!({
1149                    "key": key.as_str(),
1150                    "value": value,
1151                    "expected_type": expected_type
1152                })),
1153                Some(Cow::Owned(format!(
1154                    "Provide a valid {expected_type} value for this setting."
1155                ))),
1156            )),
1157        }
1158    }
1159
1160    /// Create a setting value out of range error
1161    pub fn setting_value_out_of_range(
1162        key: crate::config::settings::SettingKey,
1163        value: impl Into<String>,
1164        reason: &str,
1165    ) -> Self {
1166        let value = value.into();
1167        Self::Internal {
1168            kind: ErrorKind::Validation,
1169            message: Cow::Owned(format!(
1170                "Value '{value}' out of range for '{key}': {reason}"
1171            )),
1172            context: Some(ErrorContext::new(
1173                Some(json!({
1174                    "key": key.as_str(),
1175                    "value": value,
1176                    "reason": reason
1177                })),
1178                Some(Cow::Owned(format!(
1179                    "Provide a value within the valid range: {reason}"
1180                ))),
1181            )),
1182        }
1183    }
1184
1185    // ---- Batch Dependency Errors ----
1186
1187    /// Create a batch dependency cycle detected error
1188    #[must_use]
1189    pub fn batch_cycle_detected(cycle: &[String]) -> Self {
1190        let cycle_str = cycle.join(" → ");
1191        Self::Internal {
1192            kind: ErrorKind::Validation,
1193            message: Cow::Owned(format!(
1194                "Dependency cycle detected in batch operations: {cycle_str}"
1195            )),
1196            context: Some(ErrorContext::new(
1197                Some(json!({ "cycle": cycle })),
1198                Some(Cow::Borrowed(
1199                    "Remove circular dependencies between batch operations.",
1200                )),
1201            )),
1202        }
1203    }
1204
1205    /// Create a batch missing dependency reference error
1206    pub fn batch_missing_dependency(
1207        operation_id: impl Into<String>,
1208        missing_dep: impl Into<String>,
1209    ) -> Self {
1210        let operation_id = operation_id.into();
1211        let missing_dep = missing_dep.into();
1212        Self::Internal {
1213            kind: ErrorKind::Validation,
1214            message: Cow::Owned(format!(
1215                "Operation '{operation_id}' depends on '{missing_dep}' which does not exist"
1216            )),
1217            context: Some(ErrorContext::new(
1218                Some(json!({ "operation_id": operation_id, "missing_dependency": missing_dep })),
1219                Some(Cow::Borrowed(
1220                    "Check that the depends_on references match existing operation ids.",
1221                )),
1222            )),
1223        }
1224    }
1225
1226    /// Create a batch undefined variable error
1227    pub fn batch_undefined_variable(
1228        operation_id: impl Into<String>,
1229        variable: impl Into<String>,
1230    ) -> Self {
1231        let operation_id = operation_id.into();
1232        let variable = variable.into();
1233        Self::Internal {
1234            kind: ErrorKind::Validation,
1235            message: Cow::Owned(format!(
1236                "Operation '{operation_id}' references undefined variable '{{{{{variable}}}}}'"
1237            )),
1238            context: Some(ErrorContext::new(
1239                Some(json!({ "operation_id": operation_id, "variable": variable })),
1240                Some(Cow::Borrowed(
1241                    "Ensure the variable is captured by a preceding operation.",
1242                )),
1243            )),
1244        }
1245    }
1246
1247    /// Create a batch capture failed error
1248    pub fn batch_capture_failed(
1249        operation_id: impl Into<String>,
1250        variable: impl Into<String>,
1251        reason: impl Into<String>,
1252    ) -> Self {
1253        let operation_id = operation_id.into();
1254        let variable = variable.into();
1255        let reason = reason.into();
1256        Self::Internal {
1257            kind: ErrorKind::Validation,
1258            message: Cow::Owned(format!(
1259                "Failed to capture variable '{variable}' from operation '{operation_id}': {reason}"
1260            )),
1261            context: Some(ErrorContext::new(
1262                Some(
1263                    json!({ "operation_id": operation_id, "variable": variable, "reason": reason }),
1264                ),
1265                Some(Cow::Borrowed(
1266                    "Check the JQ query and ensure the response contains the expected data.",
1267                )),
1268            )),
1269        }
1270    }
1271
1272    /// Create a batch operation missing id error
1273    pub fn batch_missing_id(context: impl Into<String>) -> Self {
1274        let context = context.into();
1275        Self::Internal {
1276            kind: ErrorKind::Validation,
1277            message: Cow::Owned(format!(
1278                "Batch operation requires an id: {context}"
1279            )),
1280            context: Some(ErrorContext::new(
1281                Some(json!({ "context": context })),
1282                Some(Cow::Borrowed(
1283                    "Add an 'id' field to operations that use capture, capture_append, or depends_on.",
1284                )),
1285            )),
1286        }
1287    }
1288}
1289
1290#[cfg(test)]
1291mod tests {
1292    use super::*;
1293    use std::time::Duration;
1294    use wiremock::matchers::{method, path};
1295    use wiremock::{Mock, MockServer, ResponseTemplate};
1296
1297    // ---- Internal ErrorKind variants via to_json() ----
1298
1299    #[test]
1300    fn test_to_json_specification_kind() {
1301        let err = Error::spec_not_found("my-api");
1302        let j = err.to_json();
1303        assert_eq!(j.error_type, "Specification");
1304        assert!(j.message.contains("my-api"));
1305        assert!(j.context.is_some(), "spec_not_found carries a suggestion");
1306        assert!(j.details.is_some());
1307    }
1308
1309    #[test]
1310    fn test_to_json_specification_cache_stale() {
1311        let err = Error::cache_stale("stale-api");
1312        let j = err.to_json();
1313        assert_eq!(j.error_type, "Specification");
1314        assert!(j.message.contains("stale-api"));
1315        assert!(j.context.is_some());
1316    }
1317
1318    #[test]
1319    fn test_to_json_authentication_secret_not_set() {
1320        let err = Error::secret_not_set("api-key", "MY_API_KEY");
1321        let j = err.to_json();
1322        assert_eq!(j.error_type, "Authentication");
1323        assert!(j.message.contains("MY_API_KEY"));
1324        assert!(j.context.is_some(), "secret_not_set carries a suggestion");
1325        assert!(j.details.is_some());
1326    }
1327
1328    #[test]
1329    fn test_to_json_authentication_unsupported_scheme() {
1330        let err = Error::unsupported_auth_scheme("digest");
1331        let j = err.to_json();
1332        assert_eq!(j.error_type, "Authentication");
1333        assert!(j.message.contains("digest"));
1334        assert!(j.context.is_some());
1335    }
1336
1337    #[test]
1338    fn test_to_json_validation_kind() {
1339        let err = Error::invalid_config("bad value");
1340        let j = err.to_json();
1341        assert_eq!(j.error_type, "Validation");
1342        assert!(j.message.contains("bad value"));
1343        assert!(j.context.is_some());
1344    }
1345
1346    #[test]
1347    fn test_to_json_network_internal_kind() {
1348        // ErrorKind::Network (internal) is produced by retry_limit_exceeded.
1349        let err = Error::retry_limit_exceeded(3, "connection refused");
1350        let j = err.to_json();
1351        assert_eq!(j.error_type, "Network");
1352        assert!(
1353            j.context.is_some(),
1354            "retry_limit_exceeded carries a suggestion"
1355        );
1356    }
1357
1358    #[test]
1359    fn test_to_json_http_request_kind() {
1360        let err = Error::request_failed(reqwest::StatusCode::UNPROCESSABLE_ENTITY, "bad body");
1361        let j = err.to_json();
1362        assert_eq!(j.error_type, "HttpError");
1363        assert!(j.message.contains("422") || j.message.contains("Unprocessable"));
1364        assert!(j.message.contains("bad body"));
1365        assert!(j.context.is_some());
1366        assert!(j.details.is_some());
1367    }
1368
1369    #[test]
1370    fn test_to_json_headers_invalid_header_name() {
1371        let err = Error::invalid_header_name("X-Bad\0Header", "contains NUL");
1372        let j = err.to_json();
1373        assert_eq!(j.error_type, "Headers");
1374        assert!(j.context.is_some(), "header errors carry suggestions");
1375        assert!(j.details.is_some());
1376    }
1377
1378    #[test]
1379    fn test_to_json_headers_empty_header_name() {
1380        let err = Error::empty_header_name();
1381        let j = err.to_json();
1382        assert_eq!(j.error_type, "Headers");
1383        assert!(j.context.is_some());
1384    }
1385
1386    #[test]
1387    fn test_to_json_headers_invalid_idempotency_key() {
1388        let err = Error::invalid_idempotency_key();
1389        let j = err.to_json();
1390        assert_eq!(j.error_type, "Headers");
1391        assert!(j.context.is_some());
1392    }
1393
1394    #[test]
1395    fn test_to_json_interactive_timeout() {
1396        let err = Error::interactive_timeout();
1397        let j = err.to_json();
1398        assert_eq!(j.error_type, "Interactive");
1399        assert!(j.context.is_some(), "interactive errors carry suggestions");
1400    }
1401
1402    #[test]
1403    fn test_to_json_interactive_input_too_long() {
1404        let err = Error::interactive_input_too_long(256);
1405        let j = err.to_json();
1406        assert_eq!(j.error_type, "Interactive");
1407        assert!(j.context.is_some());
1408        assert!(j.details.is_some());
1409    }
1410
1411    #[test]
1412    fn test_to_json_interactive_editor_not_set() {
1413        let err = Error::editor_not_set();
1414        let j = err.to_json();
1415        assert_eq!(j.error_type, "Interactive");
1416        assert!(j.context.is_some());
1417    }
1418
1419    #[test]
1420    fn test_to_json_server_variable_missing() {
1421        let err = Error::missing_server_variable("region");
1422        let j = err.to_json();
1423        assert_eq!(j.error_type, "ServerVariable");
1424        assert!(j.message.contains("region"));
1425        assert!(
1426            j.context.is_some(),
1427            "server variable errors carry suggestions"
1428        );
1429        assert!(j.details.is_some());
1430    }
1431
1432    #[test]
1433    fn test_to_json_server_variable_unresolved_template() {
1434        let err = Error::unresolved_template_variable("env", "https://api.{env}.example.com");
1435        let j = err.to_json();
1436        assert_eq!(j.error_type, "ServerVariable");
1437        assert!(j.context.is_some());
1438    }
1439
1440    #[test]
1441    fn test_to_json_runtime_kind() {
1442        let err = Error::operation_not_found("unknown-op");
1443        let j = err.to_json();
1444        assert_eq!(j.error_type, "Runtime");
1445        assert!(j.message.contains("unknown-op"));
1446        assert!(j.context.is_some());
1447    }
1448
1449    // ---- External error variants via to_json() ----
1450
1451    #[test]
1452    fn test_to_json_io_not_found() {
1453        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "no such file");
1454        let err = Error::Io(io_err);
1455        let j = err.to_json();
1456        assert_eq!(j.error_type, "FileSystem");
1457        assert!(j.context.is_some(), "NotFound carries a suggestion");
1458    }
1459
1460    #[test]
1461    fn test_to_json_io_permission_denied() {
1462        let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "access denied");
1463        let err = Error::Io(io_err);
1464        let j = err.to_json();
1465        assert_eq!(j.error_type, "FileSystem");
1466        assert!(j.context.is_some(), "PermissionDenied carries a suggestion");
1467    }
1468
1469    #[test]
1470    fn test_to_json_io_other_kind() {
1471        let io_err = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "broken pipe");
1472        let err = Error::Io(io_err);
1473        let j = err.to_json();
1474        assert_eq!(j.error_type, "FileSystem");
1475        assert!(j.context.is_none(), "generic IO kind carries no suggestion");
1476    }
1477
1478    #[test]
1479    fn test_to_json_yaml_error() {
1480        let yaml_err = serde_yaml::from_str::<serde_yaml::Value>("key: - value").unwrap_err();
1481        let err = Error::Yaml(yaml_err);
1482        let j = err.to_json();
1483        assert_eq!(j.error_type, "YAMLParsing");
1484        assert!(j.context.is_some());
1485    }
1486
1487    #[test]
1488    fn test_to_json_json_error() {
1489        let json_err = serde_json::from_str::<serde_json::Value>("{bad").unwrap_err();
1490        let err = Error::Json(json_err);
1491        let j = err.to_json();
1492        assert_eq!(j.error_type, "JSONParsing");
1493        assert!(j.context.is_some());
1494    }
1495
1496    #[test]
1497    fn test_to_json_toml_error() {
1498        let toml_err = toml::from_str::<toml::Value>("key = ").unwrap_err();
1499        let err = Error::Toml(toml_err);
1500        let j = err.to_json();
1501        assert_eq!(j.error_type, "TOMLParsing");
1502        assert!(j.context.is_some());
1503    }
1504
1505    #[test]
1506    fn test_to_json_anyhow_error() {
1507        let err = Error::Anyhow(anyhow::anyhow!("unexpected failure"));
1508        let j = err.to_json();
1509        assert_eq!(j.error_type, "Unknown");
1510        assert!(j.message.contains("unexpected failure"));
1511        assert!(j.context.is_none(), "anyhow errors carry no suggestion");
1512    }
1513
1514    // ---- Error::Network (reqwest::Error) via to_json() ----
1515    //
1516    // These tests require a live socket to produce real reqwest::Error values.
1517
1518    async fn status_req_error(status: u16) -> reqwest::Error {
1519        let server = MockServer::start().await;
1520        Mock::given(method("GET"))
1521            .and(path("/err"))
1522            .respond_with(ResponseTemplate::new(status))
1523            .mount(&server)
1524            .await;
1525        reqwest::Client::new()
1526            .get(format!("{}/err", server.uri()))
1527            .send()
1528            .await
1529            .expect("mock server must respond")
1530            .error_for_status()
1531            .expect_err("status >= 400 must produce an error")
1532    }
1533
1534    #[tokio::test]
1535    async fn test_to_json_network_connect_error() {
1536        let req_err = reqwest::Client::new()
1537            .get("http://127.0.0.1:1/")
1538            .send()
1539            .await
1540            .expect_err("port 1 must refuse connections");
1541        assert!(req_err.is_connect());
1542        let j = Error::Network(req_err).to_json();
1543        assert_eq!(j.error_type, "Network");
1544        assert!(
1545            j.context.is_some(),
1546            "connect error carries ERR_CONNECTION hint"
1547        );
1548    }
1549
1550    #[tokio::test]
1551    async fn test_to_json_network_timeout_error() {
1552        let server = MockServer::start().await;
1553        Mock::given(method("GET"))
1554            .and(path("/slow"))
1555            .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(10)))
1556            .mount(&server)
1557            .await;
1558        let req_err = reqwest::Client::builder()
1559            .timeout(Duration::from_millis(1))
1560            .build()
1561            .unwrap()
1562            .get(format!("{}/slow", server.uri()))
1563            .send()
1564            .await
1565            .expect_err("must time out");
1566        assert!(req_err.is_timeout());
1567        let j = Error::Network(req_err).to_json();
1568        assert_eq!(j.error_type, "Network");
1569        assert!(
1570            j.context.is_some(),
1571            "timeout error carries ERR_TIMEOUT hint"
1572        );
1573    }
1574
1575    #[tokio::test]
1576    async fn test_to_json_network_401() {
1577        let req_err = status_req_error(401).await;
1578        let j = Error::Network(req_err).to_json();
1579        assert_eq!(j.error_type, "Network");
1580        assert!(j.context.is_some());
1581    }
1582
1583    #[tokio::test]
1584    async fn test_to_json_network_403() {
1585        let req_err = status_req_error(403).await;
1586        let j = Error::Network(req_err).to_json();
1587        assert_eq!(j.error_type, "Network");
1588        assert!(j.context.is_some());
1589    }
1590
1591    #[tokio::test]
1592    async fn test_to_json_network_404() {
1593        let req_err = status_req_error(404).await;
1594        let j = Error::Network(req_err).to_json();
1595        assert_eq!(j.error_type, "Network");
1596        assert!(j.context.is_some());
1597    }
1598
1599    #[tokio::test]
1600    async fn test_to_json_network_429() {
1601        let req_err = status_req_error(429).await;
1602        let j = Error::Network(req_err).to_json();
1603        assert_eq!(j.error_type, "Network");
1604        assert!(j.context.is_some());
1605    }
1606
1607    #[tokio::test]
1608    async fn test_to_json_network_500() {
1609        let req_err = status_req_error(500).await;
1610        let j = Error::Network(req_err).to_json();
1611        assert_eq!(j.error_type, "Network");
1612        assert!(j.context.is_some());
1613    }
1614
1615    /// Exercises the `_ => None` fallback within the `is_status()` arm — context must be None.
1616    /// Redirect codes (3xx) cannot be used because reqwest follows them automatically.
1617    /// 418 (I'm a Teapot) is not followed by reqwest and is not matched by any explicit arm.
1618    #[tokio::test]
1619    async fn test_to_json_network_status_fallback_no_context() {
1620        let req_err = status_req_error(418).await;
1621        let j = Error::Network(req_err).to_json();
1622        assert_eq!(j.error_type, "Network");
1623        assert!(
1624            j.context.is_none(),
1625            "unrecognised status code must produce no suggestion"
1626        );
1627    }
1628}