Skip to main content

scope_cli/cli/
errors.rs

1//! Error display and remediation hints for CLI output.
2//!
3//! Colors errors red and hints dimmed when stderr is a TTY.
4//! Falls back to plain text when piped.
5
6use crate::cli::output::Output;
7use crate::errln;
8use scope::display::theme::{Painter, Role, color_enabled_stderr};
9use scope::error::ScopeError;
10
11/// Displays an error with a remediation hint when available.
12///
13/// Uses color when stderr is a TTY, plain text otherwise.
14pub fn display_error(e: &ScopeError, out: &Output) -> std::io::Result<()> {
15    display_error_styled(e, color_enabled_stderr(), out)
16}
17
18/// Internal styled implementation, testable with an explicit `tty` flag.
19fn 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    // A disabled painter returns the text unchanged.
26    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    // The source chain names the real cause (for example a TLS failure
35    // under "error sending request"). Show each link once.
36    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
60/// For a venue's "does not support …" error, how to get the capability
61/// back when the user's own file hides the catalog's: `None` otherwise.
62/// `load` reads the venues (the registry, or a test's).
63fn 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
84/// Shown when a TLS handshake got a non-TLS reply.
85const 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
89/// The messages of `e`'s source chain, without links whose text is already
90/// in `shown` (the top-level message usually repeats the first source).
91fn 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
104/// True when a cause says the peer answered a TLS handshake with non-TLS
105/// data (rustls: InvalidContentType; OpenSSL: wrong version number).
106fn is_non_tls_reply(cause: &str) -> bool {
107    cause.contains("InvalidContentType") || cause.contains("wrong version number")
108}
109
110/// Where to learn more about a partial failure, such as a section that
111/// could not be fetched. In the TUI (`tui`), where `-v` has no effect, it
112/// names the TUI's help.
113pub 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
121/// Next steps for a network failure inside the TUI, where `-v` has no effect.
122const 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
125/// Returns a user-facing suggestion for common error types. In the TUI
126/// (`tui`) the suggestions name TUI commands, not CLI flags.
127pub 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    /// Reproduces a router block page: a TCP server on 127.0.0.1 that answers
165    /// the TLS ClientHello with plaintext HTTP (what ASUS AiProtection did to
166    /// api.dexscreener.com). Returns the error reqwest gives for it.
167    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        // "error sending request" alone hid the real cause for an hour.
189        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        // A plaintext reply to a TLS handshake means something on the
204        // network path answered instead of the server.
205        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    // ================================================================
225    // display_error (delegates to non-TTY in CI)
226    // ================================================================
227
228    #[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        // Piped stdout must stay clean on failure, and the user still needs
238        // the message and the remediation hint.
239        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    // ================================================================
281    // display_error_styled — TTY branch (colored output)
282    // ================================================================
283
284    #[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    // ================================================================
368    // error_suggestion
369    // ================================================================
370
371    #[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        // Color must not hide the real cause behind "error sending request".
444        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        // Another missing capability the catalog lacks too, and other errors.
474        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        // The hint shows under the error.
481        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}