1use crate::cli::output::Output;
7use crate::errln;
8use scope::display::theme::{Painter, Role, color_enabled_stderr};
9use scope::error::ScopeError;
10
11pub fn display_error(e: &ScopeError, out: &Output) -> std::io::Result<()> {
15 display_error_styled(e, color_enabled_stderr(), out)
16}
17
18fn display_error_styled(e: &ScopeError, tty: bool, out: &Output) -> std::io::Result<()> {
20 let msg = match e {
21 ScopeError::NotFound(inner) => inner.clone(),
22 other => format!("{}", other),
23 };
24
25 let painter = Painter::new(tty);
27 errln!(
28 out,
29 "\n {} {}",
30 painter.paint_strong(Role::Err, "✗"),
31 painter.paint(Role::Err, &msg)
32 )?;
33
34 let causes = cause_chain(e, &msg);
37 for cause in &causes {
38 errln!(
39 out,
40 " {} {}",
41 painter.paint(Role::Secondary, "caused by:"),
42 cause
43 )?;
44 }
45
46 let hint = if causes.iter().any(|c| is_non_tls_reply(c)) {
47 Some(INTERCEPTION_HINT)
48 } else {
49 error_suggestion(e, out.is_tui())
50 };
51 if let Some(hint) = hint {
52 errln!(out, "\n {}", painter.paint(Role::Secondary, hint))?;
53 }
54 errln!(out)?;
55 Ok(())
56}
57
58const INTERCEPTION_HINT: &str = "The server's reply was not TLS. A router, firewall, proxy or VPN on your\n \
60 network may be blocking this host and answering with its own block page.\n \
61 Allow the host in that device, or try another network.";
62
63fn cause_chain(e: &ScopeError, shown: &str) -> Vec<String> {
66 let mut out: Vec<String> = Vec::new();
67 let mut src = std::error::Error::source(e);
68 while let Some(s) = src {
69 let text = s.to_string();
70 if !shown.contains(&text) && !out.contains(&text) {
71 out.push(text);
72 }
73 src = s.source();
74 }
75 out
76}
77
78fn is_non_tls_reply(cause: &str) -> bool {
81 cause.contains("InvalidContentType") || cause.contains("wrong version number")
82}
83
84pub fn details_hint(tui: bool) -> &'static str {
88 if tui {
89 "run 'help <command>' for usage"
90 } else {
91 "use -v for details"
92 }
93}
94
95const TUI_NETWORK_HINT: &str = "Check your network connection and try again.\n \
97 run 'help <command>' for usage; set 'format json' for raw output";
98
99pub fn error_suggestion(e: &ScopeError, tui: bool) -> Option<&'static str> {
102 match e {
103 ScopeError::InvalidAddress(_) => Some(
104 "Ensure the address format matches the target chain.\n \
105 EVM: 0x followed by 40 hex characters\n \
106 Solana: base58 encoded public key\n \
107 Tron: T followed by base58 characters",
108 ),
109 ScopeError::InvalidHash(_) => Some(
110 "Ensure the transaction hash matches the target chain.\n \
111 EVM: 0x followed by 64 hex characters\n \
112 Solana: base58 encoded signature",
113 ),
114 ScopeError::Config(_) => Some("Run `scope setup` to create or repair your configuration."),
115 ScopeError::Request(_) | ScopeError::Network(_) if tui => Some(TUI_NETWORK_HINT),
116 ScopeError::Request(_) | ScopeError::Network(_) => Some(
117 "Check your network connection and try again.\n \
118 Use -v for more details on the failing request.",
119 ),
120 ScopeError::Api(msg)
121 if msg.contains("401") || msg.contains("403") || msg.contains("key") =>
122 {
123 Some(
124 "Your API key may be missing or invalid.\n Run `scope setup --key <provider>` to configure it.",
125 )
126 }
127 ScopeError::NotFound(_) => Some(
128 "The resource was not found. Verify the address, hash, or token exists on the specified chain.",
129 ),
130 _ => None,
131 }
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137
138 async fn plaintext_reply_error() -> ScopeError {
142 use std::io::{Read, Write};
143 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
144 let port = listener.local_addr().unwrap().port();
145 std::thread::spawn(move || {
146 if let Ok((mut s, _)) = listener.accept() {
147 let mut buf = [0u8; 1024];
148 let _ = s.read(&mut buf);
149 let _ = s.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n");
150 }
151 });
152 let err = reqwest::Client::new()
153 .get(format!("https://127.0.0.1:{port}/"))
154 .send()
155 .await
156 .unwrap_err();
157 ScopeError::Request(err)
158 }
159
160 #[tokio::test]
161 async fn test_request_error_shows_its_cause_chain() {
162 let e = plaintext_reply_error().await;
164 let (o, cap) = crate::cli::output::Output::capture();
165 display_error_styled(&e, false, &o).unwrap();
166 let text = cap.err();
167 assert!(text.contains("caused by:"), "{}", text);
168 assert!(
169 text.contains("InvalidContentType") || text.contains("corrupt message"),
170 "{}",
171 text
172 );
173 }
174
175 #[tokio::test]
176 async fn test_non_tls_reply_gets_an_interception_hint() {
177 let e = plaintext_reply_error().await;
180 let (o, cap) = crate::cli::output::Output::capture();
181 display_error_styled(&e, false, &o).unwrap();
182 let text = cap.err();
183 assert!(text.contains("router, firewall, proxy or VPN"), "{}", text);
184 assert!(!text.contains("Check your network connection"), "{}", text);
185 }
186
187 #[test]
188 fn test_error_without_source_prints_no_cause_lines() {
189 let (o, cap) = crate::cli::output::Output::capture();
190 display_error_styled(&ScopeError::Other("plain".into()), false, &o).unwrap();
191 assert!(!cap.err().contains("caused by"));
192 }
193
194 fn quiet() -> crate::cli::output::Output {
195 crate::cli::output::Output::capture().0
196 }
197
198 #[test]
203 fn test_details_hint_names_no_cli_flag_in_the_tui() {
204 assert_eq!(details_hint(false), "use -v for details");
205 assert_eq!(details_hint(true), "run 'help <command>' for usage");
206 assert!(!details_hint(true).contains("-v"));
207 }
208
209 #[test]
210 fn test_display_error_goes_to_err_channel_with_hint() {
211 let (o, cap) = crate::cli::output::Output::capture();
214 display_error_styled(&ScopeError::InvalidAddress("0xbad".into()), false, &o).unwrap();
215 assert_eq!(cap.out(), "");
216 assert!(
217 cap.err().contains("✗ Invalid address format: 0xbad"),
218 "{}",
219 cap.err()
220 );
221 assert!(cap.err().contains("EVM: 0x followed by 40 hex characters"));
222 }
223
224 #[test]
225 fn test_display_error_not_found() {
226 let err = ScopeError::NotFound("test resource".into());
227 display_error(&err, &quiet()).unwrap();
228 }
229
230 #[test]
231 fn test_display_error_invalid_address() {
232 let err = ScopeError::InvalidAddress("0xbad".into());
233 display_error(&err, &quiet()).unwrap();
234 }
235
236 #[test]
237 fn test_display_error_other() {
238 let err = ScopeError::Other("something went wrong".into());
239 display_error(&err, &quiet()).unwrap();
240 }
241
242 #[test]
243 fn test_display_error_chain() {
244 let err = ScopeError::Chain("chain error".into());
245 display_error(&err, &quiet()).unwrap();
246 }
247
248 #[test]
249 fn test_display_error_api() {
250 let err = ScopeError::Api("500 Internal Server Error".into());
251 display_error(&err, &quiet()).unwrap();
252 }
253
254 #[test]
259 fn test_display_error_styled_tty_not_found() {
260 let err = ScopeError::NotFound("test resource".into());
261 display_error_styled(&err, true, &quiet()).unwrap();
262 }
263
264 #[test]
265 fn test_display_error_styled_tty_invalid_address() {
266 let err = ScopeError::InvalidAddress("0xbad".into());
267 display_error_styled(&err, true, &quiet()).unwrap();
268 }
269
270 #[test]
271 fn test_display_error_styled_tty_config() {
272 use scope::error::ConfigError;
273 let err = ScopeError::Config(ConfigError::NotFound {
274 path: std::path::PathBuf::from("/missing"),
275 });
276 display_error_styled(&err, true, &quiet()).unwrap();
277 }
278
279 #[test]
280 fn test_display_error_styled_tty_network() {
281 let err = ScopeError::Network("timeout".into());
282 display_error_styled(&err, true, &quiet()).unwrap();
283 }
284
285 #[test]
286 fn test_tui_network_error_gives_tui_next_steps_not_dash_v() {
287 let (o, cap) = Output::capture_merged();
288 display_error_styled(&ScopeError::Network("timeout".into()), false, &o).unwrap();
289 let text = cap.err();
290 assert!(!text.contains("-v"), "{text}");
291 assert!(
292 text.contains("run 'help <command>' for usage; set 'format json' for raw output"),
293 "{text}"
294 );
295 }
296
297 #[test]
298 fn test_cli_network_error_keeps_dash_v_suggestion() {
299 let (o, cap) = Output::capture();
300 display_error_styled(&ScopeError::Network("timeout".into()), false, &o).unwrap();
301 assert!(
302 cap.err().contains("Use -v for more details"),
303 "{}",
304 cap.err()
305 );
306 }
307
308 #[test]
309 fn test_venue_error_displays_with_the_venue_name() {
310 let (o, cap) = Output::capture();
311 let e = ScopeError::Venue {
312 venue: "binance".into(),
313 message: "API error: HTTP 400".into(),
314 };
315 display_error_styled(&e, false, &o).unwrap();
316 assert!(
317 cap.err().contains("Venue error (binance):"),
318 "{}",
319 cap.err()
320 );
321 }
322
323 #[test]
324 fn test_display_error_styled_tty_api_auth() {
325 let err = ScopeError::Api("401 Unauthorized".into());
326 display_error_styled(&err, true, &quiet()).unwrap();
327 }
328
329 #[test]
330 fn test_display_error_styled_tty_other_no_hint() {
331 let err = ScopeError::Other("random".into());
332 display_error_styled(&err, true, &quiet()).unwrap();
333 }
334
335 #[test]
336 fn test_display_error_styled_non_tty() {
337 let err = ScopeError::NotFound("test".into());
338 display_error_styled(&err, false, &quiet()).unwrap();
339 }
340
341 #[test]
346 fn test_error_suggestion_invalid_address() {
347 let err = ScopeError::InvalidAddress("bad".into());
348 let hint = error_suggestion(&err, false);
349 assert!(hint.is_some());
350 assert!(hint.unwrap().contains("EVM"));
351 }
352
353 #[test]
354 fn test_error_suggestion_invalid_hash() {
355 let err = ScopeError::InvalidHash("bad".into());
356 let hint = error_suggestion(&err, false);
357 assert!(hint.is_some());
358 assert!(hint.unwrap().contains("64 hex"));
359 }
360
361 #[test]
362 fn test_error_suggestion_config() {
363 use scope::error::ConfigError;
364 let err = ScopeError::Config(ConfigError::NotFound {
365 path: std::path::PathBuf::from("/missing"),
366 });
367 let hint = error_suggestion(&err, false);
368 assert!(hint.is_some());
369 assert!(hint.unwrap().contains("scope setup"));
370 }
371
372 #[test]
373 fn test_error_suggestion_network() {
374 let err = ScopeError::Network("timeout".into());
375 let hint = error_suggestion(&err, false);
376 assert!(hint.is_some());
377 assert!(hint.unwrap().contains("network"));
378 }
379
380 #[test]
381 fn test_error_suggestion_api_auth() {
382 let err = ScopeError::Api("401 Unauthorized".into());
383 let hint = error_suggestion(&err, false);
384 assert!(hint.is_some());
385 assert!(hint.unwrap().contains("API key"));
386 }
387
388 #[test]
389 fn test_error_suggestion_api_key_keyword() {
390 let err = ScopeError::Api("invalid api key".into());
391 let hint = error_suggestion(&err, false);
392 assert!(hint.is_some());
393 }
394
395 #[test]
396 fn test_error_suggestion_api_no_auth() {
397 let err = ScopeError::Api("500 Internal Server Error".into());
398 assert!(error_suggestion(&err, false).is_none());
399 }
400
401 #[test]
402 fn test_error_suggestion_not_found() {
403 let err = ScopeError::NotFound("address".into());
404 let hint = error_suggestion(&err, false);
405 assert!(hint.is_some());
406 assert!(hint.unwrap().contains("not found"));
407 }
408
409 #[test]
410 fn test_error_suggestion_other_returns_none() {
411 let err = ScopeError::Other("random".into());
412 assert!(error_suggestion(&err, false).is_none());
413 }
414
415 #[tokio::test]
416 async fn test_request_error_cause_chain_is_also_shown_on_a_color_terminal() {
417 let e = plaintext_reply_error().await;
419 let (o, cap) = crate::cli::output::Output::capture();
420 display_error_styled(&e, true, &o).unwrap();
421 let text = cap.err();
422 assert!(text.contains("caused by:"), "{}", text);
423 assert!(text.contains('\x1b'), "tty output is styled: {}", text);
424 }
425}