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    /// No token matches a symbol in the place that was searched.
112    #[error("No token matches '{input}' ({searched})")]
113    TokenNotFound {
114        /// What the user typed.
115        input: String,
116        /// Where scope looked, for example "DexScreener on ethereum".
117        searched: String,
118    },
119
120    /// A symbol names more than one token and scope cannot ask which one.
121    #[error(
122        "'{input}' matches more than one token:\n  {}\nPass --token <contract>, or run the command in a terminal to choose and save one.",
123        candidates.join("\n  ")
124    )]
125    AmbiguousToken {
126        /// What the user typed.
127        input: String,
128        /// One line for each candidate: chain, contract, name.
129        candidates: Vec<String>,
130    },
131
132    /// A saved binding names a different contract than the venue lists.
133    #[error(
134        "{symbol} on {chain}: your saved token is {saved}, but {venue} lists {venue_contract}. Check which one you mean, then pass --token <contract> or remove the binding (tokens remove {symbol} in the TUI)."
135    )]
136    TokenMismatch {
137        /// The symbol.
138        symbol: String,
139        /// The binding's chain.
140        chain: String,
141        /// The saved contract.
142        saved: String,
143        /// The venue's contract on that chain.
144        venue_contract: String,
145        /// The venue id.
146        venue: String,
147    },
148
149    /// The request itself is at fault and the caller can fix it: an unknown
150    /// venue, or a chain that a DEX venue does not read. The message is
151    /// shown as is.
152    #[error("{0}")]
153    InvalidInput(String),
154
155    /// Compliance or risk analysis failed.
156    #[error("Compliance error: {0}")]
157    Compliance(String),
158
159    /// Token discovery operation failed.
160    #[error("Discovery error: {0}")]
161    Discovery(String),
162
163    /// Contract analysis failed.
164    #[error("Contract error: {0}")]
165    Contract(String),
166
167    /// Other unspecified error.
168    #[error("{0}")]
169    Other(String),
170}
171
172impl ScopeError {
173    /// The one way to wrap an I/O error with what was being done: the
174    /// message reads `<context>: <error>` and the error kind stays, so a
175    /// closed pipe is still a closed pipe.
176    pub fn io_context(e: std::io::Error, context: impl std::fmt::Display) -> Self {
177        let kind = e.kind();
178        let wrapped = IoContext {
179            context: context.to_string(),
180            source: e,
181        };
182        Self::IoError(std::io::Error::new(kind, wrapped))
183    }
184}
185
186/// An I/O error and what was being done. It keeps the original error as
187/// its source.
188#[derive(Debug)]
189struct IoContext {
190    context: String,
191    source: std::io::Error,
192}
193
194impl std::fmt::Display for IoContext {
195    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
196        write!(f, "{}: {}", self.context, self.source)
197    }
198}
199
200impl std::error::Error for IoContext {
201    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
202        Some(&self.source)
203    }
204}
205
206/// Configuration-specific error type.
207///
208/// Provides detailed context for configuration failures, including
209/// the file path and underlying cause.
210#[derive(Debug, Error)]
211pub enum ConfigError {
212    /// Configuration file was not found at the expected path.
213    #[error("Configuration file not found: {path}")]
214    NotFound {
215        /// The path where the config file was expected.
216        path: PathBuf,
217    },
218
219    /// Failed to read the configuration file.
220    #[error("Failed to read configuration file '{path}': {source}")]
221    Read {
222        /// The path to the configuration file.
223        path: PathBuf,
224        /// The underlying I/O error.
225        #[source]
226        source: std::io::Error,
227    },
228
229    /// Configuration file contains invalid YAML.
230    #[error("Failed to parse configuration: {source}")]
231    Parse {
232        /// The underlying YAML parsing error.
233        #[source]
234        source: serde_saphyr::Error,
235    },
236
237    /// Configuration values failed validation.
238    #[error("Invalid configuration: {message}")]
239    Validation {
240        /// Description of the validation failure.
241        message: String,
242    },
243}
244
245/// A specialized [`Result`] type for Scope operations.
246///
247/// This type alias reduces boilerplate by defaulting the error type
248/// to [`ScopeError`].
249///
250/// # Examples
251///
252/// ```rust
253/// use scope::Result;
254///
255/// fn do_something() -> Result<String> {
256///     Ok("success".to_string())
257/// }
258/// ```
259pub type Result<T> = std::result::Result<T, ScopeError>;
260
261// ============================================================================
262// Unit Tests
263// ============================================================================
264
265#[cfg(test)]
266mod tests {
267    #[test]
268    fn test_token_errors_say_what_to_do() {
269        use super::ScopeError;
270        let e = ScopeError::AmbiguousToken {
271            input: "USDN".into(),
272            candidates: vec!["ethereum 0xeC74 USDN".into(), "bsc 0x55d3 USDN".into()],
273        };
274        let text = e.to_string();
275        assert!(
276            text.contains("ethereum 0xeC74 USDN") && text.contains("bsc 0x55d3 USDN"),
277            "{text}"
278        );
279        assert!(text.contains("--token"), "{text}");
280
281        let e = ScopeError::TokenMismatch {
282            symbol: "USDN".into(),
283            chain: "ethereum".into(),
284            saved: "0xAAA".into(),
285            venue_contract: "0xBBB".into(),
286            venue: "weex".into(),
287        };
288        let text = e.to_string();
289        assert!(
290            text.contains("0xAAA") && text.contains("0xBBB") && text.contains("weex"),
291            "{text}"
292        );
293    }
294
295    use super::*;
296
297    #[test]
298    fn test_bca_error_display_invalid_address() {
299        let err = ScopeError::InvalidAddress("not-an-address".into());
300        assert_eq!(err.to_string(), "Invalid address format: not-an-address");
301    }
302
303    #[test]
304    fn test_bca_error_display_invalid_hash() {
305        let err = ScopeError::InvalidHash("bad-hash".into());
306        assert_eq!(err.to_string(), "Invalid transaction hash: bad-hash");
307    }
308
309    #[test]
310    fn test_venue_error_display_names_the_venue() {
311        let err = ScopeError::Venue {
312            venue: "bybit".into(),
313            message: "API error: HTTP 400".into(),
314        };
315        assert_eq!(err.to_string(), "Venue error (bybit): API error: HTTP 400");
316    }
317
318    #[test]
319    fn test_bca_error_display_chain() {
320        let err = ScopeError::Chain("connection refused".into());
321        assert_eq!(err.to_string(), "Chain client error: connection refused");
322    }
323
324    #[test]
325    fn test_bca_error_display_export() {
326        let err = ScopeError::Export("failed to write CSV".into());
327        assert_eq!(err.to_string(), "Export failed: failed to write CSV");
328    }
329
330    #[test]
331    fn test_config_error_not_found() {
332        let err = ConfigError::NotFound {
333            path: PathBuf::from("/missing/config.yaml"),
334        };
335        assert_eq!(
336            err.to_string(),
337            "Configuration file not found: /missing/config.yaml"
338        );
339    }
340
341    #[test]
342    fn test_config_error_validation() {
343        let err = ConfigError::Validation {
344            message: "ethereum_rpc must be a valid URL".into(),
345        };
346        assert_eq!(
347            err.to_string(),
348            "Invalid configuration: ethereum_rpc must be a valid URL"
349        );
350    }
351
352    #[test]
353    fn test_bca_error_from_config_error() {
354        let config_err = ConfigError::NotFound {
355            path: PathBuf::from("/test"),
356        };
357        let bca_err: ScopeError = config_err.into();
358        assert!(matches!(bca_err, ScopeError::Config(_)));
359    }
360
361    #[test]
362    fn test_bca_error_from_io_error() {
363        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
364        let bca_err: ScopeError = io_err.into();
365        assert!(matches!(bca_err, ScopeError::IoError(_)));
366    }
367
368    #[test]
369    fn test_io_error_with_context_keeps_its_kind_and_the_old_text() {
370        // One I/O variant: the context is in the message, and the kind
371        // stays for checks such as a closed pipe.
372        let denied = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
373        let err = ScopeError::io_context(denied, "Failed to write token aliases");
374        let source = std::error::Error::source(&err).and_then(std::error::Error::source);
375        assert_eq!(
376            source.map(|s| s.to_string()).as_deref(),
377            Some("denied"),
378            "the source stays"
379        );
380        assert_eq!(
381            err.to_string(),
382            "I/O error: Failed to write token aliases: denied"
383        );
384        assert!(
385            matches!(&err, ScopeError::IoError(e) if e.kind() == std::io::ErrorKind::PermissionDenied)
386        );
387    }
388
389    #[test]
390    fn test_save_report_failure_names_the_path_and_keeps_the_kind() {
391        let dir = std::env::temp_dir().join("scope-no-such-dir-for-report-test");
392        let err = crate::display::report::save_report("x", dir.join("r.md")).unwrap_err();
393        assert!(
394            err.to_string()
395                .starts_with("I/O error: Failed to write report to ")
396        );
397        assert!(matches!(&err, ScopeError::IoError(e) if e.kind() == std::io::ErrorKind::NotFound));
398    }
399
400    #[test]
401    fn test_result_type_alias_ok() {
402        fn returns_ok() -> Result<i32> {
403            Ok(42)
404        }
405        assert_eq!(returns_ok().unwrap(), 42);
406    }
407
408    #[test]
409    fn test_result_type_alias_err() {
410        fn returns_err() -> Result<i32> {
411            Err(ScopeError::Chain("test error".into()))
412        }
413        assert!(returns_err().is_err());
414    }
415
416    #[test]
417    fn test_error_debug_impl() {
418        let err = ScopeError::InvalidAddress("0x123".into());
419        let debug_str = format!("{:?}", err);
420        assert!(debug_str.contains("InvalidAddress"));
421        assert!(debug_str.contains("0x123"));
422    }
423
424    #[test]
425    fn test_config_error_source_chain() {
426        use std::error::Error;
427
428        let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "access denied");
429        let config_err = ConfigError::Read {
430            path: PathBuf::from("/etc/scope/config.yaml"),
431            source: io_err,
432        };
433
434        // Verify the error chain works
435        assert!(config_err.source().is_some());
436    }
437}