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