Skip to main content

qn/
context.rs

1//! Runtime context shared by every command.
2//!
3//! Builds the `QuicknodeSdk` from `GlobalArgs` and attaches an `OutputCtx`.
4
5use 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/// Top-level flags inherited by every subcommand.
17#[derive(Debug, Clone, Default)]
18pub struct GlobalArgs {
19    pub api_key: Option<String>,
20    /// `--config-file`: alternate config TOML. `None` means the default path
21    /// (`~/.config/qn/config.toml`).
22    pub config_file: Option<std::path::PathBuf>,
23    /// `None` means the user didn't pass `--format`; resolve via config file
24    /// (then the TTY-aware default: `Table` on a TTY, `Json` off) when we
25    /// build the [`Ctx`].
26    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    /// Max automatic retries for read-only API calls (see `crate::retry`).
34    /// `Default` yields 0 (no retries) — the CLI default of 3 comes from clap.
35    pub retries: u32,
36    pub base_url: Option<String>,
37}
38
39impl GlobalArgs {
40    /// Resolve the output format: CLI flag > config file > TTY-aware default
41    /// (`Table` on a TTY, `Json` off).
42    /// Used by [`Ctx::from_global`] and `auth` (which doesn't build a Ctx).
43    pub fn resolve_format(&self, stdout_is_tty: bool) -> Format {
44        self.resolve_output(stdout_is_tty).0
45    }
46
47    /// Resolve `(format, wide)` together so we only read the config file once.
48    ///
49    /// For each: CLI flag > config file > built-in default. The format default
50    /// is TTY-aware: `Table` when stdout is a terminal, `Json` otherwise (so
51    /// agents / piped callers get a structured format by default). `--wide` is
52    /// purely additive — the flag sets it true; the config file can also set
53    /// it true; otherwise it's false.
54    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    /// The config file to read: `--config-file` if given, else the default path.
60    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
75/// Pure form of [`GlobalArgs::resolve_output`] — separated so it can be
76/// exhaustively unit-tested without touching the real config file. CLI values
77/// win; otherwise we fall back to config; otherwise the TTY-aware default.
78fn 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
94/// The `User-Agent` sent with every API request. Mirrors the SDK's own shape
95/// (`quicknode-sdk-<lang>/<ver> (<os>-<arch>; …)`) with the CLI as the product:
96/// `quicknode-cli/<version> (<os>-<arch>)`.
97pub 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
106/// Base SDK config shared by every construction site (`Ctx::from_global` and
107/// the `auth` commands): the API key plus the CLI `User-Agent`. Custom headers
108/// in `HttpConfig` override SDK-managed headers of the same name, which is the
109/// SDK's supported way to replace its auto-generated `User-Agent`.
110pub 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
121/// Points every sub-client at a custom host, suffixing each with its own base
122/// path. Shared by `Ctx::from_global` and the `auth` commands so `--base-url`
123/// applies uniformly (useful for wiremock tests and on-prem mirrors).
124fn 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
142/// Like [`sdk_config`] but also honors an optional `--base-url` override.
143/// Used by the `auth` commands, which build the SDK outside [`Ctx`].
144pub 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    /// Construct the SDK + output ctx from `global`. Resolves the API key per
164    /// the documented precedence: flag > config file (`--config-file` path if
165    /// given, else the default). If neither supplies a key we return
166    /// `CliError::NoApiKey` — regular commands do not prompt; the user is
167    /// directed to `qn auth login`.
168    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        // --base-url applies to every sub-client. Useful for wiremock tests and
183        // on-prem mirrors. Each sub-client has its own base path under the host
184        // so we suffix correctly.
185        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
206/// Validates a user-supplied `--base-url` and returns it with any trailing
207/// slash stripped. Rejects non-http(s) schemes, embedded userinfo, query/
208/// fragment, and non-root paths so we can't accidentally splice attacker-
209/// controlled segments into the SDK's hard-coded sub-client paths.
210fn 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        // Flag alone.
280        let (_, w) = resolve_output_inner(None, true, None, false, true);
281        assert!(w);
282        // Config alone.
283        let (_, w) = resolve_output_inner(None, false, None, true, true);
284        assert!(w);
285        // Both.
286        let (_, w) = resolve_output_inner(None, true, None, true, true);
287        assert!(w);
288        // Neither.
289        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        // SDK defaults (timeout, pooling) must stay untouched.
347        assert_eq!(http.timeout_secs, None);
348        assert_eq!(http.pool_max_idle_per_host, None);
349    }
350}