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. 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
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)
57/// - 2: SdkError::Api (server returned a non-2xx)
58/// - 3: SdkError::Http (network failure)
59/// - 4: NoApiKey / BadConfig
60/// - 5: user cancelled or needs --yes
61pub 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
74/// Renders the error to a human-friendly message using the real process argv
75/// for did-you-mean suggestions. Use [`render_with_argv`] from tests where the
76/// simulated argv differs from the process argv.
77///
78/// Verbose mode appends the underlying body / source where available.
79pub 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
84/// Like [`render`] but uses the supplied argv values for did-you-mean lookup.
85pub 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
131/// Status codes have a small set of canonical user-facing messages. Validation
132/// (400/422) gets the structured body treatment from `parse_api_body`.
133fn 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    // For non-validation status codes, body is mostly noise (server stack traces,
146    // HTML error pages, etc). Only mine it for validation-class errors.
147    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        // We tried to parse and got nothing useful; surface the raw body so
162        // the user isn't left with a bare "invalid request." line.
163        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
188/// Parses a JSON-shaped API error body, extracting human-readable messages and
189/// (when the body contains "must be one of …" enum lists) appending
190/// did-you-mean suggestions against the user's argv.
191fn 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            // Skip "Bad Request" / "Unauthorized" — these duplicate the headline.
216            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
231/// Recursively walk a JSON value, pulling strings out of any key named
232/// `error`, `errors`, `message`, or `messages`. Accepts strings, arrays of
233/// strings, arrays of objects (recurse), and nested objects (recurse).
234fn 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            // Also recurse into other object values so we can find nested
244            // error/message keys (e.g. NestJS wraps under `message.message`).
245            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
260/// Helper: when we hit one of the error keys, accept multiple shapes.
261fn 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
278/// Returns the bullet text, possibly with a `did you mean '…'?` suffix and a
279/// `(N more)` truncation marker. Also records which fields had enum lists so
280/// the caller can attach helper hints.
281fn 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    // Find the argv value that's closest to any candidate, then attach DYM if
293    // the best match is within threshold.
294    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
306/// Parse `"<field> must be one of [the following values:] X, Y, Z"`.
307/// Returns the field name and the candidate list.
308fn 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    // After "must be one of" we accept any of: " the following values: X, Y",
319    // ": X, Y", or " X, Y" (rare but possible).
320    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
337/// Find the (argv-value, candidate) pair with smallest Levenshtein distance,
338/// gated on: distance ≤ 3 AND ≥ 3 leading chars shared with the candidate.
339fn best_suggestion(argv: &[String], candidates: &[String]) -> Option<(String, String)> {
340    let mut best: Option<(usize, String, String)> = None;
341    for arg in argv {
342        // Skip flags themselves and obviously-non-value tokens.
343        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
368/// Classic O(n*m) Levenshtein distance. n,m are tiny here (≤ ~40 chars), so
369/// this is fine.
370fn 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
401/// Maps known server-side field names to a follow-up command the user can run
402/// to discover valid values.
403/// Skip standard HTTP status-phrase strings that duplicate the headline.
404fn 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    // ---- body parsing ----
488
489    #[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        // 10 candidates, only the first 5 should render inline.
545        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        // "--chain" starts with "-", should be skipped.
632        assert_eq!(best_suggestion(&argv, &candidates), None);
633    }
634
635    #[test]
636    fn renders_5xx_skips_body_parsing() {
637        // We don't want stack-trace HTML on a 500 to be parsed as bullets.
638        let body = r#"{"message":"internal error"}"#;
639        let msg = render(&api_err_with(500, body), false);
640        assert!(!msg.contains("•"), "got: {msg}");
641    }
642}