romm-cli 0.38.0

Rust-based CLI and TUI for the ROMM API
Documentation
//! Typed error hierarchy for the romm-cli library.
//!
//! Domain enums use `thiserror`; the binary boundary converts [`RommError`] to
//! user-facing messages and exit codes via [`user_message`] and [`exit_code`].

use reqwest::StatusCode;
use thiserror::Error;

use crate::core::interrupt::CancelledByUser;

// ---------------------------------------------------------------------------
// ApiError
// ---------------------------------------------------------------------------

/// HTTP and API-layer failures from [`crate::client::RommClient`].
#[derive(Debug, Error)]
pub enum ApiError {
    #[error("ROMM API error: 401 Unauthorized - {body}")]
    Unauthorized { body: String },

    #[error("ROMM API error: 403 Forbidden - {body}")]
    Forbidden { body: String },

    #[error("ROMM API error: 404 Not Found - {body}")]
    NotFound { path: String, body: String },

    #[error("ROMM API error: 429 Too Many Requests - {body}")]
    RateLimited {
        retry_after: Option<u64>,
        body: String,
    },

    #[error("ROMM API error: {status} - {body}")]
    ClientError { status: u16, body: String },

    #[error("ROMM API error: {status} - {body}")]
    ServerError { status: u16, body: String },

    #[error("request failed: {0}")]
    Request(#[from] reqwest::Error),

    #[error("invalid response: {0}")]
    Decode(#[from] serde_json::Error),

    #[error("invalid HTTP method: {0}")]
    InvalidMethod(String),

    #[error("invalid HTTP header: {0}")]
    InvalidHeader(String),

    #[error("unexpected API response: {0}")]
    UnexpectedResponse(String),

    #[error("I/O error: {0}")]
    Io(#[from] std::io::Error),
}

impl ApiError {
    /// Map an HTTP status code and response body to a typed variant.
    pub fn from_http_response(status: StatusCode, body: impl Into<String>) -> Self {
        let body = body.into();
        match status.as_u16() {
            401 => Self::Unauthorized { body },
            403 => Self::Forbidden { body },
            404 => Self::NotFound {
                path: String::new(),
                body,
            },
            429 => Self::RateLimited {
                retry_after: None,
                body,
            },
            500..=599 => Self::ServerError {
                status: status.as_u16(),
                body,
            },
            400..=499 => Self::ClientError {
                status: status.as_u16(),
                body,
            },
            _ => Self::ClientError {
                status: status.as_u16(),
                body,
            },
        }
    }

    /// HTTP status when this error represents an API response failure.
    pub fn status_code(&self) -> Option<u16> {
        match self {
            Self::Unauthorized { .. } => Some(401),
            Self::Forbidden { .. } => Some(403),
            Self::NotFound { .. } => Some(404),
            Self::RateLimited { .. } => Some(429),
            Self::ClientError { status, .. } | Self::ServerError { status, .. } => Some(*status),
            _ => None,
        }
    }

    /// True for 401/403 — callers may prompt re-authentication.
    pub fn is_auth_failure(&self) -> bool {
        matches!(self, Self::Unauthorized { .. } | Self::Forbidden { .. })
    }

    /// True when the error body or display text indicates a 404 (URL fallback logic).
    pub fn is_not_found(&self) -> bool {
        matches!(self, Self::NotFound { .. })
            || self.status_code().is_some_and(|s| s == 404)
            || self.to_string().contains("404 Not Found")
    }
}

// ---------------------------------------------------------------------------
// ConfigError
// ---------------------------------------------------------------------------

/// Configuration, token file, and keyring failures.
#[derive(Debug, Error)]
pub enum ConfigError {
    #[error(
        "API_BASE_URL is not set. Set it in the environment, a config.json file, or run: romm-cli init"
    )]
    MissingBaseUrl,

    #[error("read bearer token file {path}: {source}")]
    TokenFileRead {
        path: String,
        #[source]
        source: std::io::Error,
    },

    #[error("bearer token file exceeds max size of {max} bytes")]
    TokenFileTooLarge { max: usize },

    #[error("bearer token file must be valid UTF-8: {path}")]
    TokenFileInvalidUtf8 { path: String },

    #[error("bearer token file is empty after trimming whitespace: {path}")]
    TokenFileEmpty { path: String },

    #[error("keyring entry error for {key}: {message}")]
    KeyringEntry { key: String, message: String },

    #[error("keyring store error for {key}: {message}")]
    KeyringStore { key: String, message: String },

    #[error("Could not resolve config directory. Set ROMM_OPENAPI_PATH to store openapi.json.")]
    ConfigDirUnavailable,

    #[error("Could not determine config directory (no HOME / APPDATA?).")]
    ConfigDirNotFound,

    #[error("invalid config path")]
    InvalidConfigPath,

    #[error("{context}: {source}")]
    Io {
        context: String,
        #[source]
        source: std::io::Error,
    },

    #[error("{0}")]
    Other(String),

    #[error("failed to serialize config: {0}")]
    Serialize(#[from] serde_json::Error),
}

// ---------------------------------------------------------------------------
// DownloadError
// ---------------------------------------------------------------------------

/// Download, path resolution, and transfer failures.
#[derive(Debug, Error)]
pub enum DownloadError {
    #[error("I/O error: {0}")]
    Io(#[from] std::io::Error),

    #[error("operation cancelled by user")]
    Cancelled(#[from] CancelledByUser),

    #[error("ROMs directory is not configured. Run setup to set a ROMs path.")]
    PathNotConfigured,

    #[error("ROMs directory cannot be empty")]
    RomsDirEmpty,

    #[error("ROMs directory is not valid: {path}")]
    InvalidRomsDir { path: String },

    #[error(transparent)]
    Api(#[from] ApiError),

    #[error(transparent)]
    Request(#[from] reqwest::Error),

    #[error("download job list lock poisoned: {0}")]
    JobListPoisoned(String),

    #[error("download failed without error details")]
    FailedWithoutDetails,

    #[error("Could not move temp ROM {path} to final destination {final_path}: {source}")]
    RenameFailed {
        path: String,
        final_path: String,
        #[source]
        source: std::io::Error,
    },

    #[error("no extras targets selected")]
    NoExtrasTargets,

    #[error("extras job list lock poisoned: {0}")]
    ExtrasJobListPoisoned(String),

    #[error("{context}: {source}")]
    IoContext {
        context: String,
        #[source]
        source: std::io::Error,
    },

    #[error("{0}")]
    Unexpected(String),
}

impl DownloadError {
    /// True when the underlying API error is a 404 (URL fallback logic).
    pub fn is_not_found(&self) -> bool {
        matches!(self, DownloadError::Api(api) if api.is_not_found())
    }
}

// ---------------------------------------------------------------------------
// RommError
// ---------------------------------------------------------------------------

/// Composed public error type for library consumers.
#[derive(Debug, Error)]
pub enum RommError {
    #[error(transparent)]
    Api(#[from] ApiError),

    #[error(transparent)]
    Config(#[from] ConfigError),

    #[error(transparent)]
    Download(#[from] DownloadError),

    #[error("{0}")]
    Other(String),
}

impl RommError {
    /// True when the user cancelled a long-running operation.
    pub fn is_cancelled(&self) -> bool {
        matches!(self, RommError::Download(DownloadError::Cancelled(_)))
    }

    /// True for auth-related API or config failures.
    pub fn is_auth_or_config(&self) -> bool {
        match self {
            RommError::Config(_) => true,
            RommError::Api(api) => api.is_auth_failure(),
            _ => false,
        }
    }
}

// ---------------------------------------------------------------------------
// Frontend mapping
// ---------------------------------------------------------------------------

/// Hint for TUI error toast behavior.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TuiErrorHint {
    /// Prompt user to run setup / init.
    RunInit,
    /// Prompt user to re-authenticate in Settings.
    ReAuth,
    /// Transient failure — user may retry.
    Retry,
    /// Generic dismissible error.
    Dismiss,
}

/// Actionable user-facing message (short, no full error chain).
pub fn user_message(err: &RommError) -> String {
    if err.is_cancelled() {
        return "Operation cancelled.".to_string();
    }
    match err {
        RommError::Config(ConfigError::MissingBaseUrl) => {
            "API_BASE_URL is not set. Run `romm-cli init` to configure.".to_string()
        }
        RommError::Config(_) => {
            format!("Configuration error: {err}. Check config or run `romm-cli init`.")
        }
        RommError::Api(api) if api.is_auth_failure() => {
            "Authentication failed. Check credentials or run `romm-cli auth`.".to_string()
        }
        RommError::Api(ApiError::Request(_)) => {
            "Network error. Check your connection and server URL.".to_string()
        }
        RommError::Api(ApiError::ServerError { .. }) => {
            "Server error. Try again later.".to_string()
        }
        RommError::Download(DownloadError::PathNotConfigured) => {
            "ROMs directory is not configured. Run `romm-cli init`.".to_string()
        }
        RommError::Download(_) => format!("Download failed: {err}"),
        RommError::Api(_) => format!("API error: {err}"),
        RommError::Other(msg) => msg.clone(),
    }
}

/// Process exit code (Gap 3 partial).
pub fn exit_code(err: &RommError) -> i32 {
    if err.is_cancelled() {
        return 0;
    }
    match err {
        RommError::Config(_) => 3,
        RommError::Api(api) if api.is_auth_failure() => 3,
        RommError::Api(ApiError::Request(_)) => 4,
        RommError::Api(_) => 4,
        RommError::Download(_) => 4,
        RommError::Other(_) => 1,
    }
}

/// TUI behavior hint for an error.
pub fn tui_hint(err: &RommError) -> TuiErrorHint {
    if err.is_cancelled() {
        return TuiErrorHint::Dismiss;
    }
    match err {
        RommError::Config(ConfigError::MissingBaseUrl) => TuiErrorHint::RunInit,
        RommError::Config(_) => TuiErrorHint::ReAuth,
        RommError::Api(api) if api.is_auth_failure() => TuiErrorHint::ReAuth,
        RommError::Api(ApiError::Request(_))
        | RommError::Api(ApiError::ServerError { .. })
        | RommError::Download(_) => TuiErrorHint::Retry,
        RommError::Other(_) | RommError::Api(_) => TuiErrorHint::Dismiss,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn from_http_response_maps_status_codes() {
        let e = ApiError::from_http_response(StatusCode::UNAUTHORIZED, "bad token");
        assert!(matches!(e, ApiError::Unauthorized { .. }));
        assert_eq!(e.status_code(), Some(401));
        assert!(e.is_auth_failure());

        let e = ApiError::from_http_response(StatusCode::INTERNAL_SERVER_ERROR, "oops");
        assert!(matches!(e, ApiError::ServerError { status: 500, .. }));
        assert_eq!(e.status_code(), Some(500));

        let e = ApiError::from_http_response(StatusCode::NOT_FOUND, "missing");
        assert!(matches!(e, ApiError::NotFound { .. }));
        assert!(e.is_not_found());
    }

    #[test]
    fn romm_error_is_cancelled() {
        let err = RommError::Download(DownloadError::Cancelled(CancelledByUser));
        assert!(err.is_cancelled());
        assert_eq!(exit_code(&err), 0);
    }

    #[test]
    fn exit_code_auth_vs_network() {
        let auth = RommError::Api(ApiError::Unauthorized { body: "x".into() });
        assert_eq!(exit_code(&auth), 3);

        let net = RommError::Api(ApiError::ServerError {
            status: 503,
            body: "down".into(),
        });
        assert_eq!(exit_code(&net), 4);
    }
}