transfer_family_cli 0.3.0

TUI to browse and transfer files via AWS Transfer Family connector
Documentation
//! Error types for the Transfer Family Connector TUI CLI.
//!
//! Errors are designed for two audiences:
//! - **Machines**: Flat, actionable kinds and explicit retryability (`ErrorKind`, `ErrorStatus`).
//! - **Humans**: Rich context at failure points (operation, path, id) via the context list.
//!
//! Convention: when constructing or propagating an error, attach context (e.g. `.with("remote_path", path)`)
//! so logs and UI can say "listing /foo/ failed" or "get file.txt (`transfer_id` x) timed out".

use std::collections::HashMap;
use std::error::Error as StdError;
use std::fmt;
use std::sync::Arc;

pub type Result<T> = std::result::Result<T, Error>;

/// What the caller can do about the error (action-oriented, not origin-oriented).
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ErrorKind {
    NotFound,
    Timeout,
    InvalidInput,
    PermissionDenied,
    Io,
    Api,
    Parse,
}

/// Whether the operation is safe to retry.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ErrorStatus {
    Permanent,
    Temporary,
}

/// Single error type with kind, status, message, context, and optional source.
#[non_exhaustive]
#[derive(Clone, Debug)]
pub struct Error {
    pub kind: ErrorKind,
    pub status: ErrorStatus,
    pub message: String,
    pub context: Vec<(&'static str, String)>,
    pub source: Option<Arc<dyn StdError + Send + Sync>>,
}

impl Error {
    /// Builder: add a context key-value pair. Consumes self and returns self for chaining.
    pub fn with(mut self, key: &'static str, value: impl Into<String>) -> Self {
        self.context.push((key, value.into()));
        self
    }

    /// Builder: attach the underlying cause. Consumes self and returns self for chaining.
    pub fn with_source(self, source: impl StdError + Send + Sync + 'static) -> Self {
        Self {
            source: Some(Arc::new(source)),
            ..self
        }
    }

    /// User-facing display: message, deduplicated context, and full cause chain.
    #[must_use]
    pub fn display_for_user(&self) -> String {
        let mut base = self.message.clone();
        if !self.context.is_empty() {
            let mut by_key: HashMap<&'static str, String> = HashMap::new();
            for (k, v) in &self.context {
                by_key.insert(*k, v.clone());
            }
            let mut keys: Vec<&'static str> = by_key.keys().copied().collect();
            keys.sort();
            let ctx: String = keys
                .iter()
                .map(|k| format!("{k}: {}", by_key.get(*k).map(String::as_str).unwrap_or("")))
                .collect::<Vec<_>>()
                .join(", ");
            base = format!("{base} ({ctx})");
        }
        let mut chain = String::new();
        let mut src: Option<&(dyn StdError + 'static)> = self.source();
        while let Some(s) = src {
            let msg = s.to_string();
            if !msg.is_empty() {
                if !chain.is_empty() {
                    chain.push_str(" — ");
                }
                chain.push_str(&msg);
            }
            src = s.source();
        }
        if chain.is_empty() {
            base
        } else {
            format!("{base} — {chain}")
        }
    }

    /// Whether this error is safe to retry.
    #[must_use]
    pub fn is_retryable(&self) -> bool {
        self.status == ErrorStatus::Temporary
    }

    /// Private constructor; public constructors delegate here.
    fn new(
        kind: ErrorKind,
        status: ErrorStatus,
        message: impl Into<String>,
        source: Option<Arc<dyn StdError + Send + Sync>>,
    ) -> Self {
        Self {
            kind,
            status,
            message: message.into(),
            context: Vec::new(),
            source,
        }
    }

    // --- Constructors (kind + status) ---

    pub fn not_found(message: impl Into<String>) -> Self {
        Self::new(ErrorKind::NotFound, ErrorStatus::Permanent, message, None)
    }

    pub fn timeout(message: impl Into<String>) -> Self {
        Self::new(ErrorKind::Timeout, ErrorStatus::Temporary, message, None)
    }

    pub fn invalid_input(message: impl Into<String>) -> Self {
        Self::new(
            ErrorKind::InvalidInput,
            ErrorStatus::Permanent,
            message,
            None,
        )
    }

    pub fn permission_denied(message: impl Into<String>) -> Self {
        Self::new(
            ErrorKind::PermissionDenied,
            ErrorStatus::Permanent,
            message,
            None,
        )
    }

    pub fn io(message: impl Into<String>, source: impl StdError + Send + Sync + 'static) -> Self {
        Self::new(
            ErrorKind::Io,
            ErrorStatus::Permanent,
            message,
            Some(Arc::new(source)),
        )
    }

    pub fn api(message: impl Into<String>) -> Self {
        Self::new(ErrorKind::Api, ErrorStatus::Temporary, message, None)
    }

    pub fn api_permanent(message: impl Into<String>) -> Self {
        Self::new(ErrorKind::Api, ErrorStatus::Permanent, message, None)
    }

    pub fn parse(
        message: impl Into<String>,
        source: impl StdError + Send + Sync + 'static,
    ) -> Self {
        Self::new(
            ErrorKind::Parse,
            ErrorStatus::Permanent,
            message,
            Some(Arc::new(source)),
        )
    }

    /// Maps an AWS Transfer SDK error to Error by HTTP status (429/5xx -> temporary Api, else `api_permanent`).
    #[must_use]
    pub fn from_transfer_sdk_status(message: impl Into<String>, status: Option<u16>) -> Self {
        match status {
            Some(429) | Some(500..=599) => Self::api(message),
            _ => Self::api_permanent(message),
        }
    }

    /// Maps an S3 SDK error to Error by HTTP status (404 -> `NotFound`, 429/5xx -> Api, else `api_permanent`).
    #[must_use]
    pub fn from_s3_sdk_status(message: impl Into<String>, status: Option<u16>) -> Self {
        match status {
            Some(404) => Self::not_found(message),
            Some(429) | Some(500..=599) => Self::api(message),
            _ => Self::api_permanent(message),
        }
    }
}

impl fmt::Display for Error {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.display_for_user())
    }
}

impl StdError for Error {
    fn source(&self) -> Option<&(dyn StdError + 'static)> {
        self.source
            .as_ref()
            .map(|s| s.as_ref() as &(dyn StdError + 'static))
    }
}

impl From<serde_json::Error> for Error {
    fn from(e: serde_json::Error) -> Self {
        Error::parse("parse error", e)
    }
}

impl From<std::io::Error> for Error {
    fn from(e: std::io::Error) -> Self {
        Error::io("IO error", e)
    }
}