lastfm-client 4.0.1

A modern, async Rust library for fetching and analyzing Last.fm user data
Documentation
use serde::{Deserialize, Serialize};
use std::time::Duration;
use thiserror::Error;

/// Raw error response from the Last.fm API
#[derive(Debug, Deserialize, Serialize)]
#[non_exhaustive]
pub struct LastFmErrorResponse {
    /// Human-readable error message
    pub message: String,
    /// Numeric error code
    pub error: u32,
}

/// Errors that can occur when interacting with the Last.fm API
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum LastFmError {
    /// Represents a Last.fm API error with code and message
    /// Access details via the struct fields: `method`, `message`, `error_code`, `retryable`
    #[error("api error (method: {method}, code: {error_code}): {message}")]
    Api {
        /// API method that caused the error
        method: String,
        /// Human-readable error message
        message: String,
        /// Numeric Last.fm error code
        error_code: u32,
        /// Whether this error is retryable
        retryable: bool,
    },

    /// Represents rate limiting error
    /// Access `retry_after` via the struct field
    #[error("rate limit exceeded{}", retry_after.map(|d| format!(" (retry after {}ms)", d.as_millis())).unwrap_or_default())]
    RateLimited {
        /// Suggested delay before retrying
        retry_after: Option<Duration>,
    },

    /// Represents HTTP/network errors
    /// Access source error via `Error::source()`
    #[error("network error: {0}")]
    Network(#[from] reqwest::Error),

    /// Represents JSON parsing errors
    /// Access source error via `Error::source()`
    #[error("failed to parse response: {0}")]
    Parse(#[from] serde_json::Error),

    /// Represents file I/O errors
    /// Access source error via `Error::source()`
    #[error("file operation failed: {0}")]
    Io(#[from] std::io::Error),

    /// Represents CSV errors
    /// Access source error via `Error::source()`
    #[error("csv operation failed: {0}")]
    Csv(#[from] csv::Error),

    /// Represents missing environment variable errors
    #[error("missing required environment variable: {0}")]
    MissingEnvVar(String),

    /// Represents configuration errors
    #[error("configuration error: {0}")]
    Config(String),

    /// Represents HTTP response errors (non-success status codes)
    #[error("http error (status: {status})")]
    Http {
        /// HTTP status code
        status: u16,
        /// Underlying error source
        #[source]
        source: Option<Box<dyn std::error::Error + Send + Sync>>,
    },

    /// Represents URL parsing errors
    #[error("invalid url: {source}")]
    Url {
        /// URL parse error
        #[source]
        source: url::ParseError,
    },
}

impl LastFmError {
    /// Check if this error is retryable
    #[must_use]
    pub const fn is_retryable(&self) -> bool {
        match self {
            Self::Api { retryable, .. } => *retryable,
            Self::RateLimited { .. } | Self::Network(_) => true,
            _ => false,
        }
    }

    /// Get the retry delay if specified
    #[must_use]
    pub const fn retry_after(&self) -> Option<Duration> {
        match self {
            Self::RateLimited { retry_after } => *retry_after,
            _ => None,
        }
    }

    /// Get the API method name if this is an API error
    #[must_use]
    pub fn api_method(&self) -> Option<&str> {
        match self {
            Self::Api { method, .. } => Some(method),
            _ => None,
        }
    }

    /// Get the API error code if this is an API error
    #[must_use]
    pub const fn api_error_code(&self) -> Option<u32> {
        match self {
            Self::Api { error_code, .. } => Some(*error_code),
            _ => None,
        }
    }

    /// Get the API error message if this is an API error
    #[must_use]
    pub fn api_message(&self) -> Option<&str> {
        match self {
            Self::Api { message, .. } => Some(message),
            _ => None,
        }
    }

    /// Get the environment variable name if this is a missing env var error
    #[must_use]
    pub fn env_var_name(&self) -> Option<&str> {
        match self {
            Self::MissingEnvVar(name) => Some(name),
            _ => None,
        }
    }

    /// Get the HTTP status code if this is an HTTP error
    #[must_use]
    pub const fn http_status(&self) -> Option<u16> {
        match self {
            Self::Http { status, .. } => Some(*status),
            _ => None,
        }
    }
}

impl From<url::ParseError> for LastFmError {
    fn from(err: url::ParseError) -> Self {
        Self::Url { source: err }
    }
}

/// Helper type for Result with `LastFmError`
pub type Result<T> = std::result::Result<T, LastFmError>;

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

    #[test]
    fn test_retryable_errors() {
        let api_error = LastFmError::Api {
            method: "test.method".to_string(),
            message: "Temporary error".to_string(),
            error_code: 500,
            retryable: true,
        };
        assert!(api_error.is_retryable());

        let non_retryable = LastFmError::Api {
            method: "test.method".to_string(),
            message: "Invalid API key".to_string(),
            error_code: 10,
            retryable: false,
        };
        assert!(!non_retryable.is_retryable());

        let rate_limited = LastFmError::RateLimited {
            retry_after: Some(Duration::from_secs(5)),
        };
        assert!(rate_limited.is_retryable());

        let parse_error = LastFmError::Parse(serde_json::from_str::<()>("invalid").unwrap_err());
        assert!(!parse_error.is_retryable());
    }

    #[test]
    fn test_rate_limit_retry_after() {
        let error = LastFmError::RateLimited {
            retry_after: Some(Duration::from_secs(5)),
        };
        assert_eq!(error.retry_after(), Some(Duration::from_secs(5)));

        let api_error = LastFmError::Api {
            method: "test".to_string(),
            message: "Error".to_string(),
            error_code: 500,
            retryable: true,
        };
        assert_eq!(api_error.retry_after(), None);
    }

    #[test]
    fn test_error_display() {
        let error = LastFmError::MissingEnvVar("LAST_FM_API_KEY".to_string());
        let display = format!("{error}");
        assert_eq!(
            display,
            "missing required environment variable: LAST_FM_API_KEY"
        );
    }

    #[test]
    fn test_api_error_accessors() {
        let error = LastFmError::Api {
            method: "user.getrecenttracks".to_string(),
            message: "Invalid API key".to_string(),
            error_code: 10,
            retryable: false,
        };

        assert_eq!(error.api_method(), Some("user.getrecenttracks"));
        assert_eq!(error.api_error_code(), Some(10));
        assert_eq!(error.api_message(), Some("Invalid API key"));

        // Test that non-API errors return None
        let parse_error = LastFmError::Parse(serde_json::from_str::<()>("invalid").unwrap_err());
        assert_eq!(parse_error.api_method(), None);
        assert_eq!(parse_error.api_error_code(), None);
        assert_eq!(parse_error.api_message(), None);
    }

    #[test]
    fn test_env_var_accessor() {
        let error = LastFmError::MissingEnvVar("LAST_FM_API_KEY".to_string());
        assert_eq!(error.env_var_name(), Some("LAST_FM_API_KEY"));

        let api_error = LastFmError::Api {
            method: "test".to_string(),
            message: "Error".to_string(),
            error_code: 10,
            retryable: false,
        };
        assert_eq!(api_error.env_var_name(), None);
    }

    #[test]
    fn test_http_error() {
        let error = LastFmError::Http {
            status: 404,
            source: None,
        };
        assert_eq!(error.http_status(), Some(404));
        assert_eq!(format!("{error}"), "http error (status: 404)");
    }

    #[test]
    fn test_display_messages_format() {
        assert_eq!(
            format!(
                "{}",
                LastFmError::Api {
                    method: "user.getrecenttracks".to_string(),
                    message: "Invalid API key".to_string(),
                    error_code: 10,
                    retryable: false
                }
            ),
            "api error (method: user.getrecenttracks, code: 10): Invalid API key"
        );
        assert_eq!(
            format!("{}", LastFmError::RateLimited { retry_after: None }),
            "rate limit exceeded"
        );
        assert_eq!(
            format!(
                "{}",
                LastFmError::RateLimited {
                    retry_after: Some(Duration::from_secs(5))
                }
            ),
            "rate limit exceeded (retry after 5000ms)"
        );
        assert_eq!(
            format!("{}", LastFmError::MissingEnvVar("TEST".to_string())),
            "missing required environment variable: TEST"
        );
        assert_eq!(
            format!("{}", LastFmError::Config("bad value".to_string())),
            "configuration error: bad value"
        );
    }
}