Skip to main content

qn/
errors.rs

1//! Top-level error type for the CLI and the SDK→user message mapping.
2
3use std::path::PathBuf;
4
5use quicknode_sdk::errors::{HttpKind, SdkError};
6use thiserror::Error;
7
8#[derive(Debug, Error)]
9pub enum CliError {
10    #[error("no API key found. Set QN_CLI__API_KEY or run 'qn auth login'")]
11    NoApiKey,
12
13    #[error("config file at {path} is invalid: {source}")]
14    BadConfig {
15        path: PathBuf,
16        #[source]
17        source: toml::de::Error,
18    },
19
20    #[error("could not write config file at {path}: {source}")]
21    ConfigWrite {
22        path: PathBuf,
23        #[source]
24        source: std::io::Error,
25    },
26
27    #[error("invalid argument: {0}")]
28    Arg(String),
29
30    #[error("operation cancelled")]
31    Cancelled,
32
33    #[error(
34        "operation requires confirmation; pass --yes to proceed without an interactive prompt"
35    )]
36    NeedsConfirmation,
37
38    #[error(transparent)]
39    Sdk(#[from] SdkError),
40
41    #[error(transparent)]
42    Io(#[from] std::io::Error),
43
44    #[error(transparent)]
45    Json(#[from] serde_json::Error),
46
47    #[error("could not serialize output: {0}")]
48    Format(String),
49}
50
51/// Maps a [`CliError`] to a process exit code per the plan.
52///
53/// - 0: success (never produced here)
54/// - 1: generic CLI failure (arg parse, IO, decode)
55/// - 2: SdkError::Api (server returned a non-2xx)
56/// - 3: SdkError::Http (network failure)
57/// - 4: NoApiKey / BadConfig
58/// - 5: user cancelled or needs --yes
59pub fn exit_code_for(err: &CliError) -> i32 {
60    match err {
61        CliError::NoApiKey | CliError::BadConfig { .. } | CliError::ConfigWrite { .. } => 4,
62        CliError::Cancelled | CliError::NeedsConfirmation => 5,
63        CliError::Sdk(sdk) => match sdk {
64            SdkError::Api { .. } => 2,
65            SdkError::Http(_) => 3,
66            _ => 1,
67        },
68        _ => 1,
69    }
70}
71
72/// Renders the error to a human-friendly single line (or two for multi-line bodies).
73///
74/// Verbose mode appends the underlying body / source where available.
75pub fn render(err: &CliError, verbose: bool) -> String {
76    match err {
77        CliError::Sdk(SdkError::Api { status, body }) => {
78            let code = status.as_u16();
79            let base = match code {
80                401 | 403 => "unauthorized. Check your API key with 'qn auth whoami'.".to_string(),
81                404 => "not found.".to_string(),
82                422 => "the API rejected the request as invalid.".to_string(),
83                429 => "rate limited by the Quicknode API. Try again shortly.".to_string(),
84                500..=599 => format!(
85                    "Quicknode API is having issues (HTTP {code}). Try again or check status.quicknode.com."
86                ),
87                _ => format!("API returned HTTP {code}."),
88            };
89            if verbose && !body.is_empty() {
90                format!("Error: {base}\n{body}")
91            } else {
92                format!("Error: {base}")
93            }
94        }
95        CliError::Sdk(sdk @ SdkError::Http(_)) => {
96            let msg = match sdk.http_kind() {
97                Some(HttpKind::Timeout) => {
98                    "request timed out. Check your connection and try again."
99                }
100                Some(HttpKind::Connect) => {
101                    "could not connect to api.quicknode.com. Check your network."
102                }
103                _ => "HTTP transport failure talking to the Quicknode API.",
104            };
105            if verbose {
106                format!("Error: {msg}\n{sdk}")
107            } else {
108                format!("Error: {msg}")
109            }
110        }
111        CliError::Sdk(SdkError::Decode { body, .. }) => {
112            if verbose {
113                format!("Error: unexpected response shape from API.\n{body}")
114            } else {
115                "Error: unexpected response shape from API. Re-run with --verbose to see the body."
116                    .to_string()
117            }
118        }
119        CliError::BadConfig { path, source } => {
120            if verbose {
121                format!(
122                    "Error: config file at {} is invalid: {source}",
123                    path.display()
124                )
125            } else {
126                format!(
127                    "Error: config file at {} is invalid. Re-run with --verbose for details.",
128                    path.display()
129                )
130            }
131        }
132        other => format!("Error: {other}"),
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use quicknode_sdk::errors::SdkError;
140
141    fn api_err(code: u16) -> CliError {
142        CliError::Sdk(SdkError::Api {
143            status: reqwest::StatusCode::from_u16(code).unwrap(),
144            body: "{\"message\":\"boom\"}".to_string(),
145        })
146    }
147
148    #[test]
149    fn exit_code_api_is_2() {
150        assert_eq!(exit_code_for(&api_err(404)), 2);
151    }
152
153    #[test]
154    fn exit_code_no_api_key_is_4() {
155        assert_eq!(exit_code_for(&CliError::NoApiKey), 4);
156    }
157
158    #[test]
159    fn exit_code_cancelled_is_5() {
160        assert_eq!(exit_code_for(&CliError::Cancelled), 5);
161    }
162
163    #[test]
164    fn renders_401_as_unauthorized() {
165        let msg = render(&api_err(401), false);
166        assert!(msg.contains("unauthorized"), "got: {msg}");
167    }
168
169    #[test]
170    fn renders_429_as_rate_limited() {
171        let msg = render(&api_err(429), false);
172        assert!(msg.contains("rate limited"), "got: {msg}");
173    }
174
175    #[test]
176    fn renders_5xx_with_status() {
177        let msg = render(&api_err(503), false);
178        assert!(msg.contains("503"), "got: {msg}");
179    }
180
181    #[test]
182    fn verbose_404_includes_body() {
183        let msg = render(&api_err(404), true);
184        assert!(msg.contains("boom"), "got: {msg}");
185    }
186
187    #[test]
188    fn non_verbose_404_omits_body() {
189        let msg = render(&api_err(404), false);
190        assert!(!msg.contains("boom"), "got: {msg}");
191    }
192}