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