1use std::path::PathBuf;
34use thiserror::Error;
35
36#[derive(Debug, Error)]
42pub enum ScopeError {
43 #[error("Configuration error: {0}")]
47 Config(#[from] ConfigError),
48
49 #[error("Chain client error: {0}")]
53 Chain(String),
54
55 #[error("Venue error ({venue}): {message}")]
59 Venue {
60 venue: String,
62 message: String,
64 },
65
66 #[error("API request failed: {0}")]
70 Request(#[from] reqwest::Error),
71
72 #[error("Invalid address format: {0}")]
77 InvalidAddress(String),
78
79 #[error("Invalid transaction hash: {0}")]
83 InvalidHash(String),
84
85 #[error("I/O error: {0}")]
89 IoError(#[from] std::io::Error),
90
91 #[error("Export failed: {0}")]
93 Export(String),
94
95 #[error("JSON error: {0}")]
97 Json(#[from] serde_json::Error),
98
99 #[error("Network error: {0}")]
101 Network(String),
102
103 #[error("API error: {0}")]
105 Api(String),
106
107 #[error("Not found: {0}")]
109 NotFound(String),
110
111 #[error("{0}")]
115 InvalidInput(String),
116
117 #[error("Compliance error: {0}")]
119 Compliance(String),
120
121 #[error("Discovery error: {0}")]
123 Discovery(String),
124
125 #[error("Contract error: {0}")]
127 Contract(String),
128
129 #[error("{0}")]
131 Other(String),
132}
133
134impl ScopeError {
135 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#[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#[derive(Debug, Error)]
173pub enum ConfigError {
174 #[error("Configuration file not found: {path}")]
176 NotFound {
177 path: PathBuf,
179 },
180
181 #[error("Failed to read configuration file '{path}': {source}")]
183 Read {
184 path: PathBuf,
186 #[source]
188 source: std::io::Error,
189 },
190
191 #[error("Failed to parse configuration: {source}")]
193 Parse {
194 #[source]
196 source: serde_yaml::Error,
197 },
198
199 #[error("Invalid configuration: {message}")]
201 Validation {
202 message: String,
204 },
205}
206
207pub type Result<T> = std::result::Result<T, ScopeError>;
222
223#[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 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 assert!(config_err.source().is_some());
370 }
371}