Skip to main content

scope/
error.rs

1//! # Error Handling Module
2//!
3//! This module defines the error types used throughout the Scope application.
4//! It provides a unified error type [`ScopeError`] that wraps all possible
5//! error conditions, along with a convenient [`Result`] type alias.
6//!
7//! ## Error Hierarchy
8//!
9//! ```text
10//! ScopeError
11//! ├── Config     - Configuration loading/parsing errors
12//! ├── Chain      - Blockchain client errors
13//! ├── Request    - HTTP/API request failures
14//! ├── InvalidAddress - Malformed blockchain address
15//! ├── InvalidHash    - Malformed transaction hash
16//! ├── Io         - File system operations
17//! └── Export     - Data export failures
18//! ```
19//!
20//! ## Usage
21//!
22//! ```rust
23//! use scope::{ScopeError, Result};
24//!
25//! fn validate_address(addr: &str) -> Result<()> {
26//!     if !addr.starts_with("0x") || addr.len() != 42 {
27//!         return Err(ScopeError::InvalidAddress(addr.to_string()));
28//!     }
29//!     Ok(())
30//! }
31//! ```
32
33use std::path::PathBuf;
34use thiserror::Error;
35
36/// The primary error type for the Scope application.
37///
38/// This enum encompasses all error conditions that can occur during
39/// blockchain analysis operations. Each variant provides context-specific
40/// information to aid in debugging and user-friendly error messages.
41#[derive(Debug, Error)]
42pub enum ScopeError {
43    /// Configuration file could not be loaded or parsed.
44    ///
45    /// This includes missing files, invalid YAML syntax, or schema violations.
46    #[error("Configuration error: {0}")]
47    Config(#[from] ConfigError),
48
49    /// Blockchain client encountered an error.
50    ///
51    /// This covers RPC failures, unsupported chain operations, or protocol errors.
52    #[error("Chain client error: {0}")]
53    Chain(String),
54
55    /// A market venue (exchange) request or capability failed.
56    ///
57    /// `venue` is the venue id that made the request, for example `bybit`.
58    #[error("Venue error ({venue}): {message}")]
59    Venue {
60        /// Venue id from the descriptor.
61        venue: String,
62        /// What went wrong.
63        message: String,
64    },
65
66    /// HTTP request to an external API failed.
67    ///
68    /// Wraps reqwest errors for network failures, timeouts, or HTTP error responses.
69    #[error("API request failed: {0}")]
70    Request(#[from] reqwest::Error),
71
72    /// The provided blockchain address is malformed.
73    ///
74    /// Addresses must be valid for their respective chain (e.g., 0x-prefixed
75    /// 40-character hex string for Ethereum).
76    #[error("Invalid address format: {0}")]
77    InvalidAddress(String),
78
79    /// The provided transaction hash is malformed.
80    ///
81    /// Transaction hashes must be valid for their respective chain.
82    #[error("Invalid transaction hash: {0}")]
83    InvalidHash(String),
84
85    /// An I/O operation failed. Context goes into the error's message
86    /// (`io::Error::new(e.kind(), …)`); the kind stays, so callers can
87    /// tell a closed pipe from a full disk.
88    #[error("I/O error: {0}")]
89    IoError(#[from] std::io::Error),
90
91    /// Data export operation failed.
92    #[error("Export failed: {0}")]
93    Export(String),
94
95    /// JSON serialization/deserialization error.
96    #[error("JSON error: {0}")]
97    Json(#[from] serde_json::Error),
98
99    /// Network operation failed.
100    #[error("Network error: {0}")]
101    Network(String),
102
103    /// External API returned an error.
104    #[error("API error: {0}")]
105    Api(String),
106
107    /// Resource not found.
108    #[error("Not found: {0}")]
109    NotFound(String),
110
111    /// The request itself is at fault and the caller can fix it: an unknown
112    /// venue, or a chain that a DEX venue does not read. The message is
113    /// shown as is.
114    #[error("{0}")]
115    InvalidInput(String),
116
117    /// Compliance or risk analysis failed.
118    #[error("Compliance error: {0}")]
119    Compliance(String),
120
121    /// Token discovery operation failed.
122    #[error("Discovery error: {0}")]
123    Discovery(String),
124
125    /// Contract analysis failed.
126    #[error("Contract error: {0}")]
127    Contract(String),
128
129    /// Other unspecified error.
130    #[error("{0}")]
131    Other(String),
132}
133
134impl ScopeError {
135    /// The one way to wrap an I/O error with what was being done: the
136    /// message reads `<context>: <error>` and the error kind stays, so a
137    /// closed pipe is still a closed pipe.
138    pub fn io_context(e: std::io::Error, context: impl std::fmt::Display) -> Self {
139        let kind = e.kind();
140        let wrapped = IoContext {
141            context: context.to_string(),
142            source: e,
143        };
144        Self::IoError(std::io::Error::new(kind, wrapped))
145    }
146}
147
148/// An I/O error and what was being done. It keeps the original error as
149/// its source.
150#[derive(Debug)]
151struct IoContext {
152    context: String,
153    source: std::io::Error,
154}
155
156impl std::fmt::Display for IoContext {
157    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158        write!(f, "{}: {}", self.context, self.source)
159    }
160}
161
162impl std::error::Error for IoContext {
163    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
164        Some(&self.source)
165    }
166}
167
168/// Configuration-specific error type.
169///
170/// Provides detailed context for configuration failures, including
171/// the file path and underlying cause.
172#[derive(Debug, Error)]
173pub enum ConfigError {
174    /// Configuration file was not found at the expected path.
175    #[error("Configuration file not found: {path}")]
176    NotFound {
177        /// The path where the config file was expected.
178        path: PathBuf,
179    },
180
181    /// Failed to read the configuration file.
182    #[error("Failed to read configuration file '{path}': {source}")]
183    Read {
184        /// The path to the configuration file.
185        path: PathBuf,
186        /// The underlying I/O error.
187        #[source]
188        source: std::io::Error,
189    },
190
191    /// Configuration file contains invalid YAML.
192    #[error("Failed to parse configuration: {source}")]
193    Parse {
194        /// The underlying YAML parsing error.
195        #[source]
196        source: serde_yaml::Error,
197    },
198
199    /// Configuration values failed validation.
200    #[error("Invalid configuration: {message}")]
201    Validation {
202        /// Description of the validation failure.
203        message: String,
204    },
205}
206
207/// A specialized [`Result`] type for Scope operations.
208///
209/// This type alias reduces boilerplate by defaulting the error type
210/// to [`ScopeError`].
211///
212/// # Examples
213///
214/// ```rust
215/// use scope::Result;
216///
217/// fn do_something() -> Result<String> {
218///     Ok("success".to_string())
219/// }
220/// ```
221pub type Result<T> = std::result::Result<T, ScopeError>;
222
223// ============================================================================
224// Unit Tests
225// ============================================================================
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn test_bca_error_display_invalid_address() {
233        let err = ScopeError::InvalidAddress("not-an-address".into());
234        assert_eq!(err.to_string(), "Invalid address format: not-an-address");
235    }
236
237    #[test]
238    fn test_bca_error_display_invalid_hash() {
239        let err = ScopeError::InvalidHash("bad-hash".into());
240        assert_eq!(err.to_string(), "Invalid transaction hash: bad-hash");
241    }
242
243    #[test]
244    fn test_venue_error_display_names_the_venue() {
245        let err = ScopeError::Venue {
246            venue: "bybit".into(),
247            message: "API error: HTTP 400".into(),
248        };
249        assert_eq!(err.to_string(), "Venue error (bybit): API error: HTTP 400");
250    }
251
252    #[test]
253    fn test_bca_error_display_chain() {
254        let err = ScopeError::Chain("connection refused".into());
255        assert_eq!(err.to_string(), "Chain client error: connection refused");
256    }
257
258    #[test]
259    fn test_bca_error_display_export() {
260        let err = ScopeError::Export("failed to write CSV".into());
261        assert_eq!(err.to_string(), "Export failed: failed to write CSV");
262    }
263
264    #[test]
265    fn test_config_error_not_found() {
266        let err = ConfigError::NotFound {
267            path: PathBuf::from("/missing/config.yaml"),
268        };
269        assert_eq!(
270            err.to_string(),
271            "Configuration file not found: /missing/config.yaml"
272        );
273    }
274
275    #[test]
276    fn test_config_error_validation() {
277        let err = ConfigError::Validation {
278            message: "ethereum_rpc must be a valid URL".into(),
279        };
280        assert_eq!(
281            err.to_string(),
282            "Invalid configuration: ethereum_rpc must be a valid URL"
283        );
284    }
285
286    #[test]
287    fn test_bca_error_from_config_error() {
288        let config_err = ConfigError::NotFound {
289            path: PathBuf::from("/test"),
290        };
291        let bca_err: ScopeError = config_err.into();
292        assert!(matches!(bca_err, ScopeError::Config(_)));
293    }
294
295    #[test]
296    fn test_bca_error_from_io_error() {
297        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
298        let bca_err: ScopeError = io_err.into();
299        assert!(matches!(bca_err, ScopeError::IoError(_)));
300    }
301
302    #[test]
303    fn test_io_error_with_context_keeps_its_kind_and_the_old_text() {
304        // One I/O variant: the context is in the message, and the kind
305        // stays for checks such as a closed pipe.
306        let denied = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
307        let err = ScopeError::io_context(denied, "Failed to write token aliases");
308        let source = std::error::Error::source(&err).and_then(std::error::Error::source);
309        assert_eq!(
310            source.map(|s| s.to_string()).as_deref(),
311            Some("denied"),
312            "the source stays"
313        );
314        assert_eq!(
315            err.to_string(),
316            "I/O error: Failed to write token aliases: denied"
317        );
318        assert!(
319            matches!(&err, ScopeError::IoError(e) if e.kind() == std::io::ErrorKind::PermissionDenied)
320        );
321    }
322
323    #[test]
324    fn test_save_report_failure_names_the_path_and_keeps_the_kind() {
325        let dir = std::env::temp_dir().join("scope-no-such-dir-for-report-test");
326        let err = crate::display::report::save_report("x", dir.join("r.md")).unwrap_err();
327        assert!(
328            err.to_string()
329                .starts_with("I/O error: Failed to write report to ")
330        );
331        assert!(matches!(&err, ScopeError::IoError(e) if e.kind() == std::io::ErrorKind::NotFound));
332    }
333
334    #[test]
335    fn test_result_type_alias_ok() {
336        fn returns_ok() -> Result<i32> {
337            Ok(42)
338        }
339        assert_eq!(returns_ok().unwrap(), 42);
340    }
341
342    #[test]
343    fn test_result_type_alias_err() {
344        fn returns_err() -> Result<i32> {
345            Err(ScopeError::Chain("test error".into()))
346        }
347        assert!(returns_err().is_err());
348    }
349
350    #[test]
351    fn test_error_debug_impl() {
352        let err = ScopeError::InvalidAddress("0x123".into());
353        let debug_str = format!("{:?}", err);
354        assert!(debug_str.contains("InvalidAddress"));
355        assert!(debug_str.contains("0x123"));
356    }
357
358    #[test]
359    fn test_config_error_source_chain() {
360        use std::error::Error;
361
362        let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "access denied");
363        let config_err = ConfigError::Read {
364            path: PathBuf::from("/etc/scope/config.yaml"),
365            source: io_err,
366        };
367
368        // Verify the error chain works
369        assert!(config_err.source().is_some());
370    }
371}