1use 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
51pub 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
72pub 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}