Skip to main content

qn/
errors.rs

1//! Top-level error type for the CLI and the SDK→user message mapping.
2
3use 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    /// A paid call may have settled; maps to exit 3.
44    #[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    /// A paid request was refused without settlement; maps to exit 2.
51    #[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
64/// Map a CLI error to its process exit code.
65pub 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
82// Map known payment problem types to actionable headlines.
83fn 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
98/// Renders the error to a human-friendly message using the real process argv
99/// for did-you-mean suggestions. Use [`render_with_argv`] from tests where the
100/// simulated argv differs from the process argv.
101///
102/// Verbose mode appends the underlying body / source where available.
103pub 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
108/// Like [`render`] but uses the supplied argv values for did-you-mean lookup.
109pub 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            // The failed host varies: control-plane calls hit api.quicknode.com,
173            // RPC data-plane calls hit the endpoint host (*.quiknode.pro). Name
174            // the actual host from the reqwest error's URL when available rather
175            // than hardcoding one.
176            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
220/// Return a short gateway rejection reason, if present.
221fn 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    // Map actionable payment problem types before generic API errors.
231    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    // Parse response bodies only for validation errors.
255    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        // Preserve an unstructured validation body.
270        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        // 400/422 with nothing parseable already surfaced the raw body above;
284        // everywhere else, point at the flag that reveals it.
285        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
300/// Parses a JSON-shaped API error body, extracting human-readable messages and
301/// (when the body contains "must be one of …" enum lists) appending
302/// did-you-mean suggestions against the user's argv.
303fn 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            // Skip "Bad Request" / "Unauthorized" — these duplicate the headline.
328            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
343/// Recursively walk a JSON value, pulling strings out of any key named
344/// `error`, `errors`, `message`, or `messages`. Accepts strings, arrays of
345/// strings, arrays of objects (recurse), and nested objects (recurse).
346fn 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            // Also recurse into other object values so we can find nested
356            // error/message keys (e.g. NestJS wraps under `message.message`).
357            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
372/// Helper: when we hit one of the error keys, accept multiple shapes.
373fn 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
390/// Returns the bullet text, possibly with a `did you mean '…'?` suffix and a
391/// `(N more)` truncation marker. Also records which fields had enum lists so
392/// the caller can attach helper hints.
393fn 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    // Find the argv value that's closest to any candidate, then attach DYM if
405    // the best match is within threshold.
406    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
418/// Parse `"<field> must be one of [the following values:] X, Y, Z"`.
419/// Returns the field name and the candidate list.
420fn 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    // After "must be one of" we accept any of: " the following values: X, Y",
431    // ": X, Y", or " X, Y" (rare but possible).
432    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
449/// Find the (argv-value, candidate) pair with smallest Levenshtein distance,
450/// gated on: distance ≤ 3 AND ≥ 3 leading chars shared with the candidate.
451fn best_suggestion(argv: &[String], candidates: &[String]) -> Option<(String, String)> {
452    let mut best: Option<(usize, String, String)> = None;
453    for arg in argv {
454        // Skip flags themselves and obviously-non-value tokens.
455        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
480/// Classic O(n*m) Levenshtein distance. n,m are tiny here (≤ ~40 chars), so
481/// this is fine.
482fn 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
513/// Maps known server-side field names to a follow-up command the user can run
514/// to discover valid values.
515/// Skip standard HTTP status-phrase strings that duplicate the headline.
516fn 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    // ---- payment errors ----
570
571    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        // The gateway said no and nothing settled: an unmatched/unreadable
581        // offer (unsupported) or a 4xx-refused credential (rejected). The
582        // paid lane wraps 5xx rejections into PaymentMaybeCharged before
583        // this mapping ever sees them.
584        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        // Request sent, outcome unknown: the transport-ambiguity bucket, so
598        // scripts can distinguish "safe to retry" (2) from "check wallet" (3).
599        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    // When the SDK identifies max_amount as the lever, that sentence leads and
617    // the (long, mostly irrelevant) menu dump is held back for --verbose.
618    #[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        // The gateway's RFC 9457 problem document for an exhausted channel. The
643        // raw JSON must not reach the user; the message names the fix.
644        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        // A short gateway reason surfaces in the default message (the SDK has
668        // already reduced its JSON error shape to this string).
669        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        // A long/JSON body (e.g. a full 402 payment menu) is not a reason: the
683        // default message stays generic and the raw body appears under --verbose.
684        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        // A gateway 402 carries an actionable problem+json body; without
749        // --verbose the message must say how to see it.
750        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    // ---- body parsing ----
760
761    #[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        // 10 candidates, only the first 5 should render inline.
817        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        // "--chain" starts with "-", should be skipped.
904        assert_eq!(best_suggestion(&argv, &candidates), None);
905    }
906
907    #[test]
908    fn renders_5xx_skips_body_parsing() {
909        // We don't want stack-trace HTML on a 500 to be parsed as bullets.
910        let body = r#"{"message":"internal error"}"#;
911        let msg = render(&api_err_with(500, body), false);
912        assert!(!msg.contains("•"), "got: {msg}");
913    }
914}