Skip to main content

scope/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::error::ScopeError;
7use owo_colors::OwoColorize;
8use std::io::IsTerminal;
9
10/// Returns `true` when stderr is an interactive terminal.
11fn is_tty_stderr() -> bool {
12    std::io::stderr().is_terminal()
13}
14
15/// Displays an error with a remediation hint when available.
16///
17/// Uses color when stderr is a TTY, plain text otherwise.
18pub fn display_error(e: &ScopeError) {
19    display_error_styled(e, is_tty_stderr())
20}
21
22/// Internal styled implementation, testable with an explicit `tty` flag.
23fn display_error_styled(e: &ScopeError, tty: bool) {
24    let msg = match e {
25        ScopeError::NotFound(inner) => inner.clone(),
26        other => format!("{}", other),
27    };
28
29    if tty {
30        eprintln!("\n  {} {}", "✗".red().bold(), msg.red());
31    } else {
32        eprintln!("\n  ✗ {}", msg);
33    }
34
35    if let Some(hint) = error_suggestion(e) {
36        if tty {
37            eprintln!("\n  {}", hint.dimmed());
38        } else {
39            eprintln!("\n  {}", hint);
40        }
41    }
42    eprintln!();
43}
44
45/// Returns a user-facing suggestion for common error types.
46pub fn error_suggestion(e: &ScopeError) -> Option<&'static str> {
47    match e {
48        ScopeError::InvalidAddress(_) => Some(
49            "Ensure the address format matches the target chain.\n      \
50             EVM: 0x followed by 40 hex characters\n      \
51             Solana: base58 encoded public key\n      \
52             Tron: T followed by base58 characters",
53        ),
54        ScopeError::InvalidHash(_) => Some(
55            "Ensure the transaction hash matches the target chain.\n      \
56             EVM: 0x followed by 64 hex characters\n      \
57             Solana: base58 encoded signature",
58        ),
59        ScopeError::Config(_) => Some("Run `scope setup` to create or repair your configuration."),
60        ScopeError::Request(_) | ScopeError::Network(_) => Some(
61            "Check your network connection and try again.\n      \
62             Use -v for more details on the failing request.",
63        ),
64        ScopeError::Api(msg)
65            if msg.contains("401") || msg.contains("403") || msg.contains("key") =>
66        {
67            Some(
68                "Your API key may be missing or invalid.\n      Run `scope setup --key <provider>` to configure it.",
69            )
70        }
71        ScopeError::NotFound(_) => Some(
72            "The resource was not found. Verify the address, hash, or token exists on the specified chain.",
73        ),
74        _ => None,
75    }
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81
82    // ================================================================
83    // display_error (delegates to non-TTY in CI)
84    // ================================================================
85
86    #[test]
87    fn test_display_error_not_found() {
88        let err = ScopeError::NotFound("test resource".into());
89        display_error(&err);
90    }
91
92    #[test]
93    fn test_display_error_invalid_address() {
94        let err = ScopeError::InvalidAddress("0xbad".into());
95        display_error(&err);
96    }
97
98    #[test]
99    fn test_display_error_other() {
100        let err = ScopeError::Other("something went wrong".into());
101        display_error(&err);
102    }
103
104    #[test]
105    fn test_display_error_chain() {
106        let err = ScopeError::Chain("chain error".into());
107        display_error(&err);
108    }
109
110    #[test]
111    fn test_display_error_api() {
112        let err = ScopeError::Api("500 Internal Server Error".into());
113        display_error(&err);
114    }
115
116    // ================================================================
117    // display_error_styled — TTY branch (colored output)
118    // ================================================================
119
120    #[test]
121    fn test_display_error_styled_tty_not_found() {
122        let err = ScopeError::NotFound("test resource".into());
123        display_error_styled(&err, true);
124    }
125
126    #[test]
127    fn test_display_error_styled_tty_invalid_address() {
128        let err = ScopeError::InvalidAddress("0xbad".into());
129        display_error_styled(&err, true);
130    }
131
132    #[test]
133    fn test_display_error_styled_tty_config() {
134        use crate::error::ConfigError;
135        let err = ScopeError::Config(ConfigError::NotFound {
136            path: std::path::PathBuf::from("/missing"),
137        });
138        display_error_styled(&err, true);
139    }
140
141    #[test]
142    fn test_display_error_styled_tty_network() {
143        let err = ScopeError::Network("timeout".into());
144        display_error_styled(&err, true);
145    }
146
147    #[test]
148    fn test_display_error_styled_tty_api_auth() {
149        let err = ScopeError::Api("401 Unauthorized".into());
150        display_error_styled(&err, true);
151    }
152
153    #[test]
154    fn test_display_error_styled_tty_other_no_hint() {
155        let err = ScopeError::Other("random".into());
156        display_error_styled(&err, true);
157    }
158
159    #[test]
160    fn test_display_error_styled_non_tty() {
161        let err = ScopeError::NotFound("test".into());
162        display_error_styled(&err, false);
163    }
164
165    // ================================================================
166    // error_suggestion
167    // ================================================================
168
169    #[test]
170    fn test_error_suggestion_invalid_address() {
171        let err = ScopeError::InvalidAddress("bad".into());
172        let hint = error_suggestion(&err);
173        assert!(hint.is_some());
174        assert!(hint.unwrap().contains("EVM"));
175    }
176
177    #[test]
178    fn test_error_suggestion_invalid_hash() {
179        let err = ScopeError::InvalidHash("bad".into());
180        let hint = error_suggestion(&err);
181        assert!(hint.is_some());
182        assert!(hint.unwrap().contains("64 hex"));
183    }
184
185    #[test]
186    fn test_error_suggestion_config() {
187        use crate::error::ConfigError;
188        let err = ScopeError::Config(ConfigError::NotFound {
189            path: std::path::PathBuf::from("/missing"),
190        });
191        let hint = error_suggestion(&err);
192        assert!(hint.is_some());
193        assert!(hint.unwrap().contains("scope setup"));
194    }
195
196    #[test]
197    fn test_error_suggestion_network() {
198        let err = ScopeError::Network("timeout".into());
199        let hint = error_suggestion(&err);
200        assert!(hint.is_some());
201        assert!(hint.unwrap().contains("network"));
202    }
203
204    #[test]
205    fn test_error_suggestion_api_auth() {
206        let err = ScopeError::Api("401 Unauthorized".into());
207        let hint = error_suggestion(&err);
208        assert!(hint.is_some());
209        assert!(hint.unwrap().contains("API key"));
210    }
211
212    #[test]
213    fn test_error_suggestion_api_key_keyword() {
214        let err = ScopeError::Api("invalid api key".into());
215        let hint = error_suggestion(&err);
216        assert!(hint.is_some());
217    }
218
219    #[test]
220    fn test_error_suggestion_api_no_auth() {
221        let err = ScopeError::Api("500 Internal Server Error".into());
222        assert!(error_suggestion(&err).is_none());
223    }
224
225    #[test]
226    fn test_error_suggestion_not_found() {
227        let err = ScopeError::NotFound("address".into());
228        let hint = error_suggestion(&err);
229        assert!(hint.is_some());
230        assert!(hint.unwrap().contains("not found"));
231    }
232
233    #[test]
234    fn test_error_suggestion_other_returns_none() {
235        let err = ScopeError::Other("random".into());
236        assert!(error_suggestion(&err).is_none());
237    }
238}