use std::collections::BTreeSet;
use std::path::PathBuf;
use quicknode_sdk::errors::{HttpKind, SdkError};
use serde_json::Value;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum CliError {
#[error("no API key found. Run 'qn auth login', or pass --api-key or --config-file")]
NoApiKey,
#[error("config file at {path} is invalid: {source}")]
BadConfig {
path: PathBuf,
#[source]
source: toml::de::Error,
},
#[error("could not write config file at {path}: {source}")]
ConfigWrite {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("invalid argument: {0}")]
Arg(String),
#[error("operation cancelled")]
Cancelled,
#[error(
"operation requires confirmation; pass --yes to proceed without an interactive prompt"
)]
NeedsConfirmation,
#[error(transparent)]
Sdk(#[from] SdkError),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Json(#[from] serde_json::Error),
#[error("could not serialize output: {0}")]
Format(String),
}
pub fn exit_code_for(err: &CliError) -> i32 {
match err {
CliError::NoApiKey | CliError::BadConfig { .. } | CliError::ConfigWrite { .. } => 4,
CliError::Cancelled | CliError::NeedsConfirmation => 5,
CliError::Sdk(sdk) => match sdk {
SdkError::Api { .. } => 2,
SdkError::Http(_) => 3,
_ => 1,
},
_ => 1,
}
}
pub fn render(err: &CliError, verbose: bool) -> String {
let argv: Vec<String> = std::env::args().skip(1).collect();
render_with_argv(err, verbose, &argv)
}
pub fn render_with_argv(err: &CliError, verbose: bool, argv: &[String]) -> String {
match err {
CliError::Sdk(SdkError::Api { status, body }) => {
render_api_error(status.as_u16(), body, verbose, argv)
}
CliError::Sdk(sdk @ SdkError::Http(_)) => {
let msg = match sdk.http_kind() {
Some(HttpKind::Timeout) => {
"request timed out. Check your connection and try again."
}
Some(HttpKind::Connect) => {
"could not connect to api.quicknode.com. Check your network."
}
_ => "HTTP transport failure talking to the Quicknode API.",
};
if verbose {
format!("Error: {msg}\n{sdk}")
} else {
format!("Error: {msg}")
}
}
CliError::Sdk(SdkError::Decode { body, .. }) => {
if verbose {
format!("Error: unexpected response shape from API.\n{body}")
} else {
"Error: unexpected response shape from API. Re-run with --verbose to see the body."
.to_string()
}
}
CliError::BadConfig { path, source } => {
if verbose {
format!(
"Error: config file at {} is invalid: {source}",
path.display()
)
} else {
format!(
"Error: config file at {} is invalid. Re-run with --verbose for details.",
path.display()
)
}
}
other => format!("Error: {other}"),
}
}
fn render_api_error(code: u16, body: &str, verbose: bool, argv: &[String]) -> String {
let headline = match code {
400 | 422 => "invalid request.".to_string(),
401 | 403 => "unauthorized. Check your API key with 'qn auth whoami'.".to_string(),
404 => "not found.".to_string(),
429 => "rate limited by the Quicknode API. Try again shortly.".to_string(),
500..=599 => format!(
"something went wrong (HTTP {code}). Please try again; if the problem persists, \
contact support at https://support.quicknode.com."
),
_ => format!("API returned HTTP {code}."),
};
let parsed = if matches!(code, 400 | 422) {
parse_api_body(body, argv)
} else {
ParsedApiBody::default()
};
let mut out = format!("Error: {headline}");
if !parsed.bullets.is_empty() {
for bullet in &parsed.bullets {
out.push_str("\n • ");
out.push_str(bullet);
}
} else if matches!(code, 400 | 422) && !body.is_empty() && !verbose {
out.push('\n');
out.push_str(body.trim());
}
for hint in &parsed.hints {
out.push('\n');
out.push_str(hint);
}
if verbose && !body.is_empty() {
out.push('\n');
out.push_str(body);
} else if matches!(code, 400 | 422) && !parsed.bullets.is_empty() && !body.is_empty() {
out.push_str("\nRe-run with --verbose for the full response body.");
}
out
}
#[derive(Default)]
struct ParsedApiBody {
bullets: Vec<String>,
hints: Vec<String>,
}
fn parse_api_body(body: &str, argv: &[String]) -> ParsedApiBody {
let mut out = ParsedApiBody::default();
if body.is_empty() {
return out;
}
let Ok(value) = serde_json::from_str::<Value>(body) else {
return out;
};
let mut raw_strings: Vec<String> = Vec::new();
collect_error_strings(&value, &mut raw_strings);
if raw_strings.is_empty() {
return out;
}
let mut seen: BTreeSet<String> = BTreeSet::new();
let mut fields_hinted: BTreeSet<String> = BTreeSet::new();
for s in raw_strings {
let trimmed = s.trim().to_string();
if trimmed.is_empty() || !seen.insert(trimmed.clone()) {
continue;
}
if is_generic_label(&trimmed) {
continue;
}
let bullet = decorate_with_suggestion(&trimmed, argv, &mut fields_hinted);
out.bullets.push(bullet);
}
for field in &fields_hinted {
if let Some(hint) = field_hint(field) {
out.hints.push(hint.to_string());
}
}
out
}
fn collect_error_strings(value: &Value, out: &mut Vec<String>) {
const KEYS: &[&str] = &["errors", "error", "messages", "message"];
match value {
Value::Object(map) => {
for key in KEYS {
if let Some(v) = map.get(*key) {
collect_strings_from(v, out);
}
}
for (k, v) in map {
if !KEYS.contains(&k.as_str()) {
collect_error_strings(v, out);
}
}
}
Value::Array(arr) => {
for v in arr {
collect_error_strings(v, out);
}
}
_ => {}
}
}
fn collect_strings_from(value: &Value, out: &mut Vec<String>) {
match value {
Value::String(s) => out.push(s.clone()),
Value::Array(arr) => {
for v in arr {
match v {
Value::String(s) => out.push(s.clone()),
Value::Object(_) => collect_error_strings(v, out),
_ => {}
}
}
}
Value::Object(_) => collect_error_strings(value, out),
_ => {}
}
}
fn decorate_with_suggestion(
raw: &str,
argv: &[String],
fields_hinted: &mut BTreeSet<String>,
) -> String {
let Some((field, candidates)) = parse_must_be_one_of(raw) else {
return raw.to_string();
};
fields_hinted.insert(field.clone());
let best = best_suggestion(argv, &candidates);
let display = truncate_candidate_list(&candidates, 5);
let mut bullet = format!("{field} must be one of: {display}");
if let Some((user_value, suggestion)) = best {
bullet.push_str(&format!(
" — did you mean '{suggestion}' (you passed '{user_value}')?"
));
}
bullet
}
fn parse_must_be_one_of(s: &str) -> Option<(String, Vec<String>)> {
let (field_part, rest) = s.split_once(" must be one of")?;
let field = field_part.trim();
if field.is_empty()
|| !field
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
{
return None;
}
let list_part = rest
.strip_prefix(" the following values: ")
.or_else(|| rest.strip_prefix(": "))
.or_else(|| rest.strip_prefix(' '))
.unwrap_or(rest)
.trim_end_matches('.');
let candidates: Vec<String> = list_part
.split(", ")
.map(|c| c.trim().to_string())
.filter(|c| !c.is_empty())
.collect();
if candidates.len() < 2 {
return None;
}
Some((field.to_string(), candidates))
}
fn best_suggestion(argv: &[String], candidates: &[String]) -> Option<(String, String)> {
let mut best: Option<(usize, String, String)> = None;
for arg in argv {
if arg.starts_with('-') || arg.is_empty() || arg.len() < 2 {
continue;
}
for cand in candidates {
let d = levenshtein(arg, cand);
if d > 3 {
continue;
}
if shared_prefix_len(arg, cand) < 3 {
continue;
}
match best.as_ref() {
None => best = Some((d, arg.clone(), cand.clone())),
Some((cur, _, _)) if d < *cur => best = Some((d, arg.clone(), cand.clone())),
_ => {}
}
}
}
best.map(|(_, a, c)| (a, c))
}
fn shared_prefix_len(a: &str, b: &str) -> usize {
a.chars().zip(b.chars()).take_while(|(x, y)| x == y).count()
}
fn levenshtein(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
if a.is_empty() {
return b.len();
}
if b.is_empty() {
return a.len();
}
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut curr = vec![0usize; b.len() + 1];
for (i, ca) in a.iter().enumerate() {
curr[0] = i + 1;
for (j, cb) in b.iter().enumerate() {
let cost = if ca == cb { 0 } else { 1 };
curr[j + 1] = (curr[j] + 1).min(prev[j + 1] + 1).min(prev[j] + cost);
}
std::mem::swap(&mut prev, &mut curr);
}
prev[b.len()]
}
fn truncate_candidate_list(candidates: &[String], keep: usize) -> String {
if candidates.len() <= keep {
return candidates.join(", ");
}
let shown = candidates[..keep].join(", ");
let extra = candidates.len() - keep;
format!("{shown} ({extra} more)")
}
fn is_generic_label(s: &str) -> bool {
matches!(
s,
"Bad Request"
| "Unauthorized"
| "Forbidden"
| "Not Found"
| "Unprocessable Entity"
| "Too Many Requests"
| "Internal Server Error"
| "Service Unavailable"
)
}
fn field_hint(field: &str) -> Option<&'static str> {
match field {
"network" => Some("Run 'qn chain list' to see supported networks."),
"chain" => Some("Run 'qn chain list' to see supported chains."),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use quicknode_sdk::errors::SdkError;
fn api_err_with(code: u16, body: &str) -> CliError {
CliError::Sdk(SdkError::Api {
status: reqwest::StatusCode::from_u16(code).unwrap(),
body: body.to_string(),
})
}
fn api_err(code: u16) -> CliError {
api_err_with(code, "{\"message\":\"boom\"}")
}
#[test]
fn exit_code_api_is_2() {
assert_eq!(exit_code_for(&api_err(404)), 2);
}
#[test]
fn exit_code_no_api_key_is_4() {
assert_eq!(exit_code_for(&CliError::NoApiKey), 4);
}
#[test]
fn exit_code_cancelled_is_5() {
assert_eq!(exit_code_for(&CliError::Cancelled), 5);
}
#[test]
fn renders_401_as_unauthorized() {
let msg = render(&api_err(401), false);
assert!(msg.contains("unauthorized"), "got: {msg}");
}
#[test]
fn renders_429_as_rate_limited() {
let msg = render(&api_err(429), false);
assert!(msg.contains("rate limited"), "got: {msg}");
}
#[test]
fn renders_5xx_with_status() {
let msg = render(&api_err(503), false);
assert!(msg.contains("503"), "got: {msg}");
}
#[test]
fn verbose_404_includes_body() {
let msg = render(&api_err(404), true);
assert!(msg.contains("boom"), "got: {msg}");
}
#[test]
fn non_verbose_404_omits_body() {
let msg = render(&api_err(404), false);
assert!(!msg.contains("boom"), "got: {msg}");
}
#[test]
fn nestjs_shape_extracts_bullets() {
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"}}"#;
let msg = render(&api_err_with(400, body), false);
assert!(msg.starts_with("Error: invalid request."), "got: {msg}");
assert!(msg.contains("• network must be one of:"), "got: {msg}");
assert!(msg.contains("• status must be one of:"), "got: {msg}");
}
#[test]
fn admin_shape_extracts_error_string() {
let body = r#"{"data":null,"error":"undefined method `chain' for nil"}"#;
let msg = render(&api_err_with(400, body), false);
assert!(msg.contains("undefined method"), "got: {msg}");
}
#[test]
fn empty_body_400_falls_through() {
let msg = render(&api_err_with(400, ""), false);
assert_eq!(msg, "Error: invalid request.");
}
#[test]
fn garbage_non_json_body_falls_back_to_raw() {
let body = "<html>oops</html>";
let msg = render(&api_err_with(400, body), false);
assert!(msg.contains("<html>oops</html>"), "got: {msg}");
}
#[test]
fn generic_errors_array_of_strings() {
let body = r#"{"errors":["first thing wrong","second thing wrong"]}"#;
let msg = render(&api_err_with(400, body), false);
assert!(msg.contains("• first thing wrong"), "got: {msg}");
assert!(msg.contains("• second thing wrong"), "got: {msg}");
}
#[test]
fn generic_errors_array_of_objects() {
let body = r#"{"errors":[{"message":"thing one"},{"message":"thing two"}]}"#;
let msg = render(&api_err_with(400, body), false);
assert!(msg.contains("• thing one"), "got: {msg}");
assert!(msg.contains("• thing two"), "got: {msg}");
}
#[test]
fn dedupes_repeated_strings() {
let body = r#"{"error":"same thing","message":"same thing"}"#;
let msg = render(&api_err_with(400, body), false);
let count = msg.matches("same thing").count();
assert_eq!(count, 1, "expected dedupe, got: {msg}");
}
#[test]
fn truncates_long_enum_list() {
let body = r#"{"message":"x must be one of a, b, c, d, e, f, g, h, i, j"}"#;
let msg = render(&api_err_with(400, body), false);
assert!(msg.contains("a, b, c, d, e (5 more)"), "got: {msg}");
}
#[test]
fn field_hint_appended_for_network() {
let body = r#"{"message":"network must be one of: ethereum-mainnet, solana-mainnet"}"#;
let msg = render(&api_err_with(400, body), false);
assert!(msg.contains("qn chain list"), "got: {msg}");
}
#[test]
fn verbose_appends_full_body() {
let body = r#"{"message":["network must be one of: a, b, c"]}"#;
let msg = render(&api_err_with(400, body), true);
assert!(msg.contains(body), "got: {msg}");
}
#[test]
fn levenshtein_basic() {
assert_eq!(levenshtein("", ""), 0);
assert_eq!(levenshtein("a", ""), 1);
assert_eq!(levenshtein("", "abc"), 3);
assert_eq!(levenshtein("kitten", "sitting"), 3);
assert_eq!(levenshtein("ethereum-mainnet", "ethereum-mainnet"), 0);
assert_eq!(levenshtein("ethereum-mainnetsds", "ethereum-mainnet"), 3);
}
#[test]
fn parse_must_be_one_of_happy_path() {
let (f, c) =
parse_must_be_one_of("network must be one of the following values: a, b, c").unwrap();
assert_eq!(f, "network");
assert_eq!(c, vec!["a", "b", "c"]);
}
#[test]
fn parse_must_be_one_of_no_following_values_prefix() {
let (f, c) = parse_must_be_one_of("status must be one of active, paused").unwrap();
assert_eq!(f, "status");
assert_eq!(c, vec!["active", "paused"]);
}
#[test]
fn parse_must_be_one_of_rejects_unrelated_strings() {
assert!(parse_must_be_one_of("some random error").is_none());
}
#[test]
fn truncate_candidate_list_under_keep_returns_all() {
assert_eq!(
truncate_candidate_list(&["a".into(), "b".into()], 5),
"a, b"
);
}
#[test]
fn best_suggestion_picks_closest_within_threshold() {
let candidates: Vec<String> =
vec!["ethereum-mainnet", "ethereum-sepolia", "solana-mainnet"]
.into_iter()
.map(String::from)
.collect();
let argv = vec!["ethereum-mainnetsds".to_string()];
let suggestion = best_suggestion(&argv, &candidates);
assert_eq!(
suggestion,
Some(("ethereum-mainnetsds".into(), "ethereum-mainnet".into()))
);
}
#[test]
fn best_suggestion_returns_none_if_too_far() {
let candidates: Vec<String> = vec!["ethereum-mainnet"]
.into_iter()
.map(String::from)
.collect();
let argv = vec!["sfjla".to_string()];
assert_eq!(best_suggestion(&argv, &candidates), None);
}
#[test]
fn best_suggestion_ignores_flag_tokens() {
let candidates: Vec<String> = vec!["chain"].into_iter().map(String::from).collect();
let argv = vec!["--chain".to_string()];
assert_eq!(best_suggestion(&argv, &candidates), None);
}
#[test]
fn renders_5xx_skips_body_parsing() {
let body = r#"{"message":"internal error"}"#;
let msg = render(&api_err_with(500, body), false);
assert!(!msg.contains("•"), "got: {msg}");
}
}