use std::io::IsTerminal;
use quicknode_sdk::{
AdminConfig, HttpConfig, KvStoreConfig, QuicknodeSdk, SdkFullConfig, SqlConfig, StreamsConfig,
WebhooksConfig,
};
use crate::config;
use crate::errors::CliError;
use crate::output::{Format, OutputCtx};
#[derive(Debug, Clone, Default)]
pub struct GlobalArgs {
pub api_key: Option<String>,
pub config_file: Option<std::path::PathBuf>,
pub format: Option<Format>,
pub wide: bool,
pub no_color: bool,
pub quiet: bool,
pub verbose: bool,
pub no_input: bool,
pub yes_count: u8,
pub retries: u32,
pub base_url: Option<String>,
}
impl GlobalArgs {
pub fn resolve_format(&self, stdout_is_tty: bool) -> Format {
self.resolve_output(stdout_is_tty).0
}
pub fn resolve_output(&self, stdout_is_tty: bool) -> (Format, bool) {
let (cfg_format, cfg_wide) = self.load_output_config();
resolve_output_inner(self.format, self.wide, cfg_format, cfg_wide, stdout_is_tty)
}
pub fn resolve_config_path(&self) -> Option<std::path::PathBuf> {
self.config_file.clone().or_else(config::config_path)
}
fn load_output_config(&self) -> (Option<Format>, bool) {
let Some(p) = self.resolve_config_path() else {
return (None, false);
};
match config::load_from(&p) {
Ok(Some(cfg)) => (cfg.output.format, cfg.output.wide),
_ => (None, false),
}
}
}
fn resolve_output_inner(
flag_format: Option<Format>,
flag_wide: bool,
cfg_format: Option<Format>,
cfg_wide: bool,
stdout_is_tty: bool,
) -> (Format, bool) {
let format = flag_format.or(cfg_format).unwrap_or(if stdout_is_tty {
Format::Table
} else {
Format::Json
});
let wide = flag_wide || cfg_wide;
(format, wide)
}
pub fn user_agent() -> String {
format!(
"quicknode-cli/{} ({}-{})",
env!("CARGO_PKG_VERSION"),
std::env::consts::OS,
std::env::consts::ARCH,
)
}
pub fn sdk_config(api_key: String) -> SdkFullConfig {
let mut full = SdkFullConfig::from_api_key(api_key);
let mut headers = std::collections::HashMap::new();
headers.insert("User-Agent".to_string(), user_agent());
full.http = Some(HttpConfig {
headers: Some(headers),
..Default::default()
});
full
}
pub struct Ctx {
pub sdk: QuicknodeSdk,
pub out: OutputCtx,
pub global: GlobalArgs,
}
impl Ctx {
pub fn from_global(global: GlobalArgs) -> Result<Self, CliError> {
let config_path = global.resolve_config_path();
let stdout_is_tty = std::io::stdout().is_terminal();
let (format, wide) = global.resolve_output(stdout_is_tty);
let (api_key, _) = config::resolve_api_key(
global.api_key.as_deref(),
config_path.as_deref(),
false,
|| unreachable!("prompt disabled for non-auth commands"),
)?;
let mut full = sdk_config(api_key);
if let Some(base) = &global.base_url {
let trimmed = validate_base_url(base)?;
let trimmed = trimmed.as_str();
full.admin = Some(AdminConfig {
base_url: Some(format!("{trimmed}/v0/")),
});
full.streams = Some(StreamsConfig {
base_url: Some(format!("{trimmed}/streams/rest/v1/")),
});
full.webhooks = Some(WebhooksConfig {
base_url: Some(format!("{trimmed}/webhooks/rest/v1/")),
});
full.kvstore = Some(KvStoreConfig {
base_url: Some(format!("{trimmed}/kv/rest/v1/")),
});
full.sql = Some(SqlConfig {
base_url: Some(format!("{trimmed}/sql/rest/v1/")),
});
}
let sdk = QuicknodeSdk::new(&full)?;
let out = OutputCtx::detect_with(
format,
global.no_color,
global.quiet,
global.verbose,
wide,
stdout_is_tty,
std::env::var_os("NO_COLOR"),
std::env::var("TERM").ok(),
);
Ok(Self { sdk, out, global })
}
}
fn validate_base_url(base: &str) -> Result<String, CliError> {
let parsed = url::Url::parse(base)
.map_err(|_| CliError::Arg(format!("--base-url '{base}' is not a valid URL")))?;
match parsed.scheme() {
"http" | "https" => {}
other => {
return Err(CliError::Arg(format!(
"--base-url scheme '{other}' is not allowed; use http or https"
)))
}
}
if !parsed.username().is_empty() || parsed.password().is_some() {
return Err(CliError::Arg(
"--base-url must not contain userinfo (username/password)".into(),
));
}
if parsed.query().is_some() || parsed.fragment().is_some() {
return Err(CliError::Arg(
"--base-url must not contain a query string or fragment".into(),
));
}
if !matches!(parsed.path(), "" | "/") {
return Err(CliError::Arg("--base-url must not contain a path".into()));
}
Ok(base.trim_end_matches('/').to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn flag_format_wins_over_config_and_tty_default() {
let (f, _) =
resolve_output_inner(Some(Format::Json), false, Some(Format::Yaml), false, true);
assert_eq!(f, Format::Json);
let (f, _) =
resolve_output_inner(Some(Format::Json), false, Some(Format::Yaml), false, false);
assert_eq!(f, Format::Json);
}
#[test]
fn config_format_wins_over_tty_default() {
let (f, _) = resolve_output_inner(None, false, Some(Format::Yaml), false, true);
assert_eq!(f, Format::Yaml);
let (f, _) = resolve_output_inner(None, false, Some(Format::Yaml), false, false);
assert_eq!(f, Format::Yaml);
}
#[test]
fn default_is_table_when_stdout_is_a_tty() {
let (f, _) = resolve_output_inner(None, false, None, false, true);
assert_eq!(f, Format::Table);
}
#[test]
fn default_is_json_when_stdout_is_not_a_tty() {
let (f, _) = resolve_output_inner(None, false, None, false, false);
assert_eq!(f, Format::Json);
}
#[test]
fn config_toon_overrides_non_tty_default() {
let (f, _) = resolve_output_inner(None, false, Some(Format::Toon), false, false);
assert_eq!(f, Format::Toon);
}
#[test]
fn wide_is_additive_between_flag_and_config() {
let (_, w) = resolve_output_inner(None, true, None, false, true);
assert!(w);
let (_, w) = resolve_output_inner(None, false, None, true, true);
assert!(w);
let (_, w) = resolve_output_inner(None, true, None, true, true);
assert!(w);
let (_, w) = resolve_output_inner(None, false, None, false, true);
assert!(!w);
}
#[test]
fn base_url_accepts_plain_http_and_https() {
assert_eq!(
validate_base_url("https://api.quicknode.com").unwrap(),
"https://api.quicknode.com"
);
assert_eq!(
validate_base_url("http://127.0.0.1:8080/").unwrap(),
"http://127.0.0.1:8080"
);
}
#[test]
fn base_url_rejects_non_http_schemes() {
for bad in ["file:///etc/passwd", "ftp://x", "javascript:alert(1)"] {
assert!(validate_base_url(bad).is_err(), "should reject {bad}");
}
}
#[test]
fn base_url_rejects_userinfo() {
assert!(validate_base_url("https://user:pass@evil/").is_err());
assert!(validate_base_url("https://user@evil/").is_err());
}
#[test]
fn base_url_rejects_path_query_fragment() {
assert!(validate_base_url("https://x/extra/path").is_err());
assert!(validate_base_url("https://x/?q=1").is_err());
assert!(validate_base_url("https://x/#frag").is_err());
}
#[test]
fn base_url_rejects_garbage() {
assert!(validate_base_url("not a url").is_err());
assert!(validate_base_url("").is_err());
}
#[test]
fn user_agent_identifies_the_cli() {
let ua = user_agent();
assert!(ua.starts_with("quicknode-cli/"), "ua={ua}");
assert!(ua.contains(env!("CARGO_PKG_VERSION")), "ua={ua}");
}
#[test]
fn sdk_config_sets_the_user_agent_header_and_nothing_else() {
let cfg = sdk_config("k".to_string());
let http = cfg.http.expect("http config should be set");
assert_eq!(
http.headers.as_ref().and_then(|h| h.get("User-Agent")),
Some(&user_agent())
);
assert_eq!(http.timeout_secs, None);
assert_eq!(http.pool_max_idle_per_host, None);
}
}