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.to_string())
48 } else if let Some(h) = override_hint(e, &scope::market::VenueRegistry::load) {
49 Some(h)
50 } else {
51 error_suggestion(e, out.is_tui()).map(str::to_string)
52 };
53 if let Some(hint) = hint {
54 errln!(out, "\n {}", painter.paint(Role::Secondary, &hint))?;
55 }
56 errln!(out)?;
57 Ok(())
58}
59
60fn override_hint(
64 e: &ScopeError,
65 load: &dyn Fn() -> scope::error::Result<scope::market::VenueRegistry>,
66) -> Option<String> {
67 let ScopeError::Venue { venue, message } = e else {
68 return None;
69 };
70 let missing = message.strip_prefix("does not support ")?;
71 let capability = match missing {
72 "order book" => "order_book",
73 "OHLC" => "ohlc",
74 other => other,
75 };
76 let registry = load().ok()?;
77 registry
78 .hidden_capabilities(venue)
79 .contains(&capability)
80 .then(|| registry.override_hint(venue))
81 .flatten()
82}
83
84const INTERCEPTION_HINT: &str = "The server's reply was not TLS. A router, firewall, proxy or VPN on your\n \
86 network may be blocking this host and answering with its own block page.\n \
87 Allow the host in that device, or try another network.";
88
89fn cause_chain(e: &ScopeError, shown: &str) -> Vec<String> {
92 let mut out: Vec<String> = Vec::new();
93 let mut src = std::error::Error::source(e);
94 while let Some(s) = src {
95 let text = s.to_string();
96 if !shown.contains(&text) && !out.contains(&text) {
97 out.push(text);
98 }
99 src = s.source();
100 }
101 out
102}
103
104fn is_non_tls_reply(cause: &str) -> bool {
107 cause.contains("InvalidContentType") || cause.contains("wrong version number")
108}
109
110pub fn details_hint(tui: bool) -> &'static str {
114 if tui {
115 "run 'help <command>' for usage"
116 } else {
117 "use -v for details"
118 }
119}
120
121const TUI_NETWORK_HINT: &str = "Check your network connection and try again.\n \
123 run 'help <command>' for usage; set 'format json' for raw output";
124
125pub fn error_suggestion(e: &ScopeError, tui: bool) -> Option<&'static str> {
128 match e {
129 ScopeError::InvalidAddress(_) => Some(
130 "Ensure the address format matches the target chain.\n \
131 EVM: 0x followed by 40 hex characters\n \
132 Solana: base58 encoded public key\n \
133 Tron: T followed by base58 characters",
134 ),
135 ScopeError::InvalidHash(_) => Some(
136 "Ensure the transaction hash matches the target chain.\n \
137 EVM: 0x followed by 64 hex characters\n \
138 Solana: base58 encoded signature",
139 ),
140 ScopeError::Config(_) => Some("Run `scope setup` to create or repair your configuration."),
141 ScopeError::Request(_) | ScopeError::Network(_) if tui => Some(TUI_NETWORK_HINT),
142 ScopeError::Request(_) | ScopeError::Network(_) => Some(
143 "Check your network connection and try again.\n \
144 Use -v for more details on the failing request.",
145 ),
146 ScopeError::Api(msg)
147 if msg.contains("401") || msg.contains("403") || msg.contains("key") =>
148 {
149 Some(
150 "Your API key may be missing or invalid.\n Run `scope setup --key <provider>` to configure it.",
151 )
152 }
153 ScopeError::NotFound(_) => Some(
154 "The resource was not found. Verify the address, hash, or token exists on the specified chain.",
155 ),
156 _ => None,
157 }
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 async fn plaintext_reply_error() -> ScopeError {
168 use std::io::{Read, Write};
169 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
170 let port = listener.local_addr().unwrap().port();
171 std::thread::spawn(move || {
172 if let Ok((mut s, _)) = listener.accept() {
173 let mut buf = [0u8; 1024];
174 let _ = s.read(&mut buf);
175 let _ = s.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n");
176 }
177 });
178 let err = reqwest::Client::new()
179 .get(format!("https://127.0.0.1:{port}/"))
180 .send()
181 .await
182 .unwrap_err();
183 ScopeError::Request(err)
184 }
185
186 #[tokio::test]
187 async fn test_request_error_shows_its_cause_chain() {
188 let e = plaintext_reply_error().await;
190 let (o, cap) = crate::cli::output::Output::capture();
191 display_error_styled(&e, false, &o).unwrap();
192 let text = cap.err();
193 assert!(text.contains("caused by:"), "{}", text);
194 assert!(
195 text.contains("InvalidContentType") || text.contains("corrupt message"),
196 "{}",
197 text
198 );
199 }
200
201 #[tokio::test]
202 async fn test_non_tls_reply_gets_an_interception_hint() {
203 let e = plaintext_reply_error().await;
206 let (o, cap) = crate::cli::output::Output::capture();
207 display_error_styled(&e, false, &o).unwrap();
208 let text = cap.err();
209 assert!(text.contains("router, firewall, proxy or VPN"), "{}", text);
210 assert!(!text.contains("Check your network connection"), "{}", text);
211 }
212
213 #[test]
214 fn test_error_without_source_prints_no_cause_lines() {
215 let (o, cap) = crate::cli::output::Output::capture();
216 display_error_styled(&ScopeError::Other("plain".into()), false, &o).unwrap();
217 assert!(!cap.err().contains("caused by"));
218 }
219
220 fn quiet() -> crate::cli::output::Output {
221 crate::cli::output::Output::capture().0
222 }
223
224 #[test]
229 fn test_details_hint_names_no_cli_flag_in_the_tui() {
230 assert_eq!(details_hint(false), "use -v for details");
231 assert_eq!(details_hint(true), "run 'help <command>' for usage");
232 assert!(!details_hint(true).contains("-v"));
233 }
234
235 #[test]
236 fn test_display_error_goes_to_err_channel_with_hint() {
237 let (o, cap) = crate::cli::output::Output::capture();
240 display_error_styled(&ScopeError::InvalidAddress("0xbad".into()), false, &o).unwrap();
241 assert_eq!(cap.out(), "");
242 assert!(
243 cap.err().contains("✗ Invalid address format: 0xbad"),
244 "{}",
245 cap.err()
246 );
247 assert!(cap.err().contains("EVM: 0x followed by 40 hex characters"));
248 }
249
250 #[test]
251 fn test_display_error_not_found() {
252 let err = ScopeError::NotFound("test resource".into());
253 display_error(&err, &quiet()).unwrap();
254 }
255
256 #[test]
257 fn test_display_error_invalid_address() {
258 let err = ScopeError::InvalidAddress("0xbad".into());
259 display_error(&err, &quiet()).unwrap();
260 }
261
262 #[test]
263 fn test_display_error_other() {
264 let err = ScopeError::Other("something went wrong".into());
265 display_error(&err, &quiet()).unwrap();
266 }
267
268 #[test]
269 fn test_display_error_chain() {
270 let err = ScopeError::Chain("chain error".into());
271 display_error(&err, &quiet()).unwrap();
272 }
273
274 #[test]
275 fn test_display_error_api() {
276 let err = ScopeError::Api("500 Internal Server Error".into());
277 display_error(&err, &quiet()).unwrap();
278 }
279
280 #[test]
285 fn test_display_error_styled_tty_not_found() {
286 let err = ScopeError::NotFound("test resource".into());
287 display_error_styled(&err, true, &quiet()).unwrap();
288 }
289
290 #[test]
291 fn test_display_error_styled_tty_invalid_address() {
292 let err = ScopeError::InvalidAddress("0xbad".into());
293 display_error_styled(&err, true, &quiet()).unwrap();
294 }
295
296 #[test]
297 fn test_display_error_styled_tty_config() {
298 use scope::error::ConfigError;
299 let err = ScopeError::Config(ConfigError::NotFound {
300 path: std::path::PathBuf::from("/missing"),
301 });
302 display_error_styled(&err, true, &quiet()).unwrap();
303 }
304
305 #[test]
306 fn test_display_error_styled_tty_network() {
307 let err = ScopeError::Network("timeout".into());
308 display_error_styled(&err, true, &quiet()).unwrap();
309 }
310
311 #[test]
312 fn test_tui_network_error_gives_tui_next_steps_not_dash_v() {
313 let (o, cap) = Output::capture_merged();
314 display_error_styled(&ScopeError::Network("timeout".into()), false, &o).unwrap();
315 let text = cap.err();
316 assert!(!text.contains("-v"), "{text}");
317 assert!(
318 text.contains("run 'help <command>' for usage; set 'format json' for raw output"),
319 "{text}"
320 );
321 }
322
323 #[test]
324 fn test_cli_network_error_keeps_dash_v_suggestion() {
325 let (o, cap) = Output::capture();
326 display_error_styled(&ScopeError::Network("timeout".into()), false, &o).unwrap();
327 assert!(
328 cap.err().contains("Use -v for more details"),
329 "{}",
330 cap.err()
331 );
332 }
333
334 #[test]
335 fn test_venue_error_displays_with_the_venue_name() {
336 let (o, cap) = Output::capture();
337 let e = ScopeError::Venue {
338 venue: "binance".into(),
339 message: "API error: HTTP 400".into(),
340 };
341 display_error_styled(&e, false, &o).unwrap();
342 assert!(
343 cap.err().contains("Venue error (binance):"),
344 "{}",
345 cap.err()
346 );
347 }
348
349 #[test]
350 fn test_display_error_styled_tty_api_auth() {
351 let err = ScopeError::Api("401 Unauthorized".into());
352 display_error_styled(&err, true, &quiet()).unwrap();
353 }
354
355 #[test]
356 fn test_display_error_styled_tty_other_no_hint() {
357 let err = ScopeError::Other("random".into());
358 display_error_styled(&err, true, &quiet()).unwrap();
359 }
360
361 #[test]
362 fn test_display_error_styled_non_tty() {
363 let err = ScopeError::NotFound("test".into());
364 display_error_styled(&err, false, &quiet()).unwrap();
365 }
366
367 #[test]
372 fn test_error_suggestion_invalid_address() {
373 let err = ScopeError::InvalidAddress("bad".into());
374 let hint = error_suggestion(&err, false);
375 assert!(hint.is_some());
376 assert!(hint.unwrap().contains("EVM"));
377 }
378
379 #[test]
380 fn test_error_suggestion_invalid_hash() {
381 let err = ScopeError::InvalidHash("bad".into());
382 let hint = error_suggestion(&err, false);
383 assert!(hint.is_some());
384 assert!(hint.unwrap().contains("64 hex"));
385 }
386
387 #[test]
388 fn test_error_suggestion_config() {
389 use scope::error::ConfigError;
390 let err = ScopeError::Config(ConfigError::NotFound {
391 path: std::path::PathBuf::from("/missing"),
392 });
393 let hint = error_suggestion(&err, false);
394 assert!(hint.is_some());
395 assert!(hint.unwrap().contains("scope setup"));
396 }
397
398 #[test]
399 fn test_error_suggestion_network() {
400 let err = ScopeError::Network("timeout".into());
401 let hint = error_suggestion(&err, false);
402 assert!(hint.is_some());
403 assert!(hint.unwrap().contains("network"));
404 }
405
406 #[test]
407 fn test_error_suggestion_api_auth() {
408 let err = ScopeError::Api("401 Unauthorized".into());
409 let hint = error_suggestion(&err, false);
410 assert!(hint.is_some());
411 assert!(hint.unwrap().contains("API key"));
412 }
413
414 #[test]
415 fn test_error_suggestion_api_key_keyword() {
416 let err = ScopeError::Api("invalid api key".into());
417 let hint = error_suggestion(&err, false);
418 assert!(hint.is_some());
419 }
420
421 #[test]
422 fn test_error_suggestion_api_no_auth() {
423 let err = ScopeError::Api("500 Internal Server Error".into());
424 assert!(error_suggestion(&err, false).is_none());
425 }
426
427 #[test]
428 fn test_error_suggestion_not_found() {
429 let err = ScopeError::NotFound("address".into());
430 let hint = error_suggestion(&err, false);
431 assert!(hint.is_some());
432 assert!(hint.unwrap().contains("not found"));
433 }
434
435 #[test]
436 fn test_error_suggestion_other_returns_none() {
437 let err = ScopeError::Other("random".into());
438 assert!(error_suggestion(&err, false).is_none());
439 }
440
441 #[tokio::test]
442 async fn test_request_error_cause_chain_is_also_shown_on_a_color_terminal() {
443 let e = plaintext_reply_error().await;
445 let (o, cap) = crate::cli::output::Output::capture();
446 display_error_styled(&e, true, &o).unwrap();
447 let text = cap.err();
448 assert!(text.contains("caused by:"), "{}", text);
449 assert!(text.contains('\x1b'), "tty output is styled: {}", text);
450 }
451
452 #[test]
453 fn test_a_missing_capability_your_file_hides_gets_the_override_hint() {
454 let catalog = tempfile::tempdir().unwrap();
455 let user = tempfile::tempdir().unwrap();
456 let weex = "id: weex\nname: W\nbase_url: https://w\nsymbol:\n template: \"{base}{quote}\"\n default_quote: USDT\ncapabilities:\n trades:\n path: /t\n response:\n price: p\n quantity: q\n";
457 let with_ohlc = format!(
458 "{weex} ohlc:\n path: /k\n response:\n ohlc_format: array_of_arrays\n"
459 );
460 std::fs::write(catalog.path().join("weex.yaml"), with_ohlc).unwrap();
461 std::fs::write(user.path().join("weex.yaml"), weex).unwrap();
462 let (c, u) = (catalog.path().to_path_buf(), user.path().to_path_buf());
463 let load = move || scope::market::VenueRegistry::load_dirs(&c, &u);
464 let e = ScopeError::Venue {
465 venue: "weex".into(),
466 message: "does not support OHLC".into(),
467 };
468 let hint = override_hint(&e, &load).unwrap();
469 assert!(
470 hint.contains("overrides the catalog's weex, which also has ohlc"),
471 "{hint}"
472 );
473 let ticker = ScopeError::Venue {
475 venue: "weex".into(),
476 message: "does not support ticker".into(),
477 };
478 assert!(override_hint(&ticker, &load).is_none());
479 assert!(override_hint(&ScopeError::Other("x".into()), &load).is_none());
480 let (out, cap) = Output::capture();
482 let shown = override_hint(&e, &load);
483 assert!(shown.is_some());
484 display_error_styled(&e, false, &out).unwrap();
485 assert!(cap.err().contains("does not support OHLC"), "{}", cap.err());
486 }
487}