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("No token matches '{input}' ({searched})")]
113 TokenNotFound {
114 input: String,
116 searched: String,
118 },
119
120 #[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 input: String,
128 candidates: Vec<String>,
130 },
131
132 #[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 symbol: String,
139 chain: String,
141 saved: String,
143 venue_contract: String,
145 venue: String,
147 },
148
149 #[error("{0}")]
153 InvalidInput(String),
154
155 #[error("Compliance error: {0}")]
157 Compliance(String),
158
159 #[error("Discovery error: {0}")]
161 Discovery(String),
162
163 #[error("Contract error: {0}")]
165 Contract(String),
166
167 #[error("{0}")]
169 Other(String),
170}
171
172impl ScopeError {
173 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#[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#[derive(Debug, Error)]
211pub enum ConfigError {
212 #[error("Configuration file not found: {path}")]
214 NotFound {
215 path: PathBuf,
217 },
218
219 #[error("Failed to read configuration file '{path}': {source}")]
221 Read {
222 path: PathBuf,
224 #[source]
226 source: std::io::Error,
227 },
228
229 #[error("Failed to parse configuration: {source}")]
231 Parse {
232 #[source]
234 source: serde_saphyr::Error,
235 },
236
237 #[error("Invalid configuration: {message}")]
239 Validation {
240 message: String,
242 },
243}
244
245pub type Result<T> = std::result::Result<T, ScopeError>;
260
261#[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 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 assert!(config_err.source().is_some());
436 }
437}