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)
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
58/// Shown when a TLS handshake got a non-TLS reply.
59const 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
63/// The messages of `e`'s source chain, without links whose text is already
64/// in `shown` (the top-level message usually repeats the first source).
65fn 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
78/// True when a cause says the peer answered a TLS handshake with non-TLS
79/// data (rustls: InvalidContentType; OpenSSL: wrong version number).
80fn is_non_tls_reply(cause: &str) -> bool {
81    cause.contains("InvalidContentType") || cause.contains("wrong version number")
82}
83
84/// Where to learn more about a partial failure, such as a section that
85/// could not be fetched. In the TUI (`tui`), where `-v` has no effect, it
86/// names the TUI's help.
87pub 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
95/// Next steps for a network failure inside the TUI, where `-v` has no effect.
96const 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
99/// Returns a user-facing suggestion for common error types. In the TUI
100/// (`tui`) the suggestions name TUI commands, not CLI flags.
101pub 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    /// Reproduces a router block page: a TCP server on 127.0.0.1 that answers
139    /// the TLS ClientHello with plaintext HTTP (what ASUS AiProtection did to
140    /// api.dexscreener.com). Returns the error reqwest gives for it.
141    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        // "error sending request" alone hid the real cause for an hour.
163        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        // A plaintext reply to a TLS handshake means something on the
178        // network path answered instead of the server.
179        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    // ================================================================
199    // display_error (delegates to non-TTY in CI)
200    // ================================================================
201
202    #[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        // Piped stdout must stay clean on failure, and the user still needs
212        // the message and the remediation hint.
213        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    // ================================================================
255    // display_error_styled — TTY branch (colored output)
256    // ================================================================
257
258    #[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    // ================================================================
342    // error_suggestion
343    // ================================================================
344
345    #[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        // Color must not hide the real cause behind "error sending request".
418        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}