scope-bca-core 0.7.0

Scope Blockchain Analysis - Core library for blockchain data analysis
Documentation
//! # Error Handling Module
//!
//! This module defines the error types used throughout the Scope application.
//! It provides a unified error type [`ScopeError`] that wraps all possible
//! error conditions, along with a convenient [`Result`] type alias.
//!
//! ## Error Hierarchy
//!
//! ```text
//! ScopeError
//! ├── Config     - Configuration loading/parsing errors
//! ├── Chain      - Blockchain client errors
//! ├── Request    - HTTP/API request failures
//! ├── InvalidAddress - Malformed blockchain address
//! ├── InvalidHash    - Malformed transaction hash
//! ├── Io         - File system operations
//! └── Export     - Data export failures
//! ```
//!
//! ## Usage
//!
//! ```rust
//! use scope::{ScopeError, Result};
//!
//! fn validate_address(addr: &str) -> Result<()> {
//!     if !addr.starts_with("0x") || addr.len() != 42 {
//!         return Err(ScopeError::InvalidAddress(addr.to_string()));
//!     }
//!     Ok(())
//! }
//! ```

use std::path::PathBuf;
use thiserror::Error;

/// The primary error type for the Scope application.
///
/// This enum encompasses all error conditions that can occur during
/// blockchain analysis operations. Each variant provides context-specific
/// information to aid in debugging and user-friendly error messages.
#[derive(Debug, Error)]
pub enum ScopeError {
    /// Configuration file could not be loaded or parsed.
    ///
    /// This includes missing files, invalid YAML syntax, or schema violations.
    #[error("Configuration error: {0}")]
    Config(#[from] ConfigError),

    /// Blockchain client encountered an error.
    ///
    /// This covers RPC failures, unsupported chain operations, or protocol errors.
    #[error("Chain client error: {0}")]
    Chain(String),

    /// A market venue (exchange) request or capability failed.
    ///
    /// `venue` is the venue id that made the request, for example `bybit`.
    #[error("Venue error ({venue}): {message}")]
    Venue {
        /// Venue id from the descriptor.
        venue: String,
        /// What went wrong.
        message: String,
    },

    /// HTTP request to an external API failed.
    ///
    /// Wraps reqwest errors for network failures, timeouts, or HTTP error responses.
    #[error("API request failed: {0}")]
    Request(#[from] reqwest::Error),

    /// The provided blockchain address is malformed.
    ///
    /// Addresses must be valid for their respective chain (e.g., 0x-prefixed
    /// 40-character hex string for Ethereum).
    #[error("Invalid address format: {0}")]
    InvalidAddress(String),

    /// The provided transaction hash is malformed.
    ///
    /// Transaction hashes must be valid for their respective chain.
    #[error("Invalid transaction hash: {0}")]
    InvalidHash(String),

    /// An I/O operation failed. Context goes into the error's message
    /// (`io::Error::new(e.kind(), …)`); the kind stays, so callers can
    /// tell a closed pipe from a full disk.
    #[error("I/O error: {0}")]
    IoError(#[from] std::io::Error),

    /// Data export operation failed.
    #[error("Export failed: {0}")]
    Export(String),

    /// JSON serialization/deserialization error.
    #[error("JSON error: {0}")]
    Json(#[from] serde_json::Error),

    /// Network operation failed.
    #[error("Network error: {0}")]
    Network(String),

    /// External API returned an error.
    #[error("API error: {0}")]
    Api(String),

    /// Resource not found.
    #[error("Not found: {0}")]
    NotFound(String),

    /// The request itself is at fault and the caller can fix it: an unknown
    /// venue, or a chain that a DEX venue does not read. The message is
    /// shown as is.
    #[error("{0}")]
    InvalidInput(String),

    /// Compliance or risk analysis failed.
    #[error("Compliance error: {0}")]
    Compliance(String),

    /// Token discovery operation failed.
    #[error("Discovery error: {0}")]
    Discovery(String),

    /// Contract analysis failed.
    #[error("Contract error: {0}")]
    Contract(String),

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

impl ScopeError {
    /// The one way to wrap an I/O error with what was being done: the
    /// message reads `<context>: <error>` and the error kind stays, so a
    /// closed pipe is still a closed pipe.
    pub fn io_context(e: std::io::Error, context: impl std::fmt::Display) -> Self {
        let kind = e.kind();
        let wrapped = IoContext {
            context: context.to_string(),
            source: e,
        };
        Self::IoError(std::io::Error::new(kind, wrapped))
    }
}

/// An I/O error and what was being done. It keeps the original error as
/// its source.
#[derive(Debug)]
struct IoContext {
    context: String,
    source: std::io::Error,
}

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

impl std::error::Error for IoContext {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        Some(&self.source)
    }
}

/// Configuration-specific error type.
///
/// Provides detailed context for configuration failures, including
/// the file path and underlying cause.
#[derive(Debug, Error)]
pub enum ConfigError {
    /// Configuration file was not found at the expected path.
    #[error("Configuration file not found: {path}")]
    NotFound {
        /// The path where the config file was expected.
        path: PathBuf,
    },

    /// Failed to read the configuration file.
    #[error("Failed to read configuration file '{path}': {source}")]
    Read {
        /// The path to the configuration file.
        path: PathBuf,
        /// The underlying I/O error.
        #[source]
        source: std::io::Error,
    },

    /// Configuration file contains invalid YAML.
    #[error("Failed to parse configuration: {source}")]
    Parse {
        /// The underlying YAML parsing error.
        #[source]
        source: serde_yaml::Error,
    },

    /// Configuration values failed validation.
    #[error("Invalid configuration: {message}")]
    Validation {
        /// Description of the validation failure.
        message: String,
    },
}

/// A specialized [`Result`] type for Scope operations.
///
/// This type alias reduces boilerplate by defaulting the error type
/// to [`ScopeError`].
///
/// # Examples
///
/// ```rust
/// use scope::Result;
///
/// fn do_something() -> Result<String> {
///     Ok("success".to_string())
/// }
/// ```
pub type Result<T> = std::result::Result<T, ScopeError>;

// ============================================================================
// Unit Tests
// ============================================================================

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

    #[test]
    fn test_bca_error_display_invalid_address() {
        let err = ScopeError::InvalidAddress("not-an-address".into());
        assert_eq!(err.to_string(), "Invalid address format: not-an-address");
    }

    #[test]
    fn test_bca_error_display_invalid_hash() {
        let err = ScopeError::InvalidHash("bad-hash".into());
        assert_eq!(err.to_string(), "Invalid transaction hash: bad-hash");
    }

    #[test]
    fn test_venue_error_display_names_the_venue() {
        let err = ScopeError::Venue {
            venue: "bybit".into(),
            message: "API error: HTTP 400".into(),
        };
        assert_eq!(err.to_string(), "Venue error (bybit): API error: HTTP 400");
    }

    #[test]
    fn test_bca_error_display_chain() {
        let err = ScopeError::Chain("connection refused".into());
        assert_eq!(err.to_string(), "Chain client error: connection refused");
    }

    #[test]
    fn test_bca_error_display_export() {
        let err = ScopeError::Export("failed to write CSV".into());
        assert_eq!(err.to_string(), "Export failed: failed to write CSV");
    }

    #[test]
    fn test_config_error_not_found() {
        let err = ConfigError::NotFound {
            path: PathBuf::from("/missing/config.yaml"),
        };
        assert_eq!(
            err.to_string(),
            "Configuration file not found: /missing/config.yaml"
        );
    }

    #[test]
    fn test_config_error_validation() {
        let err = ConfigError::Validation {
            message: "ethereum_rpc must be a valid URL".into(),
        };
        assert_eq!(
            err.to_string(),
            "Invalid configuration: ethereum_rpc must be a valid URL"
        );
    }

    #[test]
    fn test_bca_error_from_config_error() {
        let config_err = ConfigError::NotFound {
            path: PathBuf::from("/test"),
        };
        let bca_err: ScopeError = config_err.into();
        assert!(matches!(bca_err, ScopeError::Config(_)));
    }

    #[test]
    fn test_bca_error_from_io_error() {
        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
        let bca_err: ScopeError = io_err.into();
        assert!(matches!(bca_err, ScopeError::IoError(_)));
    }

    #[test]
    fn test_io_error_with_context_keeps_its_kind_and_the_old_text() {
        // One I/O variant: the context is in the message, and the kind
        // stays for checks such as a closed pipe.
        let denied = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
        let err = ScopeError::io_context(denied, "Failed to write token aliases");
        let source = std::error::Error::source(&err).and_then(std::error::Error::source);
        assert_eq!(
            source.map(|s| s.to_string()).as_deref(),
            Some("denied"),
            "the source stays"
        );
        assert_eq!(
            err.to_string(),
            "I/O error: Failed to write token aliases: denied"
        );
        assert!(
            matches!(&err, ScopeError::IoError(e) if e.kind() == std::io::ErrorKind::PermissionDenied)
        );
    }

    #[test]
    fn test_save_report_failure_names_the_path_and_keeps_the_kind() {
        let dir = std::env::temp_dir().join("scope-no-such-dir-for-report-test");
        let err = crate::display::report::save_report("x", dir.join("r.md")).unwrap_err();
        assert!(
            err.to_string()
                .starts_with("I/O error: Failed to write report to ")
        );
        assert!(matches!(&err, ScopeError::IoError(e) if e.kind() == std::io::ErrorKind::NotFound));
    }

    #[test]
    fn test_result_type_alias_ok() {
        fn returns_ok() -> Result<i32> {
            Ok(42)
        }
        assert_eq!(returns_ok().unwrap(), 42);
    }

    #[test]
    fn test_result_type_alias_err() {
        fn returns_err() -> Result<i32> {
            Err(ScopeError::Chain("test error".into()))
        }
        assert!(returns_err().is_err());
    }

    #[test]
    fn test_error_debug_impl() {
        let err = ScopeError::InvalidAddress("0x123".into());
        let debug_str = format!("{:?}", err);
        assert!(debug_str.contains("InvalidAddress"));
        assert!(debug_str.contains("0x123"));
    }

    #[test]
    fn test_config_error_source_chain() {
        use std::error::Error;

        let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "access denied");
        let config_err = ConfigError::Read {
            path: PathBuf::from("/etc/scope/config.yaml"),
            source: io_err,
        };

        // Verify the error chain works
        assert!(config_err.source().is_some());
    }
}