Skip to main content

alien_error/
lib.rs

1//! Alien-Error – minimal clean version with context-based API.
2//! Provides:
3//!   • `AlienErrorMetadata` trait (implemented by enums via #[derive(AlienError)])
4//!   • `AlienError<T>` container (generic over error type)
5//!   • `.context()` extension method for AlienError Results
6//!   • `.into_alien_error()` for converting std errors
7//!   • `Result<T>` alias
8//!   • OpenAPI schema generation (with `openapi` feature)
9//!   • Axum IntoResponse implementation (with `axum` feature)
10//!
11//! Use `.context(YourError::Variant { ... })` on AlienError Results to wrap errors.
12//! Use `.into_alien_error()` on std::error::Error Results to convert them first.
13//!
14//! ## OpenAPI Schema Generation
15//!
16//! When the `openapi` feature is enabled, the `AlienError` struct implements
17//! `utoipa::ToSchema`, allowing it to be used in OpenAPI documentation:
18//!
19//! ```rust,ignore
20//! use utoipa::OpenApi;
21//! use alien_error::AlienError;
22//!
23//! #[derive(OpenApi)]
24//! #[openapi(components(schemas(AlienError)))]
25//! struct ApiDoc;
26//! ```
27//!
28//! ## Axum Integration
29//!
30//! When the `axum` feature is enabled, `AlienError` implements `axum::response::IntoResponse`,
31//! allowing it to be returned directly from Axum handlers. By default, the `IntoResponse`
32//! implementation uses external response behavior (sanitizes internal errors).
33//!
34//! For different use cases, you can choose between:
35//!
36//! ### External API Responses (Default)
37//! ```rust,ignore
38//! use axum::response::IntoResponse;
39//! use alien_error::{AlienError, AlienErrorData};
40//!
41//! // Default behavior - sanitizes internal errors for security
42//! async fn api_handler() -> Result<String, AlienError<MyError>> {
43//!     Err(AlienError::new(MyError::InternalDatabaseError {
44//!         credentials: "secret".to_string()
45//!     }))
46//! }
47//! // Returns: HTTP 500 with {"code": "GENERIC_ERROR", "message": "Internal server error"}
48//! ```
49//!
50//! ### Explicit External Responses
51//! ```rust,ignore
52//! async fn api_handler() -> impl IntoResponse {
53//!     let error = AlienError::new(MyError::InternalDatabaseError {
54//!         credentials: "secret".to_string()
55//!     });
56//!     error.into_external_response() // Explicitly sanitize
57//! }
58//! ```
59//!
60//! ### Internal Service Communication
61//! ```rust,ignore
62//! async fn internal_handler() -> impl IntoResponse {
63//!     let error = AlienError::new(MyError::InternalDatabaseError {
64//!         credentials: "secret".to_string()
65//!     });
66//!     error.into_internal_response() // Preserve all details
67//! }
68//! // Returns: HTTP 500 with full error details including sensitive information
69//! ```
70
71use std::{error::Error as StdError, fmt};
72
73use serde::{Deserialize, Serialize};
74
75#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
76pub enum HumanLayerPresentation {
77    #[default]
78    Normal,
79    Transparent,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct HumanErrorCause {
84    pub code: String,
85    pub message: String,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct HumanErrorReport {
90    pub code: String,
91    pub message: String,
92    pub hint: Option<String>,
93    pub causes: Vec<HumanErrorCause>,
94}
95
96/// Data every public-facing error variant must expose.
97pub trait AlienErrorData {
98    /// Short machine-readable identifier ("NOT_FOUND", "TIMEOUT", …).
99    fn code(&self) -> &'static str;
100    /// Whether the failing operation can be retried.
101    fn retryable(&self) -> bool;
102    /// Whether the error is internal (should not be shown to end users).
103    fn internal(&self) -> bool;
104    /// Human-readable message (defaults to `Display`).
105    fn message(&self) -> String;
106    /// HTTP status code for this error (defaults to 500).
107    fn http_status_code(&self) -> u16 {
108        500
109    }
110    /// Optional diagnostic payload built from struct/enum fields.
111    fn context(&self) -> Option<serde_json::Value> {
112        None
113    }
114
115    /// Whether to inherit the retryable flag from the source error.
116    /// Returns None if this error should inherit from source, Some(value) for explicit value.
117    fn retryable_inherit(&self) -> Option<bool> {
118        Some(self.retryable())
119    }
120
121    /// Whether to inherit the internal flag from the source error.
122    /// Returns None if this error should inherit from source, Some(value) for explicit value.
123    fn internal_inherit(&self) -> Option<bool> {
124        Some(self.internal())
125    }
126
127    /// Whether to inherit the HTTP status code from the source error.
128    /// Returns None if this error should inherit from source, Some(value) for explicit value.
129    fn http_status_code_inherit(&self) -> Option<u16> {
130        Some(self.http_status_code())
131    }
132
133    /// Controls whether this error layer should be shown in the default human CLI renderer.
134    fn human_layer_presentation(&self) -> HumanLayerPresentation {
135        HumanLayerPresentation::Normal
136    }
137
138    /// Optional actionable hint for human-facing CLI output.
139    fn hint(&self) -> Option<String> {
140        None
141    }
142}
143
144/// A special marker type for generic/standard errors that don't have specific metadata
145#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Default)]
146#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
147pub struct GenericError {
148    pub message: String,
149}
150
151impl std::fmt::Display for GenericError {
152    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153        write!(f, "{}", self.message)
154    }
155}
156
157impl StdError for GenericError {}
158
159impl AlienErrorData for GenericError {
160    fn code(&self) -> &'static str {
161        "GENERIC_ERROR"
162    }
163
164    fn retryable(&self) -> bool {
165        false
166    }
167
168    fn internal(&self) -> bool {
169        false
170    }
171
172    fn message(&self) -> String {
173        self.message.clone()
174    }
175
176    fn http_status_code(&self) -> u16 {
177        500
178    }
179}
180
181/// Canonical error container that provides a structured way to represent errors
182/// with rich metadata including error codes, human-readable messages, context,
183/// and chaining capabilities for error propagation.
184///
185/// This struct is designed to be both machine-readable and user-friendly,
186/// supporting serialization for API responses and detailed error reporting
187/// in distributed systems.
188#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)]
189#[serde(rename_all = "camelCase")]
190#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
191pub struct AlienError<T = GenericError>
192where
193    T: AlienErrorData + Clone + std::fmt::Debug + Serialize,
194{
195    /// A unique identifier for the type of error.
196    ///
197    /// This should be a short, machine-readable string that can be used
198    /// by clients to programmatically handle different error types.
199    /// Examples: "NOT_FOUND", "VALIDATION_ERROR", "TIMEOUT"
200    #[cfg_attr(feature = "openapi", schema(example = "NOT_FOUND", max_length = 128))]
201    pub code: String,
202
203    /// Human-readable error message.
204    ///
205    /// This message should be clear and actionable for developers or end-users,
206    /// providing context about what went wrong and potentially how to fix it.
207    #[cfg_attr(
208        feature = "openapi",
209        schema(example = "Item not found.", max_length = 16384)
210    )]
211    pub message: String,
212
213    /// Additional diagnostic information about the error context.
214    ///
215    /// This optional field can contain structured data providing more details
216    /// about the error, such as validation errors, request parameters that
217    /// caused the issue, or other relevant context information.
218    #[serde(skip_serializing_if = "Option::is_none")]
219    #[cfg_attr(feature = "openapi", schema(nullable = true))]
220    pub context: Option<serde_json::Value>,
221
222    /// Optional human-facing remediation hint.
223    #[serde(skip_serializing_if = "Option::is_none")]
224    #[cfg_attr(feature = "openapi", schema(nullable = true))]
225    pub hint: Option<String>,
226
227    /// Indicates whether the operation that caused the error should be retried.
228    ///
229    /// When `true`, the error is transient and the operation might succeed
230    /// if attempted again. When `false`, retrying the same operation is
231    /// unlikely to succeed without changes.
232    #[cfg_attr(feature = "openapi", schema(default = false))]
233    pub retryable: bool,
234
235    /// Indicates if this is an internal error that should not be exposed to users.
236    ///
237    /// When `true`, this error contains sensitive information or implementation
238    /// details that should not be shown to end-users. Such errors should be
239    /// logged for debugging but replaced with generic error messages in responses.
240    pub internal: bool,
241
242    /// HTTP status code for this error.
243    ///
244    /// Used when converting the error to an HTTP response. If None, falls back to
245    /// the error type's default status code or 500.
246    #[serde(skip_serializing_if = "Option::is_none")]
247    #[cfg_attr(feature = "openapi", schema(minimum = 100, maximum = 599))]
248    pub http_status_code: Option<u16>,
249
250    /// The underlying error that caused this error, creating an error chain.
251    ///
252    /// This allows for proper error propagation and debugging by maintaining
253    /// the full context of how an error occurred through multiple layers
254    /// of an application.
255    #[serde(skip_serializing_if = "Option::is_none")]
256    #[cfg_attr(feature = "openapi", schema(value_type = Option<serde_json::Value>))]
257    pub source: Option<Box<AlienError<GenericError>>>,
258
259    #[serde(skip, default)]
260    #[cfg_attr(feature = "openapi", schema(ignore))]
261    pub human_layer_presentation: HumanLayerPresentation,
262
263    /// The original error for pattern matching
264    #[serde(
265        rename = "_error_for_pattern_matching",
266        skip_serializing_if = "Option::is_none"
267    )]
268    #[cfg_attr(feature = "openapi", schema(ignore))]
269    pub error: Option<T>,
270}
271
272impl<T> AlienError<T>
273where
274    T: AlienErrorData + Clone + std::fmt::Debug + Serialize,
275{
276    /// Create an AlienError from an AlienErrorData implementor
277    pub fn new(meta: T) -> Self {
278        AlienError {
279            code: meta.code().to_string(),
280            message: meta.message(),
281            context: meta.context(),
282            hint: meta.hint(),
283            retryable: meta.retryable(),
284            internal: meta.internal(),
285            http_status_code: Some(meta.http_status_code()),
286            source: None,
287            human_layer_presentation: meta.human_layer_presentation(),
288            error: Some(meta),
289        }
290    }
291}
292
293impl AlienError<GenericError> {
294    /// Create an AlienError from a standard error
295    pub fn from_std(err: &(dyn StdError + 'static)) -> Self {
296        let generic = GenericError {
297            message: err.to_string(),
298        };
299
300        // Recursively build the source chain
301        let source = err.source().map(|src| Box::new(Self::from_std(src)));
302
303        AlienError {
304            code: generic.code().to_string(),
305            message: generic.message(),
306            context: generic.context(),
307            hint: generic.hint(),
308            retryable: generic.retryable(),
309            internal: generic.internal(),
310            http_status_code: Some(generic.http_status_code()),
311            source,
312            human_layer_presentation: HumanLayerPresentation::Normal,
313            error: Some(generic),
314        }
315    }
316}
317
318impl<T> fmt::Display for AlienError<T>
319where
320    T: AlienErrorData + Clone + std::fmt::Debug + Serialize,
321{
322    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
323        write!(f, "{}: {}", self.code, self.message)?;
324        fn recurse(
325            e: &AlienError<GenericError>,
326            indent: &str,
327            f: &mut fmt::Formatter<'_>,
328        ) -> fmt::Result {
329            writeln!(f, "{}├─▶ {}: {}", indent, e.code, e.message)?;
330            if let Some(ref src) = e.source {
331                recurse(src, &format!("{}│   ", indent), f)?;
332            }
333            Ok(())
334        }
335        if let Some(ref src) = self.source {
336            writeln!(f)?;
337            recurse(src, "", f)?;
338        }
339        Ok(())
340    }
341}
342
343impl<T> StdError for AlienError<T>
344where
345    T: AlienErrorData + Clone + std::fmt::Debug + Serialize,
346{
347    fn source(&self) -> Option<&(dyn StdError + 'static)> {
348        self.source
349            .as_ref()
350            .map(|e| e.as_ref() as &(dyn StdError + 'static))
351    }
352}
353
354/// Extension trait for adding context to AlienError Results
355pub trait Context<T, E> {
356    /// Add context to an AlienError result, wrapping it with a new error
357    fn context<M: AlienErrorData + Clone + std::fmt::Debug + Serialize>(
358        self,
359        meta: M,
360    ) -> std::result::Result<T, AlienError<M>>;
361}
362
363// Implementation for AlienError results
364impl<T, E> Context<T, E> for std::result::Result<T, AlienError<E>>
365where
366    E: AlienErrorData + Clone + std::fmt::Debug + Serialize + 'static + Send + Sync,
367{
368    fn context<M: AlienErrorData + Clone + std::fmt::Debug + Serialize>(
369        self,
370        meta: M,
371    ) -> std::result::Result<T, AlienError<M>> {
372        self.map_err(|err| {
373            let mut new_err = AlienError::new(meta.clone());
374
375            // Check for inheritance and apply source error properties
376            // SAFETY: err.retryable, err.internal, and err.http_status_code are always valid
377            // as they are primitive types (bool, Option<u16>) that cannot be in an invalid state
378            if meta.retryable_inherit().is_none() {
379                new_err.retryable = err.retryable;
380            }
381            if meta.internal_inherit().is_none() {
382                new_err.internal = err.internal;
383            }
384            if meta.http_status_code_inherit().is_none() {
385                new_err.http_status_code = err.http_status_code;
386            }
387
388            // Convert the original typed error to a generic error to maintain the chain
389            let generic_err = AlienError {
390                code: err.code.clone(),
391                message: err.message.clone(),
392                context: err.context.clone(),
393                hint: err.hint.clone(),
394                retryable: err.retryable,
395                internal: err.internal,
396                source: err.source,
397                human_layer_presentation: err.human_layer_presentation,
398                error: None,
399                http_status_code: err.http_status_code,
400            };
401            new_err.source = Some(Box::new(generic_err));
402            new_err
403        })
404    }
405}
406
407/// Extension trait for adding context directly to AlienError instances
408pub trait ContextError<E> {
409    /// Add context to an AlienError, wrapping it with a new error
410    fn context<M: AlienErrorData + Clone + std::fmt::Debug + Serialize>(
411        self,
412        meta: M,
413    ) -> AlienError<M>;
414}
415
416// Implementation for AlienError instances
417impl<E> ContextError<E> for AlienError<E>
418where
419    E: AlienErrorData + Clone + std::fmt::Debug + Serialize + 'static + Send + Sync,
420{
421    fn context<M: AlienErrorData + Clone + std::fmt::Debug + Serialize>(
422        self,
423        meta: M,
424    ) -> AlienError<M> {
425        let mut new_err = AlienError::new(meta.clone());
426
427        // Check for inheritance and apply source error properties
428        // SAFETY: self.retryable, self.internal, and self.http_status_code are always valid
429        // as they are primitive types (bool, Option<u16>) that cannot be in an invalid state
430        if meta.retryable_inherit().is_none() {
431            new_err.retryable = self.retryable;
432        }
433        if meta.internal_inherit().is_none() {
434            new_err.internal = self.internal;
435        }
436        if meta.http_status_code_inherit().is_none() {
437            new_err.http_status_code = self.http_status_code;
438        }
439
440        // Convert the original typed error to a generic error to maintain the chain
441        let generic_err = AlienError {
442            code: self.code.clone(),
443            message: self.message.clone(),
444            context: self.context.clone(),
445            hint: self.hint.clone(),
446            retryable: self.retryable,
447            internal: self.internal,
448            source: self.source,
449            human_layer_presentation: self.human_layer_presentation,
450            error: None,
451            http_status_code: self.http_status_code,
452        };
453        new_err.source = Some(Box::new(generic_err));
454        new_err
455    }
456}
457
458/// Extension trait for converting standard errors to AlienError
459pub trait IntoAlienError<T> {
460    /// Convert a standard error result into an AlienError result
461    fn into_alien_error(self) -> std::result::Result<T, AlienError<GenericError>>;
462}
463
464impl<T, E> IntoAlienError<T> for std::result::Result<T, E>
465where
466    E: StdError + 'static,
467{
468    fn into_alien_error(self) -> std::result::Result<T, AlienError<GenericError>> {
469        self.map_err(|err| AlienError::from_std(&err as &dyn StdError))
470    }
471}
472
473/// Extension trait for converting standard errors directly to AlienError
474pub trait IntoAlienErrorDirect {
475    /// Convert a standard error into an AlienError
476    fn into_alien_error(self) -> AlienError<GenericError>;
477}
478
479impl<E> IntoAlienErrorDirect for E
480where
481    E: StdError + 'static,
482{
483    fn into_alien_error(self) -> AlienError<GenericError> {
484        AlienError::from_std(&self as &dyn StdError)
485    }
486}
487
488/// Alias for the common `Result` type used throughout an application.
489/// This is now generic over the error type for better type safety.
490pub type Result<T, E = GenericError> = std::result::Result<T, AlienError<E>>;
491
492impl<T> AlienError<T>
493where
494    T: AlienErrorData + Clone + std::fmt::Debug + Serialize,
495{
496    /// Convert this AlienError<T> to AlienError<GenericError> without losing data
497    pub fn into_generic(self) -> AlienError<GenericError> {
498        AlienError {
499            code: self.code,
500            message: self.message,
501            context: self.context,
502            hint: self.hint,
503            retryable: self.retryable,
504            internal: self.internal,
505            source: self.source,
506            human_layer_presentation: self.human_layer_presentation,
507            error: None,
508            http_status_code: self.http_status_code,
509        }
510    }
511
512    pub fn human_report(&self) -> HumanErrorReport {
513        let mut layers = Vec::new();
514        collect_human_layers(
515            &self.code,
516            &self.message,
517            self.hint.as_deref(),
518            self.human_layer_presentation,
519            self.source.as_deref(),
520            &mut layers,
521        );
522
523        let headline_index = layers
524            .iter()
525            .position(|layer| layer.presentation == HumanLayerPresentation::Normal)
526            .unwrap_or(0);
527        let headline = &layers[headline_index];
528
529        let mut causes = Vec::new();
530        for (index, layer) in layers.iter().enumerate() {
531            if index == headline_index || layer.presentation == HumanLayerPresentation::Transparent
532            {
533                continue;
534            }
535
536            if causes.iter().any(|cause: &HumanErrorCause| {
537                cause.code == layer.code && cause.message == layer.message
538            }) {
539                continue;
540            }
541
542            causes.push(HumanErrorCause {
543                code: layer.code.clone(),
544                message: layer.message.clone(),
545            });
546        }
547
548        HumanErrorReport {
549            code: headline.code.clone(),
550            message: headline.message.clone(),
551            hint: headline.hint.clone().or_else(|| {
552                layers
553                    .iter()
554                    .enumerate()
555                    .find(|(index, layer)| {
556                        *index != headline_index
557                            && layer.presentation == HumanLayerPresentation::Normal
558                            && layer.hint.is_some()
559                    })
560                    .and_then(|(_, layer)| layer.hint.clone())
561            }),
562            causes,
563        }
564    }
565}
566
567#[derive(Debug, Clone)]
568struct HumanLayer {
569    code: String,
570    message: String,
571    hint: Option<String>,
572    presentation: HumanLayerPresentation,
573}
574
575fn collect_human_layers(
576    code: &str,
577    message: &str,
578    hint: Option<&str>,
579    presentation: HumanLayerPresentation,
580    source: Option<&AlienError<GenericError>>,
581    layers: &mut Vec<HumanLayer>,
582) {
583    layers.push(HumanLayer {
584        code: code.to_string(),
585        message: message.to_string(),
586        hint: hint.map(ToOwned::to_owned),
587        presentation,
588    });
589
590    if let Some(source) = source {
591        collect_human_layers(
592            &source.code,
593            &source.message,
594            source.hint.as_deref(),
595            source.human_layer_presentation,
596            source.source.as_deref(),
597            layers,
598        );
599    }
600}
601
602// Re-export the derive macro so users only depend on this crate.
603pub use alien_error_derive::AlienErrorData;
604
605// Conversions for anyhow interoperability
606#[cfg(feature = "anyhow")]
607impl From<anyhow::Error> for AlienError<GenericError> {
608    fn from(err: anyhow::Error) -> AlienError<GenericError> {
609        AlienError::new(GenericError {
610            message: err.to_string(),
611        })
612    }
613}
614
615#[cfg(feature = "anyhow")]
616pub trait IntoAnyhow<T> {
617    /// Convert an AlienError result into an anyhow result
618    fn into_anyhow(self) -> anyhow::Result<T>;
619}
620
621#[cfg(feature = "anyhow")]
622impl<T, E> IntoAnyhow<T> for std::result::Result<T, AlienError<E>>
623where
624    E: AlienErrorData + Clone + std::fmt::Debug + Serialize + Send + Sync + 'static,
625{
626    fn into_anyhow(self) -> anyhow::Result<T> {
627        self.map_err(|err| anyhow::Error::new(err))
628    }
629}
630
631// Axum IntoResponse implementation
632#[cfg(feature = "axum")]
633impl<T> axum::response::IntoResponse for AlienError<T>
634where
635    T: AlienErrorData + Clone + std::fmt::Debug + Serialize + Send + Sync + 'static,
636{
637    fn into_response(self) -> axum::response::Response {
638        // Default behavior: external response (sanitizes internal errors)
639        self.into_external_response()
640    }
641}
642
643#[cfg(feature = "axum")]
644impl<T> AlienError<T>
645where
646    T: AlienErrorData + Clone + std::fmt::Debug + Serialize + Send + Sync + 'static,
647{
648    /// Convert to an Axum response suitable for internal microservice communication.
649    /// Preserves all error details including sensitive information from internal errors.
650    pub fn into_internal_response(self) -> axum::response::Response {
651        use axum::http::StatusCode;
652        use axum::response::{IntoResponse, Json};
653
654        // For internal responses, preserve all error details regardless of internal flag
655        let response_error = self.into_generic();
656
657        // Convert HTTP status code to StatusCode
658        let status_code = response_error
659            .http_status_code
660            .and_then(|code| StatusCode::from_u16(code).ok())
661            .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
662
663        // Return JSON response with the error
664        (status_code, Json(response_error)).into_response()
665    }
666
667    /// Convert to an Axum response suitable for external API responses.
668    /// Sanitizes internal errors to prevent information leakage.
669    pub fn into_external_response(self) -> axum::response::Response {
670        use axum::http::StatusCode;
671        use axum::response::{IntoResponse, Json};
672
673        // For external responses, sanitize internal errors
674        let response_error = if self.internal {
675            // For internal errors, return a generic error message with 500 status code
676            AlienError::new(GenericError {
677                message: "Internal server error".to_string(),
678            })
679        } else {
680            self.into_generic()
681        };
682
683        // Convert HTTP status code to StatusCode
684        let status_code = response_error
685            .http_status_code
686            .and_then(|code| StatusCode::from_u16(code).ok())
687            .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
688
689        // Return JSON response with the error
690        (status_code, Json(response_error)).into_response()
691    }
692}
693
694#[cfg(test)]
695mod tests {
696    use super::*;
697
698    #[derive(Debug, Clone, Serialize)]
699    enum TestError {
700        Normal,
701        Transparent,
702        Hint,
703    }
704
705    impl AlienErrorData for TestError {
706        fn code(&self) -> &'static str {
707            match self {
708                Self::Normal => "NORMAL",
709                Self::Transparent => "WRAPPER",
710                Self::Hint => "HINT",
711            }
712        }
713
714        fn retryable(&self) -> bool {
715            false
716        }
717
718        fn internal(&self) -> bool {
719            false
720        }
721
722        fn message(&self) -> String {
723            match self {
724                Self::Normal => "Inner failure".to_string(),
725                Self::Transparent => "Wrapper failure".to_string(),
726                Self::Hint => "Action required".to_string(),
727            }
728        }
729
730        fn human_layer_presentation(&self) -> HumanLayerPresentation {
731            match self {
732                Self::Normal => HumanLayerPresentation::Normal,
733                Self::Transparent => HumanLayerPresentation::Transparent,
734                Self::Hint => HumanLayerPresentation::Normal,
735            }
736        }
737
738        fn hint(&self) -> Option<String> {
739            match self {
740                Self::Hint => Some("Run the setup command first.".to_string()),
741                _ => None,
742            }
743        }
744    }
745
746    #[test]
747    fn human_report_skips_transparent_wrappers() {
748        let err = Err::<(), _>(AlienError::new(TestError::Normal))
749            .context(TestError::Transparent)
750            .unwrap_err();
751
752        let report = err.human_report();
753        assert_eq!(report.code, "NORMAL");
754        assert_eq!(report.message, "Inner failure");
755        assert!(report.causes.is_empty());
756    }
757
758    #[test]
759    fn human_report_keeps_distinct_non_transparent_causes() {
760        let source = AlienError::new(GenericError {
761            message: "Socket closed".to_string(),
762        });
763        let err = Err::<(), _>(source).context(TestError::Normal).unwrap_err();
764
765        let report = err.human_report();
766        assert_eq!(report.code, "NORMAL");
767        assert_eq!(report.message, "Inner failure");
768        assert_eq!(report.causes.len(), 1);
769        assert_eq!(report.causes[0].code, "GENERIC_ERROR");
770        assert_eq!(report.causes[0].message, "Socket closed");
771    }
772
773    #[test]
774    fn human_report_uses_headline_hint_when_present() {
775        let err = AlienError::new(TestError::Hint);
776        let report = err.human_report();
777
778        assert_eq!(report.code, "HINT");
779        assert_eq!(report.message, "Action required");
780        assert_eq!(report.hint.as_deref(), Some("Run the setup command first."));
781    }
782
783    #[test]
784    fn human_report_uses_first_visible_hint_from_causes() {
785        let err = Err::<(), _>(AlienError::new(TestError::Hint))
786            .context(TestError::Transparent)
787            .unwrap_err();
788        let report = err.human_report();
789
790        assert_eq!(report.code, "HINT");
791        assert_eq!(report.hint.as_deref(), Some("Run the setup command first."));
792    }
793}