1use std::collections::BTreeSet;
4use std::path::PathBuf;
5
6use quicknode_sdk::errors::{HttpKind, SdkError};
7use serde_json::Value;
8use thiserror::Error;
9
10#[derive(Debug, Error)]
11pub enum CliError {
12 #[error("no API key found. Run 'qn auth login', or pass --api-key or --config-file")]
13 NoApiKey,
14
15 #[error("config file at {path} is invalid: {source}")]
16 BadConfig {
17 path: PathBuf,
18 #[source]
19 source: toml::de::Error,
20 },
21
22 #[error("could not write config file at {path}: {source}")]
23 ConfigWrite {
24 path: PathBuf,
25 #[source]
26 source: std::io::Error,
27 },
28
29 #[error("invalid argument: {0}")]
30 Arg(String),
31
32 #[error("operation cancelled")]
33 Cancelled,
34
35 #[error(
36 "operation requires confirmation; pass --yes to proceed without an interactive prompt"
37 )]
38 NeedsConfirmation,
39
40 #[error(transparent)]
41 Sdk(#[from] SdkError),
42
43 #[error(transparent)]
44 Io(#[from] std::io::Error),
45
46 #[error(transparent)]
47 Json(#[from] serde_json::Error),
48
49 #[error("could not serialize output: {0}")]
50 Format(String),
51}
52
53pub fn exit_code_for(err: &CliError) -> i32 {
63 match err {
64 CliError::NoApiKey | CliError::BadConfig { .. } | CliError::ConfigWrite { .. } => 4,
65 CliError::Cancelled | CliError::NeedsConfirmation => 5,
66 CliError::Sdk(sdk) => match sdk {
67 SdkError::Api { .. } => 2,
68 SdkError::Http(_) => 3,
69 _ => 1,
70 },
71 _ => 1,
72 }
73}
74
75pub fn render(err: &CliError, verbose: bool) -> String {
81 let argv: Vec<String> = std::env::args().skip(1).collect();
82 render_with_argv(err, verbose, &argv)
83}
84
85pub fn render_with_argv(err: &CliError, verbose: bool, argv: &[String]) -> String {
87 match err {
88 CliError::Sdk(SdkError::Api { status, body }) => {
89 render_api_error(status.as_u16(), body, verbose, argv)
90 }
91 CliError::Sdk(sdk @ SdkError::Http(inner)) => {
92 let host = inner.url().and_then(|u| u.host_str()).map(str::to_string);
97 let target = host
98 .map(|h| format!("'{h}'"))
99 .unwrap_or_else(|| "the Quicknode API".to_string());
100 let msg = match sdk.http_kind() {
101 Some(HttpKind::Timeout) => {
102 format!("request to {target} timed out. Check your connection and try again.")
103 }
104 Some(HttpKind::Connect) => {
105 format!("could not connect to {target}. Check your network.")
106 }
107 _ => format!("HTTP transport failure talking to {target}."),
108 };
109 if verbose {
110 format!("Error: {msg}\n{sdk}")
111 } else {
112 format!("Error: {msg}")
113 }
114 }
115 CliError::Sdk(SdkError::Decode { body, .. }) => {
116 if verbose {
117 format!("Error: unexpected response shape from API.\n{body}")
118 } else {
119 "Error: unexpected response shape from API. Re-run with --verbose to see the body."
120 .to_string()
121 }
122 }
123 CliError::BadConfig { path, source } => {
124 if verbose {
125 format!(
126 "Error: config file at {} is invalid: {source}",
127 path.display()
128 )
129 } else {
130 format!(
131 "Error: config file at {} is invalid. Re-run with --verbose for details.",
132 path.display()
133 )
134 }
135 }
136 other => format!("Error: {other}"),
137 }
138}
139
140fn render_api_error(code: u16, body: &str, verbose: bool, argv: &[String]) -> String {
143 let headline = match code {
144 400 | 422 => "invalid request.".to_string(),
145 401 | 403 => "unauthorized. Check your API key with 'qn auth whoami'.".to_string(),
146 404 => "not found.".to_string(),
147 429 => "rate limited by the Quicknode API. Try again shortly.".to_string(),
148 500..=599 => format!(
149 "something went wrong (HTTP {code}). Please try again; if the problem persists, \
150 contact support at https://support.quicknode.com."
151 ),
152 _ => format!("API returned HTTP {code}."),
153 };
154
155 let parsed = if matches!(code, 400 | 422) {
158 parse_api_body(body, argv)
159 } else {
160 ParsedApiBody::default()
161 };
162
163 let mut out = format!("Error: {headline}");
164
165 if !parsed.bullets.is_empty() {
166 for bullet in &parsed.bullets {
167 out.push_str("\n • ");
168 out.push_str(bullet);
169 }
170 } else if matches!(code, 400 | 422) && !body.is_empty() && !verbose {
171 out.push('\n');
174 out.push_str(body.trim());
175 }
176
177 for hint in &parsed.hints {
178 out.push('\n');
179 out.push_str(hint);
180 }
181
182 if verbose && !body.is_empty() {
183 out.push('\n');
184 out.push_str(body);
185 } else if matches!(code, 400 | 422) && !parsed.bullets.is_empty() && !body.is_empty() {
186 out.push_str("\nRe-run with --verbose for the full response body.");
187 }
188
189 out
190}
191
192#[derive(Default)]
193struct ParsedApiBody {
194 bullets: Vec<String>,
195 hints: Vec<String>,
196}
197
198fn parse_api_body(body: &str, argv: &[String]) -> ParsedApiBody {
202 let mut out = ParsedApiBody::default();
203 if body.is_empty() {
204 return out;
205 }
206 let Ok(value) = serde_json::from_str::<Value>(body) else {
207 return out;
208 };
209
210 let mut raw_strings: Vec<String> = Vec::new();
211 collect_error_strings(&value, &mut raw_strings);
212 if raw_strings.is_empty() {
213 return out;
214 }
215
216 let mut seen: BTreeSet<String> = BTreeSet::new();
217 let mut fields_hinted: BTreeSet<String> = BTreeSet::new();
218
219 for s in raw_strings {
220 let trimmed = s.trim().to_string();
221 if trimmed.is_empty() || !seen.insert(trimmed.clone()) {
222 continue;
223 }
224 if is_generic_label(&trimmed) {
225 continue;
227 }
228 let bullet = decorate_with_suggestion(&trimmed, argv, &mut fields_hinted);
229 out.bullets.push(bullet);
230 }
231
232 for field in &fields_hinted {
233 if let Some(hint) = field_hint(field) {
234 out.hints.push(hint.to_string());
235 }
236 }
237
238 out
239}
240
241fn collect_error_strings(value: &Value, out: &mut Vec<String>) {
245 const KEYS: &[&str] = &["errors", "error", "messages", "message"];
246 match value {
247 Value::Object(map) => {
248 for key in KEYS {
249 if let Some(v) = map.get(*key) {
250 collect_strings_from(v, out);
251 }
252 }
253 for (k, v) in map {
256 if !KEYS.contains(&k.as_str()) {
257 collect_error_strings(v, out);
258 }
259 }
260 }
261 Value::Array(arr) => {
262 for v in arr {
263 collect_error_strings(v, out);
264 }
265 }
266 _ => {}
267 }
268}
269
270fn collect_strings_from(value: &Value, out: &mut Vec<String>) {
272 match value {
273 Value::String(s) => out.push(s.clone()),
274 Value::Array(arr) => {
275 for v in arr {
276 match v {
277 Value::String(s) => out.push(s.clone()),
278 Value::Object(_) => collect_error_strings(v, out),
279 _ => {}
280 }
281 }
282 }
283 Value::Object(_) => collect_error_strings(value, out),
284 _ => {}
285 }
286}
287
288fn decorate_with_suggestion(
292 raw: &str,
293 argv: &[String],
294 fields_hinted: &mut BTreeSet<String>,
295) -> String {
296 let Some((field, candidates)) = parse_must_be_one_of(raw) else {
297 return raw.to_string();
298 };
299
300 fields_hinted.insert(field.clone());
301
302 let best = best_suggestion(argv, &candidates);
305
306 let display = truncate_candidate_list(&candidates, 5);
307 let mut bullet = format!("{field} must be one of: {display}");
308 if let Some((user_value, suggestion)) = best {
309 bullet.push_str(&format!(
310 " — did you mean '{suggestion}' (you passed '{user_value}')?"
311 ));
312 }
313 bullet
314}
315
316fn parse_must_be_one_of(s: &str) -> Option<(String, Vec<String>)> {
319 let (field_part, rest) = s.split_once(" must be one of")?;
320 let field = field_part.trim();
321 if field.is_empty()
322 || !field
323 .chars()
324 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
325 {
326 return None;
327 }
328 let list_part = rest
331 .strip_prefix(" the following values: ")
332 .or_else(|| rest.strip_prefix(": "))
333 .or_else(|| rest.strip_prefix(' '))
334 .unwrap_or(rest)
335 .trim_end_matches('.');
336 let candidates: Vec<String> = list_part
337 .split(", ")
338 .map(|c| c.trim().to_string())
339 .filter(|c| !c.is_empty())
340 .collect();
341 if candidates.len() < 2 {
342 return None;
343 }
344 Some((field.to_string(), candidates))
345}
346
347fn best_suggestion(argv: &[String], candidates: &[String]) -> Option<(String, String)> {
350 let mut best: Option<(usize, String, String)> = None;
351 for arg in argv {
352 if arg.starts_with('-') || arg.is_empty() || arg.len() < 2 {
354 continue;
355 }
356 for cand in candidates {
357 let d = levenshtein(arg, cand);
358 if d > 3 {
359 continue;
360 }
361 if shared_prefix_len(arg, cand) < 3 {
362 continue;
363 }
364 match best.as_ref() {
365 None => best = Some((d, arg.clone(), cand.clone())),
366 Some((cur, _, _)) if d < *cur => best = Some((d, arg.clone(), cand.clone())),
367 _ => {}
368 }
369 }
370 }
371 best.map(|(_, a, c)| (a, c))
372}
373
374fn shared_prefix_len(a: &str, b: &str) -> usize {
375 a.chars().zip(b.chars()).take_while(|(x, y)| x == y).count()
376}
377
378fn levenshtein(a: &str, b: &str) -> usize {
381 let a: Vec<char> = a.chars().collect();
382 let b: Vec<char> = b.chars().collect();
383 if a.is_empty() {
384 return b.len();
385 }
386 if b.is_empty() {
387 return a.len();
388 }
389 let mut prev: Vec<usize> = (0..=b.len()).collect();
390 let mut curr = vec![0usize; b.len() + 1];
391 for (i, ca) in a.iter().enumerate() {
392 curr[0] = i + 1;
393 for (j, cb) in b.iter().enumerate() {
394 let cost = if ca == cb { 0 } else { 1 };
395 curr[j + 1] = (curr[j] + 1).min(prev[j + 1] + 1).min(prev[j] + cost);
396 }
397 std::mem::swap(&mut prev, &mut curr);
398 }
399 prev[b.len()]
400}
401
402fn truncate_candidate_list(candidates: &[String], keep: usize) -> String {
403 if candidates.len() <= keep {
404 return candidates.join(", ");
405 }
406 let shown = candidates[..keep].join(", ");
407 let extra = candidates.len() - keep;
408 format!("{shown} ({extra} more)")
409}
410
411fn is_generic_label(s: &str) -> bool {
415 matches!(
416 s,
417 "Bad Request"
418 | "Unauthorized"
419 | "Forbidden"
420 | "Not Found"
421 | "Unprocessable Entity"
422 | "Too Many Requests"
423 | "Internal Server Error"
424 | "Service Unavailable"
425 )
426}
427
428fn field_hint(field: &str) -> Option<&'static str> {
429 match field {
430 "network" => Some("Run 'qn chain list' to see supported networks."),
431 "chain" => Some("Run 'qn chain list' to see supported chains."),
432 _ => None,
433 }
434}
435
436#[cfg(test)]
437mod tests {
438 use super::*;
439 use quicknode_sdk::errors::SdkError;
440
441 fn api_err_with(code: u16, body: &str) -> CliError {
442 CliError::Sdk(SdkError::Api {
443 status: reqwest::StatusCode::from_u16(code).unwrap(),
444 body: body.to_string(),
445 })
446 }
447
448 fn api_err(code: u16) -> CliError {
449 api_err_with(code, "{\"message\":\"boom\"}")
450 }
451
452 #[test]
453 fn exit_code_api_is_2() {
454 assert_eq!(exit_code_for(&api_err(404)), 2);
455 }
456
457 #[test]
458 fn exit_code_no_api_key_is_4() {
459 assert_eq!(exit_code_for(&CliError::NoApiKey), 4);
460 }
461
462 #[test]
463 fn exit_code_cancelled_is_5() {
464 assert_eq!(exit_code_for(&CliError::Cancelled), 5);
465 }
466
467 #[test]
468 fn renders_401_as_unauthorized() {
469 let msg = render(&api_err(401), false);
470 assert!(msg.contains("unauthorized"), "got: {msg}");
471 }
472
473 #[test]
474 fn renders_429_as_rate_limited() {
475 let msg = render(&api_err(429), false);
476 assert!(msg.contains("rate limited"), "got: {msg}");
477 }
478
479 #[test]
480 fn renders_5xx_with_status() {
481 let msg = render(&api_err(503), false);
482 assert!(msg.contains("503"), "got: {msg}");
483 }
484
485 #[test]
486 fn verbose_404_includes_body() {
487 let msg = render(&api_err(404), true);
488 assert!(msg.contains("boom"), "got: {msg}");
489 }
490
491 #[test]
492 fn non_verbose_404_omits_body() {
493 let msg = render(&api_err(404), false);
494 assert!(!msg.contains("boom"), "got: {msg}");
495 }
496
497 #[test]
500 fn nestjs_shape_extracts_bullets() {
501 let body = r#"{"statusCode":400,"message":{"message":["network must be one of the following values: ethereum-mainnet, ethereum-sepolia, solana-mainnet","status must be one of the following values: active, paused, terminated"],"error":"Bad Request"}}"#;
502 let msg = render(&api_err_with(400, body), false);
503 assert!(msg.starts_with("Error: invalid request."), "got: {msg}");
504 assert!(msg.contains("• network must be one of:"), "got: {msg}");
505 assert!(msg.contains("• status must be one of:"), "got: {msg}");
506 }
507
508 #[test]
509 fn admin_shape_extracts_error_string() {
510 let body = r#"{"data":null,"error":"undefined method `chain' for nil"}"#;
511 let msg = render(&api_err_with(400, body), false);
512 assert!(msg.contains("undefined method"), "got: {msg}");
513 }
514
515 #[test]
516 fn empty_body_400_falls_through() {
517 let msg = render(&api_err_with(400, ""), false);
518 assert_eq!(msg, "Error: invalid request.");
519 }
520
521 #[test]
522 fn garbage_non_json_body_falls_back_to_raw() {
523 let body = "<html>oops</html>";
524 let msg = render(&api_err_with(400, body), false);
525 assert!(msg.contains("<html>oops</html>"), "got: {msg}");
526 }
527
528 #[test]
529 fn generic_errors_array_of_strings() {
530 let body = r#"{"errors":["first thing wrong","second thing wrong"]}"#;
531 let msg = render(&api_err_with(400, body), false);
532 assert!(msg.contains("• first thing wrong"), "got: {msg}");
533 assert!(msg.contains("• second thing wrong"), "got: {msg}");
534 }
535
536 #[test]
537 fn generic_errors_array_of_objects() {
538 let body = r#"{"errors":[{"message":"thing one"},{"message":"thing two"}]}"#;
539 let msg = render(&api_err_with(400, body), false);
540 assert!(msg.contains("• thing one"), "got: {msg}");
541 assert!(msg.contains("• thing two"), "got: {msg}");
542 }
543
544 #[test]
545 fn dedupes_repeated_strings() {
546 let body = r#"{"error":"same thing","message":"same thing"}"#;
547 let msg = render(&api_err_with(400, body), false);
548 let count = msg.matches("same thing").count();
549 assert_eq!(count, 1, "expected dedupe, got: {msg}");
550 }
551
552 #[test]
553 fn truncates_long_enum_list() {
554 let body = r#"{"message":"x must be one of a, b, c, d, e, f, g, h, i, j"}"#;
556 let msg = render(&api_err_with(400, body), false);
557 assert!(msg.contains("a, b, c, d, e (5 more)"), "got: {msg}");
558 }
559
560 #[test]
561 fn field_hint_appended_for_network() {
562 let body = r#"{"message":"network must be one of: ethereum-mainnet, solana-mainnet"}"#;
563 let msg = render(&api_err_with(400, body), false);
564 assert!(msg.contains("qn chain list"), "got: {msg}");
565 }
566
567 #[test]
568 fn verbose_appends_full_body() {
569 let body = r#"{"message":["network must be one of: a, b, c"]}"#;
570 let msg = render(&api_err_with(400, body), true);
571 assert!(msg.contains(body), "got: {msg}");
572 }
573
574 #[test]
575 fn levenshtein_basic() {
576 assert_eq!(levenshtein("", ""), 0);
577 assert_eq!(levenshtein("a", ""), 1);
578 assert_eq!(levenshtein("", "abc"), 3);
579 assert_eq!(levenshtein("kitten", "sitting"), 3);
580 assert_eq!(levenshtein("ethereum-mainnet", "ethereum-mainnet"), 0);
581 assert_eq!(levenshtein("ethereum-mainnetsds", "ethereum-mainnet"), 3);
582 }
583
584 #[test]
585 fn parse_must_be_one_of_happy_path() {
586 let (f, c) =
587 parse_must_be_one_of("network must be one of the following values: a, b, c").unwrap();
588 assert_eq!(f, "network");
589 assert_eq!(c, vec!["a", "b", "c"]);
590 }
591
592 #[test]
593 fn parse_must_be_one_of_no_following_values_prefix() {
594 let (f, c) = parse_must_be_one_of("status must be one of active, paused").unwrap();
595 assert_eq!(f, "status");
596 assert_eq!(c, vec!["active", "paused"]);
597 }
598
599 #[test]
600 fn parse_must_be_one_of_rejects_unrelated_strings() {
601 assert!(parse_must_be_one_of("some random error").is_none());
602 }
603
604 #[test]
605 fn truncate_candidate_list_under_keep_returns_all() {
606 assert_eq!(
607 truncate_candidate_list(&["a".into(), "b".into()], 5),
608 "a, b"
609 );
610 }
611
612 #[test]
613 fn best_suggestion_picks_closest_within_threshold() {
614 let candidates: Vec<String> =
615 vec!["ethereum-mainnet", "ethereum-sepolia", "solana-mainnet"]
616 .into_iter()
617 .map(String::from)
618 .collect();
619 let argv = vec!["ethereum-mainnetsds".to_string()];
620 let suggestion = best_suggestion(&argv, &candidates);
621 assert_eq!(
622 suggestion,
623 Some(("ethereum-mainnetsds".into(), "ethereum-mainnet".into()))
624 );
625 }
626
627 #[test]
628 fn best_suggestion_returns_none_if_too_far() {
629 let candidates: Vec<String> = vec!["ethereum-mainnet"]
630 .into_iter()
631 .map(String::from)
632 .collect();
633 let argv = vec!["sfjla".to_string()];
634 assert_eq!(best_suggestion(&argv, &candidates), None);
635 }
636
637 #[test]
638 fn best_suggestion_ignores_flag_tokens() {
639 let candidates: Vec<String> = vec!["chain"].into_iter().map(String::from).collect();
640 let argv = vec!["--chain".to_string()];
641 assert_eq!(best_suggestion(&argv, &candidates), None);
643 }
644
645 #[test]
646 fn renders_5xx_skips_body_parsing() {
647 let body = r#"{"message":"internal error"}"#;
649 let msg = render(&api_err_with(500, body), false);
650 assert!(!msg.contains("•"), "got: {msg}");
651 }
652}