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