1use std::io::IsTerminal;
6
7use quicknode_sdk::{
8 AdminConfig, HttpConfig, KvStoreConfig, QuicknodeSdk, SdkFullConfig, SqlConfig, StreamsConfig,
9 WebhooksConfig,
10};
11
12use crate::config;
13use crate::errors::CliError;
14use crate::output::{Format, OutputCtx};
15
16#[derive(Debug, Clone, Default)]
18pub struct GlobalArgs {
19 pub api_key: Option<String>,
20 pub config_file: Option<std::path::PathBuf>,
23 pub format: Option<Format>,
27 pub wide: bool,
28 pub no_color: bool,
29 pub quiet: bool,
30 pub verbose: bool,
31 pub no_input: bool,
32 pub yes_count: u8,
33 pub retries: u32,
36 pub base_url: Option<String>,
37}
38
39impl GlobalArgs {
40 pub fn resolve_format(&self, stdout_is_tty: bool) -> Format {
44 self.resolve_output(stdout_is_tty).0
45 }
46
47 pub fn resolve_output(&self, stdout_is_tty: bool) -> (Format, bool) {
55 let (cfg_format, cfg_wide) = self.load_output_config();
56 resolve_output_inner(self.format, self.wide, cfg_format, cfg_wide, stdout_is_tty)
57 }
58
59 pub fn resolve_config_path(&self) -> Option<std::path::PathBuf> {
61 self.config_file.clone().or_else(config::config_path)
62 }
63
64 fn load_output_config(&self) -> (Option<Format>, bool) {
65 let Some(p) = self.resolve_config_path() else {
66 return (None, false);
67 };
68 match config::load_from(&p) {
69 Ok(Some(cfg)) => (cfg.output.format, cfg.output.wide),
70 _ => (None, false),
71 }
72 }
73}
74
75fn resolve_output_inner(
79 flag_format: Option<Format>,
80 flag_wide: bool,
81 cfg_format: Option<Format>,
82 cfg_wide: bool,
83 stdout_is_tty: bool,
84) -> (Format, bool) {
85 let format = flag_format.or(cfg_format).unwrap_or(if stdout_is_tty {
86 Format::Table
87 } else {
88 Format::Json
89 });
90 let wide = flag_wide || cfg_wide;
91 (format, wide)
92}
93
94pub fn user_agent() -> String {
98 format!(
99 "quicknode-cli/{} ({}-{})",
100 env!("CARGO_PKG_VERSION"),
101 std::env::consts::OS,
102 std::env::consts::ARCH,
103 )
104}
105
106pub fn sdk_config(api_key: String) -> SdkFullConfig {
111 let mut full = SdkFullConfig::from_api_key(api_key);
112 let mut headers = std::collections::HashMap::new();
113 headers.insert("User-Agent".to_string(), user_agent());
114 full.http = Some(HttpConfig {
115 headers: Some(headers),
116 ..Default::default()
117 });
118 full
119}
120
121fn apply_base_url(full: &mut SdkFullConfig, trimmed: &str) {
125 full.admin = Some(AdminConfig {
126 base_url: Some(format!("{trimmed}/v0/")),
127 });
128 full.streams = Some(StreamsConfig {
129 base_url: Some(format!("{trimmed}/streams/rest/v1/")),
130 });
131 full.webhooks = Some(WebhooksConfig {
132 base_url: Some(format!("{trimmed}/webhooks/rest/v1/")),
133 });
134 full.kvstore = Some(KvStoreConfig {
135 base_url: Some(format!("{trimmed}/kv/rest/v1/")),
136 });
137 full.sql = Some(SqlConfig {
138 base_url: Some(format!("{trimmed}/sql/rest/v1/")),
139 });
140}
141
142pub fn sdk_config_with_base(
145 api_key: String,
146 base_url: Option<&str>,
147) -> Result<SdkFullConfig, CliError> {
148 let mut full = sdk_config(api_key);
149 if let Some(base) = base_url {
150 let trimmed = validate_base_url(base)?;
151 apply_base_url(&mut full, trimmed.as_str());
152 }
153 Ok(full)
154}
155
156pub struct Ctx {
157 pub sdk: QuicknodeSdk,
158 pub out: OutputCtx,
159 pub global: GlobalArgs,
160}
161
162impl Ctx {
163 pub fn from_global(global: GlobalArgs) -> Result<Self, CliError> {
169 let config_path = global.resolve_config_path();
170 let stdout_is_tty = std::io::stdout().is_terminal();
171 let (format, wide) = global.resolve_output(stdout_is_tty);
172
173 let (api_key, _) = config::resolve_api_key(
174 global.api_key.as_deref(),
175 config_path.as_deref(),
176 false,
177 || unreachable!("prompt disabled for non-auth commands"),
178 )?;
179
180 let mut full = sdk_config(api_key);
181
182 if let Some(base) = &global.base_url {
186 let trimmed = validate_base_url(base)?;
187 apply_base_url(&mut full, trimmed.as_str());
188 }
189
190 let sdk = QuicknodeSdk::new(&full)?;
191 let out = OutputCtx::detect_with(
192 format,
193 global.no_color,
194 global.quiet,
195 global.verbose,
196 wide,
197 stdout_is_tty,
198 std::env::var_os("NO_COLOR"),
199 std::env::var("TERM").ok(),
200 );
201
202 Ok(Self { sdk, out, global })
203 }
204}
205
206fn validate_base_url(base: &str) -> Result<String, CliError> {
211 let parsed = url::Url::parse(base)
212 .map_err(|_| CliError::Arg(format!("--base-url '{base}' is not a valid URL")))?;
213 match parsed.scheme() {
214 "http" | "https" => {}
215 other => {
216 return Err(CliError::Arg(format!(
217 "--base-url scheme '{other}' is not allowed; use http or https"
218 )))
219 }
220 }
221 if !parsed.username().is_empty() || parsed.password().is_some() {
222 return Err(CliError::Arg(
223 "--base-url must not contain userinfo (username/password)".into(),
224 ));
225 }
226 if parsed.query().is_some() || parsed.fragment().is_some() {
227 return Err(CliError::Arg(
228 "--base-url must not contain a query string or fragment".into(),
229 ));
230 }
231 if !matches!(parsed.path(), "" | "/") {
232 return Err(CliError::Arg("--base-url must not contain a path".into()));
233 }
234 Ok(base.trim_end_matches('/').to_string())
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240
241 #[test]
242 fn flag_format_wins_over_config_and_tty_default() {
243 let (f, _) =
244 resolve_output_inner(Some(Format::Json), false, Some(Format::Yaml), false, true);
245 assert_eq!(f, Format::Json);
246 let (f, _) =
247 resolve_output_inner(Some(Format::Json), false, Some(Format::Yaml), false, false);
248 assert_eq!(f, Format::Json);
249 }
250
251 #[test]
252 fn config_format_wins_over_tty_default() {
253 let (f, _) = resolve_output_inner(None, false, Some(Format::Yaml), false, true);
254 assert_eq!(f, Format::Yaml);
255 let (f, _) = resolve_output_inner(None, false, Some(Format::Yaml), false, false);
256 assert_eq!(f, Format::Yaml);
257 }
258
259 #[test]
260 fn default_is_table_when_stdout_is_a_tty() {
261 let (f, _) = resolve_output_inner(None, false, None, false, true);
262 assert_eq!(f, Format::Table);
263 }
264
265 #[test]
266 fn default_is_json_when_stdout_is_not_a_tty() {
267 let (f, _) = resolve_output_inner(None, false, None, false, false);
268 assert_eq!(f, Format::Json);
269 }
270
271 #[test]
272 fn config_toon_overrides_non_tty_default() {
273 let (f, _) = resolve_output_inner(None, false, Some(Format::Toon), false, false);
274 assert_eq!(f, Format::Toon);
275 }
276
277 #[test]
278 fn wide_is_additive_between_flag_and_config() {
279 let (_, w) = resolve_output_inner(None, true, None, false, true);
281 assert!(w);
282 let (_, w) = resolve_output_inner(None, false, None, true, true);
284 assert!(w);
285 let (_, w) = resolve_output_inner(None, true, None, true, true);
287 assert!(w);
288 let (_, w) = resolve_output_inner(None, false, None, false, true);
290 assert!(!w);
291 }
292
293 #[test]
294 fn base_url_accepts_plain_http_and_https() {
295 assert_eq!(
296 validate_base_url("https://api.quicknode.com").unwrap(),
297 "https://api.quicknode.com"
298 );
299 assert_eq!(
300 validate_base_url("http://127.0.0.1:8080/").unwrap(),
301 "http://127.0.0.1:8080"
302 );
303 }
304
305 #[test]
306 fn base_url_rejects_non_http_schemes() {
307 for bad in ["file:///etc/passwd", "ftp://x", "javascript:alert(1)"] {
308 assert!(validate_base_url(bad).is_err(), "should reject {bad}");
309 }
310 }
311
312 #[test]
313 fn base_url_rejects_userinfo() {
314 assert!(validate_base_url("https://user:pass@evil/").is_err());
315 assert!(validate_base_url("https://user@evil/").is_err());
316 }
317
318 #[test]
319 fn base_url_rejects_path_query_fragment() {
320 assert!(validate_base_url("https://x/extra/path").is_err());
321 assert!(validate_base_url("https://x/?q=1").is_err());
322 assert!(validate_base_url("https://x/#frag").is_err());
323 }
324
325 #[test]
326 fn base_url_rejects_garbage() {
327 assert!(validate_base_url("not a url").is_err());
328 assert!(validate_base_url("").is_err());
329 }
330
331 #[test]
332 fn user_agent_identifies_the_cli() {
333 let ua = user_agent();
334 assert!(ua.starts_with("quicknode-cli/"), "ua={ua}");
335 assert!(ua.contains(env!("CARGO_PKG_VERSION")), "ua={ua}");
336 }
337
338 #[test]
339 fn sdk_config_sets_the_user_agent_header_and_nothing_else() {
340 let cfg = sdk_config("k".to_string());
341 let http = cfg.http.expect("http config should be set");
342 assert_eq!(
343 http.headers.as_ref().and_then(|h| h.get("User-Agent")),
344 Some(&user_agent())
345 );
346 assert_eq!(http.timeout_secs, None);
348 assert_eq!(http.pool_max_idle_per_host, None);
349 }
350}