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(
45 "the paid request's outcome is unknown — the payment may have been settled; \
46 check your wallet before retrying"
47 )]
48 PaymentMaybeCharged(#[source] SdkError),
49
50 #[error("{0}")]
52 PaymentRefused(String),
53
54 #[error(transparent)]
55 Io(#[from] std::io::Error),
56
57 #[error(transparent)]
58 Json(#[from] serde_json::Error),
59
60 #[error("could not serialize output: {0}")]
61 Format(String),
62}
63
64pub fn exit_code_for(err: &CliError) -> i32 {
66 match err {
67 CliError::NoApiKey | CliError::BadConfig { .. } | CliError::ConfigWrite { .. } => 4,
68 CliError::Cancelled | CliError::NeedsConfirmation => 5,
69 CliError::PaymentMaybeCharged(_) => 3,
70 CliError::PaymentRefused(_) => 2,
71 CliError::Sdk(sdk) => match sdk {
72 SdkError::Api { .. } => 2,
73 SdkError::Http(_) => 3,
74 SdkError::PaymentUnsupported { .. } | SdkError::PaymentRejected { .. } => 2,
75 SdkError::PaymentIndeterminate => 3,
76 _ => 1,
77 },
78 _ => 1,
79 }
80}
81
82fn payment_problem_headline(body: &str) -> Option<String> {
84 let problem: serde_json::Value = serde_json::from_str(body).ok()?;
85 let kind = problem.get("type").and_then(|v| v.as_str())?;
86 let slug = kind.rsplit('/').next().unwrap_or(kind);
87 match slug {
88 "insufficient-balance" => Some(
89 "the payment channel has too little deposit left for this request. \
90 Add funds with 'qn rpc mpp top-up --deposit <BASE_UNITS>', or close and \
91 reopen it with 'qn rpc mpp close' then 'qn rpc mpp open'."
92 .to_string(),
93 ),
94 _ => None,
95 }
96}
97
98pub fn render(err: &CliError, verbose: bool) -> String {
104 let argv: Vec<String> = std::env::args().skip(1).collect();
105 render_with_argv(err, verbose, &argv)
106}
107
108pub fn render_with_argv(err: &CliError, verbose: bool, argv: &[String]) -> String {
110 match err {
111 CliError::Sdk(SdkError::PaymentUnsupported { offered })
112 if offered.contains("cannot sign") =>
113 {
114 format!("Error: {offered} Nothing was charged.")
115 }
116 CliError::Sdk(SdkError::PaymentUnsupported { offered }) => {
117 if offered.contains("raise max_amount") {
118 let body = match offered.split_once(" Full menu: ") {
119 Some((lever, _)) if !verbose => lever,
120 _ => offered.as_str(),
121 };
122 format!(
123 "Error: {}. Nothing was charged.",
124 body.trim_end().trim_end_matches('.')
125 )
126 } else {
127 format!(
128 "Error: no offered payment option matched your configuration \
129 (check --payment-network, --payment-asset, and --max-amount). Nothing was charged.\n\
130 Gateway offered: {offered}"
131 )
132 }
133 }
134 CliError::Sdk(SdkError::PaymentRejected { status, body }) => {
135 let reason = payment_rejection_reason(body);
136 let mut msg = format!("Error: the gateway refused the payment (HTTP {status}).");
137 if let Some(r) = &reason {
138 let r = r.trim_end_matches('.');
139 msg.push_str(&format!(" Gateway: {r}."));
140 }
141 msg.push_str(
142 " The signed payment was not accepted, so nothing should have settled. \
143 Common causes: the wallet is unfunded, or --payment-network/--payment-asset/--max-amount \
144 don't match an offer (see 'qn rpc x402 supported-payments' / 'qn rpc mpp supported-payments').",
145 );
146 if verbose && reason.is_none() && !body.is_empty() {
147 format!("{msg}\n{body}")
148 } else {
149 msg
150 }
151 }
152 CliError::Sdk(SdkError::PaymentIndeterminate) => {
153 "Error: the paid request was sent but its response was lost — the request \
154 may have been settled; check your wallet before retrying. Do not blindly \
155 re-run this command."
156 .to_string()
157 }
158 CliError::PaymentMaybeCharged(source) => {
159 let msg = "Error: the paid request failed after the payment was submitted — \
160 the payment may have been settled; check your wallet before \
161 retrying. Do not blindly re-run this command.";
162 if verbose {
163 format!("{msg}\n{source}")
164 } else {
165 format!("{msg} Re-run with --verbose for the response detail.")
166 }
167 }
168 CliError::Sdk(SdkError::Api { status, body }) => {
169 render_api_error(status.as_u16(), body, verbose, argv)
170 }
171 CliError::Sdk(sdk @ SdkError::Http(inner)) => {
172 let host = inner.url().and_then(|u| u.host_str()).map(str::to_string);
177 let target = host
178 .map(|h| format!("'{h}'"))
179 .unwrap_or_else(|| "the Quicknode API".to_string());
180 let msg = match sdk.http_kind() {
181 Some(HttpKind::Timeout) => {
182 format!("request to {target} timed out. Check your connection and try again.")
183 }
184 Some(HttpKind::Connect) => {
185 format!("could not connect to {target}. Check your network.")
186 }
187 _ => format!("HTTP transport failure talking to {target}."),
188 };
189 if verbose {
190 format!("Error: {msg}\n{sdk}")
191 } else {
192 format!("Error: {msg}")
193 }
194 }
195 CliError::Sdk(SdkError::Decode { body, .. }) => {
196 if verbose {
197 format!("Error: unexpected response shape from API.\n{body}")
198 } else {
199 "Error: unexpected response shape from API. Re-run with --verbose to see the body."
200 .to_string()
201 }
202 }
203 CliError::BadConfig { path, source } => {
204 if verbose {
205 format!(
206 "Error: config file at {} is invalid: {source}",
207 path.display()
208 )
209 } else {
210 format!(
211 "Error: config file at {} is invalid. Re-run with --verbose for details.",
212 path.display()
213 )
214 }
215 }
216 other => format!("Error: {other}"),
217 }
218}
219
220fn payment_rejection_reason(body: &str) -> Option<String> {
222 let trimmed = body.trim();
223 if trimmed.is_empty() || trimmed.len() > 200 || trimmed.starts_with('{') {
224 return None;
225 }
226 Some(trimmed.to_string())
227}
228
229fn render_api_error(code: u16, body: &str, verbose: bool, argv: &[String]) -> String {
230 if code == 402 {
232 if let Some(headline) = payment_problem_headline(body) {
233 let mut out = format!("Error: {headline}");
234 if verbose && !body.is_empty() {
235 out.push('\n');
236 out.push_str(body);
237 }
238 return out;
239 }
240 }
241
242 let headline = match code {
243 400 | 422 => "invalid request.".to_string(),
244 401 | 403 => "unauthorized. Check your API key with 'qn auth whoami'.".to_string(),
245 404 => "not found.".to_string(),
246 429 => "rate limited by the Quicknode API. Try again shortly.".to_string(),
247 500..=599 => format!(
248 "something went wrong (HTTP {code}). Please try again; if the problem persists, \
249 contact support at https://support.quicknode.com."
250 ),
251 _ => format!("API returned HTTP {code}."),
252 };
253
254 let parsed = if matches!(code, 400 | 422) {
256 parse_api_body(body, argv)
257 } else {
258 ParsedApiBody::default()
259 };
260
261 let mut out = format!("Error: {headline}");
262
263 if !parsed.bullets.is_empty() {
264 for bullet in &parsed.bullets {
265 out.push_str("\n • ");
266 out.push_str(bullet);
267 }
268 } else if matches!(code, 400 | 422) && !body.is_empty() && !verbose {
269 out.push('\n');
271 out.push_str(body.trim());
272 }
273
274 for hint in &parsed.hints {
275 out.push('\n');
276 out.push_str(hint);
277 }
278
279 if verbose && !body.is_empty() {
280 out.push('\n');
281 out.push_str(body);
282 } else if !body.is_empty() {
283 let raw_body_shown = matches!(code, 400 | 422) && parsed.bullets.is_empty();
286 if !raw_body_shown {
287 out.push_str("\nRe-run with --verbose for the full response body.");
288 }
289 }
290
291 out
292}
293
294#[derive(Default)]
295struct ParsedApiBody {
296 bullets: Vec<String>,
297 hints: Vec<String>,
298}
299
300fn parse_api_body(body: &str, argv: &[String]) -> ParsedApiBody {
304 let mut out = ParsedApiBody::default();
305 if body.is_empty() {
306 return out;
307 }
308 let Ok(value) = serde_json::from_str::<Value>(body) else {
309 return out;
310 };
311
312 let mut raw_strings: Vec<String> = Vec::new();
313 collect_error_strings(&value, &mut raw_strings);
314 if raw_strings.is_empty() {
315 return out;
316 }
317
318 let mut seen: BTreeSet<String> = BTreeSet::new();
319 let mut fields_hinted: BTreeSet<String> = BTreeSet::new();
320
321 for s in raw_strings {
322 let trimmed = s.trim().to_string();
323 if trimmed.is_empty() || !seen.insert(trimmed.clone()) {
324 continue;
325 }
326 if is_generic_label(&trimmed) {
327 continue;
329 }
330 let bullet = decorate_with_suggestion(&trimmed, argv, &mut fields_hinted);
331 out.bullets.push(bullet);
332 }
333
334 for field in &fields_hinted {
335 if let Some(hint) = field_hint(field) {
336 out.hints.push(hint.to_string());
337 }
338 }
339
340 out
341}
342
343fn collect_error_strings(value: &Value, out: &mut Vec<String>) {
347 const KEYS: &[&str] = &["errors", "error", "messages", "message"];
348 match value {
349 Value::Object(map) => {
350 for key in KEYS {
351 if let Some(v) = map.get(*key) {
352 collect_strings_from(v, out);
353 }
354 }
355 for (k, v) in map {
358 if !KEYS.contains(&k.as_str()) {
359 collect_error_strings(v, out);
360 }
361 }
362 }
363 Value::Array(arr) => {
364 for v in arr {
365 collect_error_strings(v, out);
366 }
367 }
368 _ => {}
369 }
370}
371
372fn collect_strings_from(value: &Value, out: &mut Vec<String>) {
374 match value {
375 Value::String(s) => out.push(s.clone()),
376 Value::Array(arr) => {
377 for v in arr {
378 match v {
379 Value::String(s) => out.push(s.clone()),
380 Value::Object(_) => collect_error_strings(v, out),
381 _ => {}
382 }
383 }
384 }
385 Value::Object(_) => collect_error_strings(value, out),
386 _ => {}
387 }
388}
389
390fn decorate_with_suggestion(
394 raw: &str,
395 argv: &[String],
396 fields_hinted: &mut BTreeSet<String>,
397) -> String {
398 let Some((field, candidates)) = parse_must_be_one_of(raw) else {
399 return raw.to_string();
400 };
401
402 fields_hinted.insert(field.clone());
403
404 let best = best_suggestion(argv, &candidates);
407
408 let display = truncate_candidate_list(&candidates, 5);
409 let mut bullet = format!("{field} must be one of: {display}");
410 if let Some((user_value, suggestion)) = best {
411 bullet.push_str(&format!(
412 " — did you mean '{suggestion}' (you passed '{user_value}')?"
413 ));
414 }
415 bullet
416}
417
418fn parse_must_be_one_of(s: &str) -> Option<(String, Vec<String>)> {
421 let (field_part, rest) = s.split_once(" must be one of")?;
422 let field = field_part.trim();
423 if field.is_empty()
424 || !field
425 .chars()
426 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
427 {
428 return None;
429 }
430 let list_part = rest
433 .strip_prefix(" the following values: ")
434 .or_else(|| rest.strip_prefix(": "))
435 .or_else(|| rest.strip_prefix(' '))
436 .unwrap_or(rest)
437 .trim_end_matches('.');
438 let candidates: Vec<String> = list_part
439 .split(", ")
440 .map(|c| c.trim().to_string())
441 .filter(|c| !c.is_empty())
442 .collect();
443 if candidates.len() < 2 {
444 return None;
445 }
446 Some((field.to_string(), candidates))
447}
448
449fn best_suggestion(argv: &[String], candidates: &[String]) -> Option<(String, String)> {
452 let mut best: Option<(usize, String, String)> = None;
453 for arg in argv {
454 if arg.starts_with('-') || arg.is_empty() || arg.len() < 2 {
456 continue;
457 }
458 for cand in candidates {
459 let d = levenshtein(arg, cand);
460 if d > 3 {
461 continue;
462 }
463 if shared_prefix_len(arg, cand) < 3 {
464 continue;
465 }
466 match best.as_ref() {
467 None => best = Some((d, arg.clone(), cand.clone())),
468 Some((cur, _, _)) if d < *cur => best = Some((d, arg.clone(), cand.clone())),
469 _ => {}
470 }
471 }
472 }
473 best.map(|(_, a, c)| (a, c))
474}
475
476fn shared_prefix_len(a: &str, b: &str) -> usize {
477 a.chars().zip(b.chars()).take_while(|(x, y)| x == y).count()
478}
479
480fn levenshtein(a: &str, b: &str) -> usize {
483 let a: Vec<char> = a.chars().collect();
484 let b: Vec<char> = b.chars().collect();
485 if a.is_empty() {
486 return b.len();
487 }
488 if b.is_empty() {
489 return a.len();
490 }
491 let mut prev: Vec<usize> = (0..=b.len()).collect();
492 let mut curr = vec![0usize; b.len() + 1];
493 for (i, ca) in a.iter().enumerate() {
494 curr[0] = i + 1;
495 for (j, cb) in b.iter().enumerate() {
496 let cost = if ca == cb { 0 } else { 1 };
497 curr[j + 1] = (curr[j] + 1).min(prev[j + 1] + 1).min(prev[j] + cost);
498 }
499 std::mem::swap(&mut prev, &mut curr);
500 }
501 prev[b.len()]
502}
503
504fn truncate_candidate_list(candidates: &[String], keep: usize) -> String {
505 if candidates.len() <= keep {
506 return candidates.join(", ");
507 }
508 let shown = candidates[..keep].join(", ");
509 let extra = candidates.len() - keep;
510 format!("{shown} ({extra} more)")
511}
512
513fn is_generic_label(s: &str) -> bool {
517 matches!(
518 s,
519 "Bad Request"
520 | "Unauthorized"
521 | "Forbidden"
522 | "Not Found"
523 | "Unprocessable Entity"
524 | "Too Many Requests"
525 | "Internal Server Error"
526 | "Service Unavailable"
527 )
528}
529
530fn field_hint(field: &str) -> Option<&'static str> {
531 match field {
532 "network" => Some("Run 'qn chain list' to see supported networks."),
533 "chain" => Some("Run 'qn chain list' to see supported chains."),
534 _ => None,
535 }
536}
537
538#[cfg(test)]
539mod tests {
540 use super::*;
541 use quicknode_sdk::errors::SdkError;
542
543 fn api_err_with(code: u16, body: &str) -> CliError {
544 CliError::Sdk(SdkError::Api {
545 status: reqwest::StatusCode::from_u16(code).unwrap(),
546 body: body.to_string(),
547 })
548 }
549
550 fn api_err(code: u16) -> CliError {
551 api_err_with(code, "{\"message\":\"boom\"}")
552 }
553
554 #[test]
555 fn exit_code_api_is_2() {
556 assert_eq!(exit_code_for(&api_err(404)), 2);
557 }
558
559 #[test]
560 fn exit_code_no_api_key_is_4() {
561 assert_eq!(exit_code_for(&CliError::NoApiKey), 4);
562 }
563
564 #[test]
565 fn exit_code_cancelled_is_5() {
566 assert_eq!(exit_code_for(&CliError::Cancelled), 5);
567 }
568
569 fn decode_err() -> SdkError {
572 SdkError::Decode {
573 source: serde_json::from_str::<serde_json::Value>("not json").unwrap_err(),
574 body: "<html>gateway oops</html>".to_string(),
575 }
576 }
577
578 #[test]
579 fn exit_code_payment_refusals_are_2() {
580 let unsupported = CliError::Sdk(SdkError::PaymentUnsupported {
585 offered: "eip155:84532/0xabc amount 999999".to_string(),
586 });
587 let rejected = CliError::Sdk(SdkError::PaymentRejected {
588 status: 402,
589 body: "invalid signature".to_string(),
590 });
591 assert_eq!(exit_code_for(&unsupported), 2);
592 assert_eq!(exit_code_for(&rejected), 2);
593 }
594
595 #[test]
596 fn exit_code_unknown_payment_outcome_is_3() {
597 let indeterminate = CliError::Sdk(SdkError::PaymentIndeterminate);
600 let maybe_charged = CliError::PaymentMaybeCharged(decode_err());
601 assert_eq!(exit_code_for(&indeterminate), 3);
602 assert_eq!(exit_code_for(&maybe_charged), 3);
603 }
604
605 #[test]
606 fn renders_payment_unsupported_as_not_charged() {
607 let err = CliError::Sdk(SdkError::PaymentUnsupported {
608 offered: "eip155:84532/0xabc amount 999999".to_string(),
609 });
610 let msg = render(&err, false);
611 assert!(msg.contains("Nothing was charged"), "got: {msg}");
612 assert!(msg.contains("999999"), "got: {msg}");
613 assert!(msg.contains("--max-amount"), "got: {msg}");
614 }
615
616 #[test]
619 fn renders_over_ceiling_lever_first_and_hides_the_menu() {
620 let err = CliError::Sdk(SdkError::PaymentUnsupported {
621 offered: "every offer for solana:abc/mint1 is above max_amount 100; the cheapest \
622 is 1000 base units — raise max_amount to at least that. Full menu: \
623 [a, b, c]"
624 .to_string(),
625 });
626
627 let terse = render(&err, false);
628 assert!(terse.contains("raise max_amount"), "got: {terse}");
629 assert!(terse.contains("Nothing was charged"), "got: {terse}");
630 assert!(
631 !terse.contains("Full menu"),
632 "menu should be hidden: {terse}"
633 );
634 assert!(!terse.contains(".."), "no doubled period: {terse}");
635
636 let verbose = render(&err, true);
637 assert!(verbose.contains("Full menu"), "got: {verbose}");
638 }
639
640 #[test]
641 fn renders_session_insufficient_balance_with_a_top_up_hint() {
642 let err = CliError::Sdk(SdkError::Api {
645 status: reqwest::StatusCode::PAYMENT_REQUIRED,
646 body: r#"{"type":"https://paymentauth.org/problems/session/insufficient-balance","title":"Insufficient Balance","status":402,"detail":"Insufficient balance: requested 10, available 0."}"#
647 .to_string(),
648 });
649 let msg = render(&err, false);
650 assert!(msg.contains("qn rpc mpp top-up"), "got: {msg}");
651 assert!(!msg.contains("API returned HTTP 402"), "got: {msg}");
652 assert!(!msg.contains("paymentauth.org"), "got: {msg}");
653 }
654
655 #[test]
656 fn renders_unknown_402_problem_with_the_generic_headline() {
657 let err = CliError::Sdk(SdkError::Api {
658 status: reqwest::StatusCode::PAYMENT_REQUIRED,
659 body: r#"{"type":"https://paymentauth.org/problems/payment-required"}"#.to_string(),
660 });
661 let msg = render(&err, false);
662 assert!(msg.contains("402"), "got: {msg}");
663 }
664
665 #[test]
666 fn renders_payment_rejected_as_refused_without_settling() {
667 let err = CliError::Sdk(SdkError::PaymentRejected {
670 status: 402,
671 body: "insufficient funds".to_string(),
672 });
673 let msg = render(&err, false);
674 assert!(msg.contains("402"), "got: {msg}");
675 assert!(msg.contains("refused"), "got: {msg}");
676 assert!(msg.contains("nothing should have settled"), "got: {msg}");
677 assert!(msg.contains("Gateway: insufficient funds"), "got: {msg}");
678 }
679
680 #[test]
681 fn payment_rejected_hides_long_body_unless_verbose() {
682 let body = format!("{{\"accepts\":[{}]}}", "\"x\",".repeat(80));
685 let err = CliError::Sdk(SdkError::PaymentRejected {
686 status: 402,
687 body: body.clone(),
688 });
689 let msg = render(&err, false);
690 assert!(
691 !msg.contains(&body),
692 "long body must not leak by default: {msg}"
693 );
694 assert!(msg.contains("supported-payments"), "got: {msg}");
695 let verbose = render(&err, true);
696 assert!(verbose.contains("accepts"), "got: {verbose}");
697 }
698
699 #[test]
700 fn renders_payment_indeterminate_as_possibly_settled() {
701 let msg = render(&CliError::Sdk(SdkError::PaymentIndeterminate), false);
702 assert!(msg.contains("may have been settled"), "got: {msg}");
703 assert!(msg.contains("check your wallet"), "got: {msg}");
704 }
705
706 #[test]
707 fn renders_payment_maybe_charged_with_source_when_verbose() {
708 let err = CliError::PaymentMaybeCharged(decode_err());
709 let msg = render(&err, false);
710 assert!(msg.contains("may have been settled"), "got: {msg}");
711 assert!(!msg.contains("gateway oops"), "got: {msg}");
712 let verbose = render(&err, true);
713 assert!(verbose.contains("gateway oops"), "got: {verbose}");
714 }
715
716 #[test]
717 fn renders_401_as_unauthorized() {
718 let msg = render(&api_err(401), false);
719 assert!(msg.contains("unauthorized"), "got: {msg}");
720 }
721
722 #[test]
723 fn renders_429_as_rate_limited() {
724 let msg = render(&api_err(429), false);
725 assert!(msg.contains("rate limited"), "got: {msg}");
726 }
727
728 #[test]
729 fn renders_5xx_with_status() {
730 let msg = render(&api_err(503), false);
731 assert!(msg.contains("503"), "got: {msg}");
732 }
733
734 #[test]
735 fn verbose_404_includes_body() {
736 let msg = render(&api_err(404), true);
737 assert!(msg.contains("boom"), "got: {msg}");
738 }
739
740 #[test]
741 fn non_verbose_404_omits_body() {
742 let msg = render(&api_err(404), false);
743 assert!(!msg.contains("boom"), "got: {msg}");
744 }
745
746 #[test]
747 fn non_verbose_402_hints_at_verbose() {
748 let body = r#"{"title":"Insufficient Balance","status":402,"detail":"Insufficient balance: requested 10, available 0."}"#;
751 let msg = render(&api_err_with(402, body), false);
752 assert!(msg.contains("402"), "got: {msg}");
753 assert!(msg.contains("--verbose"), "got: {msg}");
754 assert!(!msg.contains("Insufficient balance"), "got: {msg}");
755 let verbose = render(&api_err_with(402, body), true);
756 assert!(verbose.contains("Insufficient balance"), "got: {verbose}");
757 }
758
759 #[test]
762 fn nestjs_shape_extracts_bullets() {
763 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"}}"#;
764 let msg = render(&api_err_with(400, body), false);
765 assert!(msg.starts_with("Error: invalid request."), "got: {msg}");
766 assert!(msg.contains("• network must be one of:"), "got: {msg}");
767 assert!(msg.contains("• status must be one of:"), "got: {msg}");
768 }
769
770 #[test]
771 fn admin_shape_extracts_error_string() {
772 let body = r#"{"data":null,"error":"undefined method `chain' for nil"}"#;
773 let msg = render(&api_err_with(400, body), false);
774 assert!(msg.contains("undefined method"), "got: {msg}");
775 }
776
777 #[test]
778 fn empty_body_400_falls_through() {
779 let msg = render(&api_err_with(400, ""), false);
780 assert_eq!(msg, "Error: invalid request.");
781 }
782
783 #[test]
784 fn garbage_non_json_body_falls_back_to_raw() {
785 let body = "<html>oops</html>";
786 let msg = render(&api_err_with(400, body), false);
787 assert!(msg.contains("<html>oops</html>"), "got: {msg}");
788 }
789
790 #[test]
791 fn generic_errors_array_of_strings() {
792 let body = r#"{"errors":["first thing wrong","second thing wrong"]}"#;
793 let msg = render(&api_err_with(400, body), false);
794 assert!(msg.contains("• first thing wrong"), "got: {msg}");
795 assert!(msg.contains("• second thing wrong"), "got: {msg}");
796 }
797
798 #[test]
799 fn generic_errors_array_of_objects() {
800 let body = r#"{"errors":[{"message":"thing one"},{"message":"thing two"}]}"#;
801 let msg = render(&api_err_with(400, body), false);
802 assert!(msg.contains("• thing one"), "got: {msg}");
803 assert!(msg.contains("• thing two"), "got: {msg}");
804 }
805
806 #[test]
807 fn dedupes_repeated_strings() {
808 let body = r#"{"error":"same thing","message":"same thing"}"#;
809 let msg = render(&api_err_with(400, body), false);
810 let count = msg.matches("same thing").count();
811 assert_eq!(count, 1, "expected dedupe, got: {msg}");
812 }
813
814 #[test]
815 fn truncates_long_enum_list() {
816 let body = r#"{"message":"x must be one of a, b, c, d, e, f, g, h, i, j"}"#;
818 let msg = render(&api_err_with(400, body), false);
819 assert!(msg.contains("a, b, c, d, e (5 more)"), "got: {msg}");
820 }
821
822 #[test]
823 fn field_hint_appended_for_network() {
824 let body = r#"{"message":"network must be one of: ethereum-mainnet, solana-mainnet"}"#;
825 let msg = render(&api_err_with(400, body), false);
826 assert!(msg.contains("qn chain list"), "got: {msg}");
827 }
828
829 #[test]
830 fn verbose_appends_full_body() {
831 let body = r#"{"message":["network must be one of: a, b, c"]}"#;
832 let msg = render(&api_err_with(400, body), true);
833 assert!(msg.contains(body), "got: {msg}");
834 }
835
836 #[test]
837 fn levenshtein_basic() {
838 assert_eq!(levenshtein("", ""), 0);
839 assert_eq!(levenshtein("a", ""), 1);
840 assert_eq!(levenshtein("", "abc"), 3);
841 assert_eq!(levenshtein("kitten", "sitting"), 3);
842 assert_eq!(levenshtein("ethereum-mainnet", "ethereum-mainnet"), 0);
843 assert_eq!(levenshtein("ethereum-mainnetsds", "ethereum-mainnet"), 3);
844 }
845
846 #[test]
847 fn parse_must_be_one_of_happy_path() {
848 let (f, c) =
849 parse_must_be_one_of("network must be one of the following values: a, b, c").unwrap();
850 assert_eq!(f, "network");
851 assert_eq!(c, vec!["a", "b", "c"]);
852 }
853
854 #[test]
855 fn parse_must_be_one_of_no_following_values_prefix() {
856 let (f, c) = parse_must_be_one_of("status must be one of active, paused").unwrap();
857 assert_eq!(f, "status");
858 assert_eq!(c, vec!["active", "paused"]);
859 }
860
861 #[test]
862 fn parse_must_be_one_of_rejects_unrelated_strings() {
863 assert!(parse_must_be_one_of("some random error").is_none());
864 }
865
866 #[test]
867 fn truncate_candidate_list_under_keep_returns_all() {
868 assert_eq!(
869 truncate_candidate_list(&["a".into(), "b".into()], 5),
870 "a, b"
871 );
872 }
873
874 #[test]
875 fn best_suggestion_picks_closest_within_threshold() {
876 let candidates: Vec<String> =
877 vec!["ethereum-mainnet", "ethereum-sepolia", "solana-mainnet"]
878 .into_iter()
879 .map(String::from)
880 .collect();
881 let argv = vec!["ethereum-mainnetsds".to_string()];
882 let suggestion = best_suggestion(&argv, &candidates);
883 assert_eq!(
884 suggestion,
885 Some(("ethereum-mainnetsds".into(), "ethereum-mainnet".into()))
886 );
887 }
888
889 #[test]
890 fn best_suggestion_returns_none_if_too_far() {
891 let candidates: Vec<String> = vec!["ethereum-mainnet"]
892 .into_iter()
893 .map(String::from)
894 .collect();
895 let argv = vec!["sfjla".to_string()];
896 assert_eq!(best_suggestion(&argv, &candidates), None);
897 }
898
899 #[test]
900 fn best_suggestion_ignores_flag_tokens() {
901 let candidates: Vec<String> = vec!["chain"].into_iter().map(String::from).collect();
902 let argv = vec!["--chain".to_string()];
903 assert_eq!(best_suggestion(&argv, &candidates), None);
905 }
906
907 #[test]
908 fn renders_5xx_skips_body_parsing() {
909 let body = r#"{"message":"internal error"}"#;
911 let msg = render(&api_err_with(500, body), false);
912 assert!(!msg.contains("•"), "got: {msg}");
913 }
914}