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