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    #[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
53/// Maps a [`CliError`] to a process exit code per the plan.
54///
55/// - 0: success (never produced here)
56/// - 1: generic CLI failure (arg parse, IO, decode). clap usage errors are
57///   mapped to 1 in main.rs too, so 2 always and only means an API error.
58/// - 2: SdkError::Api (server returned a non-2xx)
59/// - 3: SdkError::Http (network failure)
60/// - 4: NoApiKey / BadConfig
61/// - 5: user cancelled or needs --yes
62pub 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
75/// Renders the error to a human-friendly message using the real process argv
76/// for did-you-mean suggestions. Use [`render_with_argv`] from tests where the
77/// simulated argv differs from the process argv.
78///
79/// Verbose mode appends the underlying body / source where available.
80pub 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
85/// Like [`render`] but uses the supplied argv values for did-you-mean lookup.
86pub 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
132/// Status codes have a small set of canonical user-facing messages. Validation
133/// (400/422) gets the structured body treatment from `parse_api_body`.
134fn render_api_error(code: u16, body: &str, verbose: bool, argv: &[String]) -> String {
135    let headline = match code {
136        400 | 422 => "invalid request.".to_string(),
137        401 | 403 => "unauthorized. Check your API key with 'qn auth whoami'.".to_string(),
138        404 => "not found.".to_string(),
139        429 => "rate limited by the Quicknode API. Try again shortly.".to_string(),
140        500..=599 => format!(
141            "something went wrong (HTTP {code}). Please try again; if the problem persists, \
142             contact support at https://support.quicknode.com."
143        ),
144        _ => format!("API returned HTTP {code}."),
145    };
146
147    // For non-validation status codes, body is mostly noise (server stack traces,
148    // HTML error pages, etc). Only mine it for validation-class errors.
149    let parsed = if matches!(code, 400 | 422) {
150        parse_api_body(body, argv)
151    } else {
152        ParsedApiBody::default()
153    };
154
155    let mut out = format!("Error: {headline}");
156
157    if !parsed.bullets.is_empty() {
158        for bullet in &parsed.bullets {
159            out.push_str("\n  • ");
160            out.push_str(bullet);
161        }
162    } else if matches!(code, 400 | 422) && !body.is_empty() && !verbose {
163        // We tried to parse and got nothing useful; surface the raw body so
164        // the user isn't left with a bare "invalid request." line.
165        out.push('\n');
166        out.push_str(body.trim());
167    }
168
169    for hint in &parsed.hints {
170        out.push('\n');
171        out.push_str(hint);
172    }
173
174    if verbose && !body.is_empty() {
175        out.push('\n');
176        out.push_str(body);
177    } else if matches!(code, 400 | 422) && !parsed.bullets.is_empty() && !body.is_empty() {
178        out.push_str("\nRe-run with --verbose for the full response body.");
179    }
180
181    out
182}
183
184#[derive(Default)]
185struct ParsedApiBody {
186    bullets: Vec<String>,
187    hints: Vec<String>,
188}
189
190/// Parses a JSON-shaped API error body, extracting human-readable messages and
191/// (when the body contains "must be one of …" enum lists) appending
192/// did-you-mean suggestions against the user's argv.
193fn parse_api_body(body: &str, argv: &[String]) -> ParsedApiBody {
194    let mut out = ParsedApiBody::default();
195    if body.is_empty() {
196        return out;
197    }
198    let Ok(value) = serde_json::from_str::<Value>(body) else {
199        return out;
200    };
201
202    let mut raw_strings: Vec<String> = Vec::new();
203    collect_error_strings(&value, &mut raw_strings);
204    if raw_strings.is_empty() {
205        return out;
206    }
207
208    let mut seen: BTreeSet<String> = BTreeSet::new();
209    let mut fields_hinted: BTreeSet<String> = BTreeSet::new();
210
211    for s in raw_strings {
212        let trimmed = s.trim().to_string();
213        if trimmed.is_empty() || !seen.insert(trimmed.clone()) {
214            continue;
215        }
216        if is_generic_label(&trimmed) {
217            // Skip "Bad Request" / "Unauthorized" — these duplicate the headline.
218            continue;
219        }
220        let bullet = decorate_with_suggestion(&trimmed, argv, &mut fields_hinted);
221        out.bullets.push(bullet);
222    }
223
224    for field in &fields_hinted {
225        if let Some(hint) = field_hint(field) {
226            out.hints.push(hint.to_string());
227        }
228    }
229
230    out
231}
232
233/// Recursively walk a JSON value, pulling strings out of any key named
234/// `error`, `errors`, `message`, or `messages`. Accepts strings, arrays of
235/// strings, arrays of objects (recurse), and nested objects (recurse).
236fn collect_error_strings(value: &Value, out: &mut Vec<String>) {
237    const KEYS: &[&str] = &["errors", "error", "messages", "message"];
238    match value {
239        Value::Object(map) => {
240            for key in KEYS {
241                if let Some(v) = map.get(*key) {
242                    collect_strings_from(v, out);
243                }
244            }
245            // Also recurse into other object values so we can find nested
246            // error/message keys (e.g. NestJS wraps under `message.message`).
247            for (k, v) in map {
248                if !KEYS.contains(&k.as_str()) {
249                    collect_error_strings(v, out);
250                }
251            }
252        }
253        Value::Array(arr) => {
254            for v in arr {
255                collect_error_strings(v, out);
256            }
257        }
258        _ => {}
259    }
260}
261
262/// Helper: when we hit one of the error keys, accept multiple shapes.
263fn collect_strings_from(value: &Value, out: &mut Vec<String>) {
264    match value {
265        Value::String(s) => out.push(s.clone()),
266        Value::Array(arr) => {
267            for v in arr {
268                match v {
269                    Value::String(s) => out.push(s.clone()),
270                    Value::Object(_) => collect_error_strings(v, out),
271                    _ => {}
272                }
273            }
274        }
275        Value::Object(_) => collect_error_strings(value, out),
276        _ => {}
277    }
278}
279
280/// Returns the bullet text, possibly with a `did you mean '…'?` suffix and a
281/// `(N more)` truncation marker. Also records which fields had enum lists so
282/// the caller can attach helper hints.
283fn decorate_with_suggestion(
284    raw: &str,
285    argv: &[String],
286    fields_hinted: &mut BTreeSet<String>,
287) -> String {
288    let Some((field, candidates)) = parse_must_be_one_of(raw) else {
289        return raw.to_string();
290    };
291
292    fields_hinted.insert(field.clone());
293
294    // Find the argv value that's closest to any candidate, then attach DYM if
295    // the best match is within threshold.
296    let best = best_suggestion(argv, &candidates);
297
298    let display = truncate_candidate_list(&candidates, 5);
299    let mut bullet = format!("{field} must be one of: {display}");
300    if let Some((user_value, suggestion)) = best {
301        bullet.push_str(&format!(
302            " — did you mean '{suggestion}' (you passed '{user_value}')?"
303        ));
304    }
305    bullet
306}
307
308/// Parse `"<field> must be one of [the following values:] X, Y, Z"`.
309/// Returns the field name and the candidate list.
310fn parse_must_be_one_of(s: &str) -> Option<(String, Vec<String>)> {
311    let (field_part, rest) = s.split_once(" must be one of")?;
312    let field = field_part.trim();
313    if field.is_empty()
314        || !field
315            .chars()
316            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
317    {
318        return None;
319    }
320    // After "must be one of" we accept any of: " the following values: X, Y",
321    // ": X, Y", or " X, Y" (rare but possible).
322    let list_part = rest
323        .strip_prefix(" the following values: ")
324        .or_else(|| rest.strip_prefix(": "))
325        .or_else(|| rest.strip_prefix(' '))
326        .unwrap_or(rest)
327        .trim_end_matches('.');
328    let candidates: Vec<String> = list_part
329        .split(", ")
330        .map(|c| c.trim().to_string())
331        .filter(|c| !c.is_empty())
332        .collect();
333    if candidates.len() < 2 {
334        return None;
335    }
336    Some((field.to_string(), candidates))
337}
338
339/// Find the (argv-value, candidate) pair with smallest Levenshtein distance,
340/// gated on: distance ≤ 3 AND ≥ 3 leading chars shared with the candidate.
341fn best_suggestion(argv: &[String], candidates: &[String]) -> Option<(String, String)> {
342    let mut best: Option<(usize, String, String)> = None;
343    for arg in argv {
344        // Skip flags themselves and obviously-non-value tokens.
345        if arg.starts_with('-') || arg.is_empty() || arg.len() < 2 {
346            continue;
347        }
348        for cand in candidates {
349            let d = levenshtein(arg, cand);
350            if d > 3 {
351                continue;
352            }
353            if shared_prefix_len(arg, cand) < 3 {
354                continue;
355            }
356            match best.as_ref() {
357                None => best = Some((d, arg.clone(), cand.clone())),
358                Some((cur, _, _)) if d < *cur => best = Some((d, arg.clone(), cand.clone())),
359                _ => {}
360            }
361        }
362    }
363    best.map(|(_, a, c)| (a, c))
364}
365
366fn shared_prefix_len(a: &str, b: &str) -> usize {
367    a.chars().zip(b.chars()).take_while(|(x, y)| x == y).count()
368}
369
370/// Classic O(n*m) Levenshtein distance. n,m are tiny here (≤ ~40 chars), so
371/// this is fine.
372fn levenshtein(a: &str, b: &str) -> usize {
373    let a: Vec<char> = a.chars().collect();
374    let b: Vec<char> = b.chars().collect();
375    if a.is_empty() {
376        return b.len();
377    }
378    if b.is_empty() {
379        return a.len();
380    }
381    let mut prev: Vec<usize> = (0..=b.len()).collect();
382    let mut curr = vec![0usize; b.len() + 1];
383    for (i, ca) in a.iter().enumerate() {
384        curr[0] = i + 1;
385        for (j, cb) in b.iter().enumerate() {
386            let cost = if ca == cb { 0 } else { 1 };
387            curr[j + 1] = (curr[j] + 1).min(prev[j + 1] + 1).min(prev[j] + cost);
388        }
389        std::mem::swap(&mut prev, &mut curr);
390    }
391    prev[b.len()]
392}
393
394fn truncate_candidate_list(candidates: &[String], keep: usize) -> String {
395    if candidates.len() <= keep {
396        return candidates.join(", ");
397    }
398    let shown = candidates[..keep].join(", ");
399    let extra = candidates.len() - keep;
400    format!("{shown} ({extra} more)")
401}
402
403/// Maps known server-side field names to a follow-up command the user can run
404/// to discover valid values.
405/// Skip standard HTTP status-phrase strings that duplicate the headline.
406fn is_generic_label(s: &str) -> bool {
407    matches!(
408        s,
409        "Bad Request"
410            | "Unauthorized"
411            | "Forbidden"
412            | "Not Found"
413            | "Unprocessable Entity"
414            | "Too Many Requests"
415            | "Internal Server Error"
416            | "Service Unavailable"
417    )
418}
419
420fn field_hint(field: &str) -> Option<&'static str> {
421    match field {
422        "network" => Some("Run 'qn chain list' to see supported networks."),
423        "chain" => Some("Run 'qn chain list' to see supported chains."),
424        _ => None,
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431    use quicknode_sdk::errors::SdkError;
432
433    fn api_err_with(code: u16, body: &str) -> CliError {
434        CliError::Sdk(SdkError::Api {
435            status: reqwest::StatusCode::from_u16(code).unwrap(),
436            body: body.to_string(),
437        })
438    }
439
440    fn api_err(code: u16) -> CliError {
441        api_err_with(code, "{\"message\":\"boom\"}")
442    }
443
444    #[test]
445    fn exit_code_api_is_2() {
446        assert_eq!(exit_code_for(&api_err(404)), 2);
447    }
448
449    #[test]
450    fn exit_code_no_api_key_is_4() {
451        assert_eq!(exit_code_for(&CliError::NoApiKey), 4);
452    }
453
454    #[test]
455    fn exit_code_cancelled_is_5() {
456        assert_eq!(exit_code_for(&CliError::Cancelled), 5);
457    }
458
459    #[test]
460    fn renders_401_as_unauthorized() {
461        let msg = render(&api_err(401), false);
462        assert!(msg.contains("unauthorized"), "got: {msg}");
463    }
464
465    #[test]
466    fn renders_429_as_rate_limited() {
467        let msg = render(&api_err(429), false);
468        assert!(msg.contains("rate limited"), "got: {msg}");
469    }
470
471    #[test]
472    fn renders_5xx_with_status() {
473        let msg = render(&api_err(503), false);
474        assert!(msg.contains("503"), "got: {msg}");
475    }
476
477    #[test]
478    fn verbose_404_includes_body() {
479        let msg = render(&api_err(404), true);
480        assert!(msg.contains("boom"), "got: {msg}");
481    }
482
483    #[test]
484    fn non_verbose_404_omits_body() {
485        let msg = render(&api_err(404), false);
486        assert!(!msg.contains("boom"), "got: {msg}");
487    }
488
489    // ---- body parsing ----
490
491    #[test]
492    fn nestjs_shape_extracts_bullets() {
493        let body = r#"{"statusCode":400,"message":{"message":["network must be one of the following values: ethereum-mainnet, ethereum-sepolia, solana-mainnet","status must be one of the following values: active, paused, terminated"],"error":"Bad Request"}}"#;
494        let msg = render(&api_err_with(400, body), false);
495        assert!(msg.starts_with("Error: invalid request."), "got: {msg}");
496        assert!(msg.contains("• network must be one of:"), "got: {msg}");
497        assert!(msg.contains("• status must be one of:"), "got: {msg}");
498    }
499
500    #[test]
501    fn admin_shape_extracts_error_string() {
502        let body = r#"{"data":null,"error":"undefined method `chain' for nil"}"#;
503        let msg = render(&api_err_with(400, body), false);
504        assert!(msg.contains("undefined method"), "got: {msg}");
505    }
506
507    #[test]
508    fn empty_body_400_falls_through() {
509        let msg = render(&api_err_with(400, ""), false);
510        assert_eq!(msg, "Error: invalid request.");
511    }
512
513    #[test]
514    fn garbage_non_json_body_falls_back_to_raw() {
515        let body = "<html>oops</html>";
516        let msg = render(&api_err_with(400, body), false);
517        assert!(msg.contains("<html>oops</html>"), "got: {msg}");
518    }
519
520    #[test]
521    fn generic_errors_array_of_strings() {
522        let body = r#"{"errors":["first thing wrong","second thing wrong"]}"#;
523        let msg = render(&api_err_with(400, body), false);
524        assert!(msg.contains("• first thing wrong"), "got: {msg}");
525        assert!(msg.contains("• second thing wrong"), "got: {msg}");
526    }
527
528    #[test]
529    fn generic_errors_array_of_objects() {
530        let body = r#"{"errors":[{"message":"thing one"},{"message":"thing two"}]}"#;
531        let msg = render(&api_err_with(400, body), false);
532        assert!(msg.contains("• thing one"), "got: {msg}");
533        assert!(msg.contains("• thing two"), "got: {msg}");
534    }
535
536    #[test]
537    fn dedupes_repeated_strings() {
538        let body = r#"{"error":"same thing","message":"same thing"}"#;
539        let msg = render(&api_err_with(400, body), false);
540        let count = msg.matches("same thing").count();
541        assert_eq!(count, 1, "expected dedupe, got: {msg}");
542    }
543
544    #[test]
545    fn truncates_long_enum_list() {
546        // 10 candidates, only the first 5 should render inline.
547        let body = r#"{"message":"x must be one of a, b, c, d, e, f, g, h, i, j"}"#;
548        let msg = render(&api_err_with(400, body), false);
549        assert!(msg.contains("a, b, c, d, e (5 more)"), "got: {msg}");
550    }
551
552    #[test]
553    fn field_hint_appended_for_network() {
554        let body = r#"{"message":"network must be one of: ethereum-mainnet, solana-mainnet"}"#;
555        let msg = render(&api_err_with(400, body), false);
556        assert!(msg.contains("qn chain list"), "got: {msg}");
557    }
558
559    #[test]
560    fn verbose_appends_full_body() {
561        let body = r#"{"message":["network must be one of: a, b, c"]}"#;
562        let msg = render(&api_err_with(400, body), true);
563        assert!(msg.contains(body), "got: {msg}");
564    }
565
566    #[test]
567    fn levenshtein_basic() {
568        assert_eq!(levenshtein("", ""), 0);
569        assert_eq!(levenshtein("a", ""), 1);
570        assert_eq!(levenshtein("", "abc"), 3);
571        assert_eq!(levenshtein("kitten", "sitting"), 3);
572        assert_eq!(levenshtein("ethereum-mainnet", "ethereum-mainnet"), 0);
573        assert_eq!(levenshtein("ethereum-mainnetsds", "ethereum-mainnet"), 3);
574    }
575
576    #[test]
577    fn parse_must_be_one_of_happy_path() {
578        let (f, c) =
579            parse_must_be_one_of("network must be one of the following values: a, b, c").unwrap();
580        assert_eq!(f, "network");
581        assert_eq!(c, vec!["a", "b", "c"]);
582    }
583
584    #[test]
585    fn parse_must_be_one_of_no_following_values_prefix() {
586        let (f, c) = parse_must_be_one_of("status must be one of active, paused").unwrap();
587        assert_eq!(f, "status");
588        assert_eq!(c, vec!["active", "paused"]);
589    }
590
591    #[test]
592    fn parse_must_be_one_of_rejects_unrelated_strings() {
593        assert!(parse_must_be_one_of("some random error").is_none());
594    }
595
596    #[test]
597    fn truncate_candidate_list_under_keep_returns_all() {
598        assert_eq!(
599            truncate_candidate_list(&["a".into(), "b".into()], 5),
600            "a, b"
601        );
602    }
603
604    #[test]
605    fn best_suggestion_picks_closest_within_threshold() {
606        let candidates: Vec<String> =
607            vec!["ethereum-mainnet", "ethereum-sepolia", "solana-mainnet"]
608                .into_iter()
609                .map(String::from)
610                .collect();
611        let argv = vec!["ethereum-mainnetsds".to_string()];
612        let suggestion = best_suggestion(&argv, &candidates);
613        assert_eq!(
614            suggestion,
615            Some(("ethereum-mainnetsds".into(), "ethereum-mainnet".into()))
616        );
617    }
618
619    #[test]
620    fn best_suggestion_returns_none_if_too_far() {
621        let candidates: Vec<String> = vec!["ethereum-mainnet"]
622            .into_iter()
623            .map(String::from)
624            .collect();
625        let argv = vec!["sfjla".to_string()];
626        assert_eq!(best_suggestion(&argv, &candidates), None);
627    }
628
629    #[test]
630    fn best_suggestion_ignores_flag_tokens() {
631        let candidates: Vec<String> = vec!["chain"].into_iter().map(String::from).collect();
632        let argv = vec!["--chain".to_string()];
633        // "--chain" starts with "-", should be skipped.
634        assert_eq!(best_suggestion(&argv, &candidates), None);
635    }
636
637    #[test]
638    fn renders_5xx_skips_body_parsing() {
639        // We don't want stack-trace HTML on a 500 to be parsed as bullets.
640        let body = r#"{"message":"internal error"}"#;
641        let msg = render(&api_err_with(500, body), false);
642        assert!(!msg.contains("•"), "got: {msg}");
643    }
644}