Skip to main content

omni_dev/cli/
snowflake.rs

1//! `omni-dev snowflake` — a thin client that runs SQL through the daemon's
2//! multiplexed, authenticate-once Snowflake sessions.
3//!
4//! Lifecycle stays on `omni-dev daemon` (`start`/`stop`/`status`/`restart`);
5//! these subcommands only send `query`/`sessions`/`disconnect` ops to the
6//! `snowflake` service over the daemon's Unix control socket.
7
8use std::fs;
9use std::io::Read as _;
10use std::path::{Path, PathBuf};
11
12use anyhow::{bail, Context, Result};
13use chrono::Utc;
14use clap::{ArgGroup, Parser, Subcommand, ValueEnum};
15use serde_json::{json, Value};
16
17use crate::cli::format::TableOrJson;
18use crate::daemon::client::DaemonClient;
19use crate::daemon::protocol::DaemonEnvelope;
20use crate::daemon::server;
21use crate::snowflake::QueryRequest;
22use crate::utils::env::EnvSource;
23use crate::utils::settings::SettingsEnv;
24
25/// The `snowflake` service routing key on the daemon control socket.
26const SERVICE: &str = "snowflake";
27
28/// Snowflake: authenticate once via external-browser SSO and run arbitrary SQL
29/// across any account, multiplexed by the daemon.
30#[derive(Parser)]
31pub struct SnowflakeCommand {
32    /// The Snowflake subcommand to execute.
33    #[command(subcommand)]
34    pub command: SnowflakeSubcommands,
35}
36
37/// Snowflake subcommands.
38#[derive(Subcommand)]
39pub enum SnowflakeSubcommands {
40    /// Run SQL (from an argument or stdin) through a multiplexed session.
41    Query(QueryCommand),
42    /// List active multiplexed sessions.
43    Sessions(SessionsCommand),
44    /// Cancel (abort) the running query on a pool without evicting the session:
45    /// one `(account, user)` pool, a pool by id, or all pools.
46    Cancel(CancelCommand),
47    /// Disconnect (evict) sessions: one `(account, user)` pool, a pool by id, or
48    /// all pools.
49    Disconnect(DisconnectCommand),
50}
51
52impl SnowflakeCommand {
53    /// Executes the Snowflake command.
54    pub async fn execute(self) -> Result<()> {
55        match self.command {
56            SnowflakeSubcommands::Query(cmd) => cmd.execute().await,
57            SnowflakeSubcommands::Sessions(cmd) => cmd.execute().await,
58            SnowflakeSubcommands::Cancel(cmd) => cmd.execute().await,
59            SnowflakeSubcommands::Disconnect(cmd) => cmd.execute().await,
60        }
61    }
62}
63
64/// Output format for query results.
65#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)]
66#[value(rename_all = "kebab-case")]
67pub enum OutputFormat {
68    /// Pretty-printed JSON (default).
69    #[default]
70    Json,
71    /// YAML.
72    Yaml,
73    /// Comma-separated values (RFC 4180 quoting), one header row plus one row
74    /// per result row.
75    Csv,
76    /// Tab-separated values (same quoting as CSV, tab delimiter).
77    Tsv,
78}
79
80/// Runs arbitrary SQL through the `(account, user)` session, authenticating it
81/// on first use (a browser may open for sign-in).
82#[derive(Parser)]
83pub struct QueryCommand {
84    /// Target account. Falls back to `SNOWFLAKE_ACCOUNT` / settings.json.
85    #[arg(long)]
86    pub account: Option<String>,
87    /// Authenticating user. Falls back to `SNOWFLAKE_USER` / settings.json.
88    #[arg(long)]
89    pub user: Option<String>,
90    /// Per-query warehouse (`USE WAREHOUSE`).
91    #[arg(long)]
92    pub warehouse: Option<String>,
93    /// Per-query role (`USE ROLE`).
94    #[arg(long)]
95    pub role: Option<String>,
96    /// Per-query database (`USE DATABASE`).
97    #[arg(long)]
98    pub database: Option<String>,
99    /// Per-query schema (`USE SCHEMA`).
100    #[arg(long)]
101    pub schema: Option<String>,
102    /// Control-socket path. Defaults to the per-user runtime location.
103    #[arg(long, value_name = "PATH")]
104    pub socket: Option<PathBuf>,
105    /// Output format.
106    #[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Json)]
107    pub output: OutputFormat,
108    /// Deprecated: use `-o`/`--output` instead.
109    #[arg(long = "format", hide = true)]
110    pub format: Option<OutputFormat>,
111    /// Output file (writes to stdout if omitted).
112    #[arg(long = "out-file", value_name = "PATH")]
113    pub out_file: Option<String>,
114    /// SQL to run. Read from stdin when omitted.
115    pub sql: Option<String>,
116}
117
118impl QueryCommand {
119    /// Executes the query command.
120    pub async fn execute(mut self) -> Result<()> {
121        if let Some(format) = self.format.take() {
122            eprintln!("warning: --format is deprecated; use -o/--output instead");
123            self.output = format;
124        }
125        let sql = match self.sql.take() {
126            Some(sql) => sql,
127            None => read_stdin()?,
128        };
129        if sql.trim().is_empty() {
130            bail!("no SQL provided (pass it as an argument or on stdin)");
131        }
132
133        let payload = serde_json::to_value(self.request(sql, &SettingsEnv::load()))?;
134
135        // First-time auth for an (account, user) opens a browser; warn on stderr
136        // so it doesn't pollute the JSON/YAML on stdout.
137        eprintln!("snowflake: a browser may open for first-time sign-in…");
138
139        let socket = server::resolve_socket(self.socket)?;
140        let result = call(&socket, "query", payload).await?;
141        let text = format_output(&result, self.output)?;
142        write_output(&text, self.out_file.as_deref())
143    }
144
145    /// Builds the query request, filling each unset identity/context field from
146    /// `env` so `SNOWFLAKE_*` defaults — including profile-scoped ones selected
147    /// via `--profile` / `OMNI_DEV_PROFILE` — resolve in this client process
148    /// rather than against the daemon's startup environment (#1110).
149    fn request(&self, sql: String, env: &impl EnvSource) -> QueryRequest {
150        let mut req = QueryRequest {
151            account: self.account.clone(),
152            user: self.user.clone(),
153            warehouse: self.warehouse.clone(),
154            role: self.role.clone(),
155            database: self.database.clone(),
156            schema: self.schema.clone(),
157            sql,
158        };
159        req.fill_defaults_from(env);
160        req
161    }
162}
163
164/// Lists the daemon's active multiplexed sessions.
165#[derive(Parser)]
166pub struct SessionsCommand {
167    /// Control-socket path. Defaults to the per-user runtime location.
168    #[arg(long, value_name = "PATH")]
169    pub socket: Option<PathBuf>,
170    /// Output format.
171    #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
172    pub output: TableOrJson,
173    /// Deprecated: use `-o`/`--output json` instead.
174    #[arg(long, hide = true)]
175    pub json: bool,
176}
177
178impl SessionsCommand {
179    /// Executes the sessions command.
180    pub async fn execute(mut self) -> Result<()> {
181        if self.json {
182            eprintln!("warning: --json is deprecated; use -o/--output json instead");
183            self.output = TableOrJson::Json;
184        }
185        let socket = server::resolve_socket(self.socket)?;
186        let result = call(&socket, "sessions", Value::Null).await?;
187        match self.output {
188            TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&result)?),
189            TableOrJson::Table => println!("{}", render_sessions(&result)),
190        }
191        Ok(())
192    }
193}
194
195/// Renders a `sessions` reply as a human-readable table: a header, one row per
196/// pool, and an indented line per authenticated session (what each is doing).
197/// Returns a placeholder line when there are no active sessions.
198fn render_sessions(result: &Value) -> String {
199    let sessions = result
200        .get("sessions")
201        .and_then(Value::as_array)
202        .map(Vec::as_slice)
203        .unwrap_or_default();
204    if sessions.is_empty() {
205        return "No active sessions.".to_string();
206    }
207    let mut out = format!(
208        "{:<4} {:<28} {:<28} {:>8} {:>7}",
209        "ID", "ACCOUNT", "USER", "SESSIONS", "QUERIES"
210    );
211    for session in sessions {
212        let id = session.get("id").and_then(Value::as_u64).unwrap_or(0);
213        let account = session.get("account").and_then(Value::as_str).unwrap_or("");
214        let user = session.get("user").and_then(Value::as_str).unwrap_or("");
215        let live = session.get("sessions").and_then(Value::as_u64).unwrap_or(0);
216        let max = session
217            .get("max_sessions")
218            .and_then(Value::as_u64)
219            .unwrap_or(0);
220        let count = session
221            .get("query_count")
222            .and_then(Value::as_u64)
223            .unwrap_or(0);
224        let pool = format!("{live}/{max}");
225        out.push_str(&format!(
226            "\n{id:<4} {account:<28} {user:<28} {pool:>8} {count:>7}"
227        ));
228        // One indented line per individual authenticated session (auth), with
229        // what it's doing.
230        if let Some(members) = session.get("members").and_then(Value::as_array) {
231            for member in members {
232                out.push_str(&format!("\n       {}", render_member(member)));
233            }
234        }
235    }
236    out
237}
238
239/// Renders one authenticated-session line: id, context, current state (running
240/// query + elapsed, busy, or idle time), and lifetime query count.
241fn render_member(member: &Value) -> String {
242    let mid = member.get("id").and_then(Value::as_u64).unwrap_or(0);
243    let qc = member
244        .get("query_count")
245        .and_then(Value::as_u64)
246        .unwrap_or(0);
247    let context = member
248        .get("context")
249        .map_or_else(|| "(default)".to_string(), context_summary);
250    let state = if let Some(running) = member.get("running").filter(|r| !r.is_null()) {
251        let sql = running.get("sql").and_then(Value::as_str).unwrap_or("");
252        let secs = age_secs(running.get("started_at").and_then(Value::as_str));
253        format!("running {secs}s: {sql}")
254    } else if member.get("busy").and_then(Value::as_bool).unwrap_or(false) {
255        "busy".to_string()
256    } else {
257        let secs = age_secs(member.get("last_used").and_then(Value::as_str));
258        format!("idle {secs}s")
259    };
260    format!("#{mid} {context} · {state} · {qc} queries")
261}
262
263/// Cancels (aborts) the running query on a target pool **without** evicting the
264/// session.
265///
266/// Frees the pooled session promptly rather than waiting out
267/// `SNOWFLAKE_QUERY_TIMEOUT`. Exactly one selector is required — the
268/// `--account`/`--user` pair, `--id`, or `--all`. `--member` (a numeric member id
269/// from `sessions`) narrows the cancel to one authenticated session's query; it
270/// may not be combined with `--all`.
271#[derive(Parser)]
272#[command(group(
273    ArgGroup::new("cancel-target")
274        .required(true)
275        .args(["account", "id", "all"]),
276))]
277pub struct CancelCommand {
278    /// Account of the pool whose query to cancel (requires `--user`).
279    #[arg(long, requires = "user")]
280    pub account: Option<String>,
281    /// User of the pool whose query to cancel (requires `--account`).
282    #[arg(long, requires = "account")]
283    pub user: Option<String>,
284    /// Numeric id of the pool (as shown by `sessions`).
285    #[arg(long)]
286    pub id: Option<u64>,
287    /// Cancel the running query on every pool.
288    #[arg(long)]
289    pub all: bool,
290    /// Numeric member id (from `sessions`) to cancel a single session's query
291    /// instead of every busy member in the pool. Not valid with `--all`.
292    #[arg(long, conflicts_with = "all")]
293    pub member: Option<u64>,
294    /// Control-socket path. Defaults to the per-user runtime location.
295    #[arg(long, value_name = "PATH")]
296    pub socket: Option<PathBuf>,
297}
298
299impl CancelCommand {
300    /// Executes the cancel command.
301    pub async fn execute(mut self) -> Result<()> {
302        let payload = self.payload();
303        let socket = server::resolve_socket(self.socket.take())?;
304        let result = call(&socket, "cancel", payload).await?;
305        println!("{}", self.message(&result));
306        Ok(())
307    }
308
309    /// Builds the socket payload for whichever selector was chosen, adding
310    /// `member` when set. Exactly one selector is present (enforced by the
311    /// `cancel-target` arg group).
312    fn payload(&self) -> Value {
313        let mut payload = if self.all {
314            json!({ "all": true })
315        } else if let Some(id) = self.id {
316            json!({ "id": id })
317        } else {
318            json!({ "account": self.account, "user": self.user })
319        };
320        if let (Some(member), Some(map)) = (self.member, payload.as_object_mut()) {
321            map.insert("member".to_string(), json!(member));
322        }
323        payload
324    }
325
326    /// The line printed after the socket call, matched to the selector, the
327    /// optional member, and how many running queries were actually cancelled.
328    fn message(&self, result: &Value) -> String {
329        let cancelled = result.get("cancelled").and_then(Value::as_u64).unwrap_or(0);
330        if self.all {
331            return format!("Cancelled {cancelled} running query(ies) across all pools.");
332        }
333        let target = if let Some(id) = self.id {
334            format!("pool #{id}")
335        } else {
336            // The arg group guarantees the pair when neither --all nor --id.
337            let account = self.account.as_deref().unwrap_or_default();
338            let user = self.user.as_deref().unwrap_or_default();
339            format!("{account} / {user}")
340        };
341        cancel_message(cancelled, &target, self.member)
342    }
343}
344
345/// The message printed after a targeted cancel, reflecting the scope (pool or
346/// identity, optionally a single member) and how many queries were cancelled.
347fn cancel_message(cancelled: u64, target: &str, member: Option<u64>) -> String {
348    let scope = match member {
349        Some(member) => format!("{target} member #{member}"),
350        None => target.to_string(),
351    };
352    if cancelled == 0 {
353        format!("No running query to cancel for {scope}.")
354    } else {
355        format!("Cancelled {cancelled} running query(ies) for {scope}.")
356    }
357}
358
359/// Evicts multiplexed sessions: one `(account, user)` pool, a pool by numeric id
360/// (from `sessions`), or every pool. Exactly one selector is required — the
361/// `--account`/`--user` pair, `--id`, or `--all`.
362#[derive(Parser)]
363#[command(group(
364    ArgGroup::new("target")
365        .required(true)
366        .args(["account", "id", "all"]),
367))]
368pub struct DisconnectCommand {
369    /// Account of the pool to evict (requires `--user`).
370    #[arg(long, requires = "user")]
371    pub account: Option<String>,
372    /// User of the pool to evict (requires `--account`).
373    #[arg(long, requires = "account")]
374    pub user: Option<String>,
375    /// Numeric id of the pool to evict (as shown by `sessions`).
376    #[arg(long)]
377    pub id: Option<u64>,
378    /// Evict every pool.
379    #[arg(long)]
380    pub all: bool,
381    /// Control-socket path. Defaults to the per-user runtime location.
382    #[arg(long, value_name = "PATH")]
383    pub socket: Option<PathBuf>,
384}
385
386impl DisconnectCommand {
387    /// Executes the disconnect command.
388    pub async fn execute(mut self) -> Result<()> {
389        let payload = self.payload();
390        let socket = server::resolve_socket(self.socket.take())?;
391        let result = call(&socket, "disconnect", payload).await?;
392        println!("{}", self.message(&result));
393        Ok(())
394    }
395
396    /// Builds the socket payload for whichever selector was chosen. Exactly one
397    /// is present (enforced by the `target` arg group), so the `--account`/
398    /// `--user` fallthrough always has both.
399    fn payload(&self) -> Value {
400        if self.all {
401            json!({ "all": true })
402        } else if let Some(id) = self.id {
403            json!({ "id": id })
404        } else {
405            json!({ "account": self.account, "user": self.user })
406        }
407    }
408
409    /// The line printed after the socket call, matched to the selector and the
410    /// number of pools the daemon actually evicted (`count`).
411    fn message(&self, result: &Value) -> String {
412        let count = result.get("count").and_then(Value::as_u64).unwrap_or(0);
413        let disconnected = result
414            .get("disconnected")
415            .and_then(Value::as_bool)
416            .unwrap_or(count > 0);
417        if self.all {
418            format!("Disconnected {count} session pool(s).")
419        } else if let Some(id) = self.id {
420            if disconnected {
421                format!("Disconnected session pool #{id}.")
422            } else {
423                format!("No active session pool with id {id}.")
424            }
425        } else {
426            // The arg group guarantees the pair when neither --all nor --id.
427            let account = self.account.as_deref().unwrap_or_default();
428            let user = self.user.as_deref().unwrap_or_default();
429            disconnect_message(disconnected, account, user)
430        }
431    }
432}
433
434/// The message printed after a `(account, user)` disconnect, depending on whether
435/// a session pool was actually evicted.
436fn disconnect_message(disconnected: bool, account: &str, user: &str) -> String {
437    if disconnected {
438        format!("Disconnected {account} / {user}.")
439    } else {
440        format!("No active session for {account} / {user}.")
441    }
442}
443
444/// Sends one `snowflake` service op over the control socket, returning its
445/// payload or turning an `ok: false` reply into an error.
446///
447/// The envelope carries this CLI process's request-log `invocation_id` so the
448/// HTTP records the daemon writes while serving the op correlate back to this
449/// invocation rather than the daemon's own (#1198).
450async fn call(socket: &Path, op: &str, payload: Value) -> Result<Value> {
451    let origin = crate::request_log::current_context().invocation_id;
452    let reply = DaemonClient::new(socket)
453        .request(DaemonEnvelope::service(SERVICE, op, payload).with_origin(origin))
454        .await?;
455    if reply.ok {
456        Ok(reply.payload)
457    } else {
458        bail!(
459            "daemon returned an error: {}",
460            reply.error.as_deref().unwrap_or("unknown error")
461        )
462    }
463}
464
465/// Seconds elapsed since an RFC 3339 timestamp (0 if absent/unparseable).
466fn age_secs(ts: Option<&str>) -> i64 {
467    ts.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
468        .map_or(0, |t| {
469            (Utc::now() - t.with_timezone(&Utc)).num_seconds().max(0)
470        })
471}
472
473/// A compact `wh/role/db/schema` label from a serialized session context
474/// (`(default)` when none are set).
475fn context_summary(context: &Value) -> String {
476    let parts: Vec<&str> = ["warehouse", "role", "database", "schema"]
477        .iter()
478        .filter_map(|key| context.get(*key).and_then(Value::as_str))
479        .collect();
480    if parts.is_empty() {
481        "(default)".to_string()
482    } else {
483        parts.join("/")
484    }
485}
486
487/// Reads SQL from stdin (the lumon pipe path).
488fn read_stdin() -> Result<String> {
489    let mut buf = String::new();
490    std::io::stdin()
491        .read_to_string(&mut buf)
492        .context("failed to read SQL from stdin")?;
493    Ok(buf)
494}
495
496/// Renders a query result in the requested output format, returning the complete
497/// text including its trailing newline so file and stdout writes are identical.
498///
499/// The query payload is `{ statements: [ {columns, rows}, … ] }` — one entry per
500/// `;`-separated statement. JSON/YAML serialize that whole value; CSV/TSV render
501/// a single table and so error on a multi-statement result (see
502/// [`delimited_output`]).
503fn format_output(value: &Value, format: OutputFormat) -> Result<String> {
504    match format {
505        // `to_string_pretty` has no trailing newline; add one for parity.
506        OutputFormat::Json => Ok(format!("{}\n", serde_json::to_string_pretty(value)?)),
507        // `serde_yaml` already emits a trailing newline.
508        OutputFormat::Yaml => Ok(serde_yaml::to_string(value)?),
509        OutputFormat::Csv => delimited_output(value, ','),
510        OutputFormat::Tsv => delimited_output(value, '\t'),
511    }
512}
513
514/// Renders the `{ statements: [ {columns, rows}, … ] }` query payload as a single
515/// delimited table. A flat CSV/TSV can represent only one result set, so a
516/// multi-statement result (more than one `statements` entry) is an error — use
517/// `-o json`/`-o yaml` for those. A bare `{columns, rows}` value (no `statements`
518/// wrapper) is treated as a single table (defensive, e.g. an older daemon).
519fn delimited_output(value: &Value, delim: char) -> Result<String> {
520    let Some(statements) = value.get("statements").and_then(Value::as_array) else {
521        return Ok(render_delimited(value, delim));
522    };
523    match statements.as_slice() {
524        [] => Ok(String::new()),
525        [one] => Ok(render_delimited(one, delim)),
526        many => bail!(
527            "-o {} cannot render a multi-statement result ({} result sets); \
528             use -o json or -o yaml",
529            if delim == '\t' { "tsv" } else { "csv" },
530            many.len()
531        ),
532    }
533}
534
535/// Writes rendered output to `out_file`, or to stdout when it is `None`.
536fn write_output(text: &str, out_file: Option<&str>) -> Result<()> {
537    if let Some(path) = out_file {
538        fs::write(path, text).with_context(|| format!("failed to write to {path}"))
539    } else {
540        print!("{text}");
541        Ok(())
542    }
543}
544
545/// Renders a `{columns, rows}` query payload as delimited text (`delim` is `,`
546/// for CSV, `\t` for TSV): a header row of column names followed by one row per
547/// result row, with RFC 4180 quoting and `\n` line terminators.
548///
549/// Column order and the header come from `columns[]`; each row's cells are fetched
550/// by the same `_<n>`-disambiguated keys `Row::to_json_object` produces, so
551/// repeated column names (e.g. `SELECT 1, 1`) are not collapsed. An empty result
552/// (`columns: []`, which is all Snowflake reports for a zero-row query) renders as
553/// an empty string.
554fn render_delimited(value: &Value, delim: char) -> String {
555    let names: Vec<&str> = value
556        .get("columns")
557        .and_then(Value::as_array)
558        .map(|cols| {
559            cols.iter()
560                .filter_map(|c| c.get("name").and_then(Value::as_str))
561                .collect()
562        })
563        .unwrap_or_default();
564    if names.is_empty() {
565        return String::new();
566    }
567
568    // The lookup keys `to_json_object` used: the first occurrence of a name maps
569    // to the bare name, later occurrences to `<name>_<n>`.
570    let keys = disambiguated_keys(&names);
571
572    let mut out = String::new();
573    push_record(&mut out, names.iter().copied(), delim);
574
575    let empty = Vec::new();
576    let rows = value
577        .get("rows")
578        .and_then(Value::as_array)
579        .unwrap_or(&empty);
580    for row in rows {
581        let fields = keys.iter().map(|key| {
582            row.get(key).map_or_else(String::new, |cell| {
583                escape_field(&json_to_field(cell), delim)
584            })
585        });
586        push_record(&mut out, fields, delim);
587    }
588    out
589}
590
591/// Reproduces `Row::to_json_object`'s duplicate-name disambiguation: the first
592/// occurrence of a name stays bare, the `n`-th (n ≥ 2) becomes `<name>_<n>`.
593fn disambiguated_keys(names: &[&str]) -> Vec<String> {
594    let mut seen: std::collections::HashMap<&str, u32> = std::collections::HashMap::new();
595    names
596        .iter()
597        .map(|&name| {
598            let count = seen.entry(name).or_insert(0);
599            *count += 1;
600            if *count == 1 {
601                name.to_string()
602            } else {
603                format!("{name}_{count}")
604            }
605        })
606        .collect()
607}
608
609/// Pushes one already-escaped record (delimiter-joined) plus a `\n` terminator.
610/// Generic over `&str`/`String` fields so both the header and data rows share it.
611fn push_record(out: &mut String, fields: impl Iterator<Item = impl AsRef<str>>, delim: char) {
612    let mut first = true;
613    for field in fields {
614        if !first {
615            out.push(delim);
616        }
617        out.push_str(field.as_ref());
618        first = false;
619    }
620    out.push('\n');
621}
622
623/// Stringifies a JSON cell for a delimited field (before escaping): `null` → empty
624/// string, strings verbatim, scalars via their JSON text, and `variant`/`object`/
625/// `array` cells as compact JSON.
626fn json_to_field(cell: &Value) -> String {
627    match cell {
628        Value::Null => String::new(),
629        Value::String(s) => s.clone(),
630        Value::Bool(_) | Value::Number(_) => cell.to_string(),
631        Value::Array(_) | Value::Object(_) => {
632            serde_json::to_string(cell).unwrap_or_else(|_| cell.to_string())
633        }
634    }
635}
636
637/// RFC 4180 field quoting: wrap in double quotes and double any embedded quotes
638/// when the field contains the delimiter, a quote, or a newline/carriage return;
639/// otherwise return it unchanged.
640fn escape_field(field: &str, delim: char) -> String {
641    if field.contains(delim) || field.contains(['"', '\n', '\r']) {
642        format!("\"{}\"", field.replace('"', "\"\""))
643    } else {
644        field.to_string()
645    }
646}
647
648#[cfg(test)]
649#[allow(clippy::unwrap_used, clippy::expect_used)]
650mod tests {
651    use super::*;
652    use crate::test_support::env::MapEnv;
653
654    /// Mirrors the `omni-dev snowflake` argv surface for parse tests.
655    #[derive(Parser)]
656    struct Wrapper {
657        #[command(subcommand)]
658        cmd: SnowflakeSubcommands,
659    }
660
661    fn parse(args: &[&str]) -> SnowflakeSubcommands {
662        try_parse(args).unwrap().cmd
663    }
664
665    fn try_parse(args: &[&str]) -> Result<Wrapper, clap::Error> {
666        let mut full = vec!["omni-dev"];
667        full.extend_from_slice(args);
668        Wrapper::try_parse_from(full)
669    }
670
671    #[test]
672    fn query_parses_sql_and_flags() {
673        let SnowflakeSubcommands::Query(cmd) = parse(&[
674            "query",
675            "--account",
676            "ACCT",
677            "--user",
678            "me",
679            "--warehouse",
680            "WH",
681            "-o",
682            "yaml",
683            "SELECT 1",
684        ]) else {
685            panic!("expected query");
686        };
687        assert_eq!(cmd.account.as_deref(), Some("ACCT"));
688        assert_eq!(cmd.user.as_deref(), Some("me"));
689        assert_eq!(cmd.warehouse.as_deref(), Some("WH"));
690        assert_eq!(cmd.sql.as_deref(), Some("SELECT 1"));
691        assert_eq!(cmd.output, OutputFormat::Yaml);
692    }
693
694    #[test]
695    fn query_deprecated_format_alias_still_parses() {
696        let SnowflakeSubcommands::Query(cmd) = parse(&["query", "--format", "yaml", "SELECT 1"])
697        else {
698            panic!("expected query");
699        };
700        // The deprecated `--format` is captured separately; `execute` folds it
701        // into `output` with a stderr warning.
702        assert_eq!(cmd.format, Some(OutputFormat::Yaml));
703        assert_eq!(cmd.output, OutputFormat::Json);
704    }
705
706    #[test]
707    fn query_sql_optional_and_format_defaults_to_json() {
708        let SnowflakeSubcommands::Query(cmd) = parse(&["query"]) else {
709            panic!("expected query");
710        };
711        assert!(cmd.sql.is_none());
712        assert_eq!(cmd.output, OutputFormat::Json);
713        assert!(cmd.format.is_none());
714        assert!(cmd.socket.is_none());
715    }
716
717    #[test]
718    fn query_request_resolves_defaults_from_env_source() {
719        let SnowflakeSubcommands::Query(cmd) = parse(&["query", "SELECT 1"]) else {
720            panic!("expected query");
721        };
722        let env = MapEnv::new()
723            .with("SNOWFLAKE_ACCOUNT", "PROFILE_ACCT")
724            .with("SNOWFLAKE_USER", "profile_user")
725            .with("SNOWFLAKE_ROLE", "PROFILE_ROLE");
726        let payload = serde_json::to_value(cmd.request("SELECT 1".to_string(), &env)).unwrap();
727        assert_eq!(
728            payload,
729            json!({
730                "account": "PROFILE_ACCT",
731                "user": "profile_user",
732                "role": "PROFILE_ROLE",
733                "sql": "SELECT 1",
734            })
735        );
736    }
737
738    #[test]
739    fn query_request_explicit_flags_beat_env_source() {
740        let SnowflakeSubcommands::Query(cmd) =
741            parse(&["query", "--account", "FLAG_ACCT", "SELECT 1"])
742        else {
743            panic!("expected query");
744        };
745        let env = MapEnv::new()
746            .with("SNOWFLAKE_ACCOUNT", "ENV_ACCT")
747            .with("SNOWFLAKE_USER", "env_user");
748        let req = cmd.request("SELECT 1".to_string(), &env);
749        assert_eq!(req.account.as_deref(), Some("FLAG_ACCT"));
750        assert_eq!(req.user.as_deref(), Some("env_user"));
751    }
752
753    #[test]
754    fn query_request_omits_unresolved_fields_from_payload() {
755        let SnowflakeSubcommands::Query(cmd) = parse(&["query", "SELECT 1"]) else {
756            panic!("expected query");
757        };
758        let payload =
759            serde_json::to_value(cmd.request("SELECT 1".to_string(), &MapEnv::new())).unwrap();
760        assert_eq!(payload, json!({ "sql": "SELECT 1" }));
761    }
762
763    #[test]
764    fn sessions_output_flag_parses() {
765        let SnowflakeSubcommands::Sessions(cmd) = parse(&["sessions", "-o", "json"]) else {
766            panic!("expected sessions");
767        };
768        assert_eq!(cmd.output, TableOrJson::Json);
769        assert!(!cmd.json);
770    }
771
772    #[test]
773    fn sessions_deprecated_json_flag_still_parses() {
774        let SnowflakeSubcommands::Sessions(cmd) = parse(&["sessions", "--json"]) else {
775            panic!("expected sessions");
776        };
777        // Captured separately; `execute` folds it into `output` with a warning.
778        assert!(cmd.json);
779        assert_eq!(cmd.output, TableOrJson::Table);
780    }
781
782    #[tokio::test]
783    async fn query_execute_folds_deprecated_format_flag() {
784        let dir = tempfile::tempdir().unwrap();
785        // Deprecated `--format` folds into `output` before the (absent-daemon)
786        // socket call fails.
787        let cmd = QueryCommand {
788            account: None,
789            user: None,
790            warehouse: None,
791            role: None,
792            database: None,
793            schema: None,
794            socket: Some(dir.path().join("absent.sock")),
795            output: OutputFormat::Json,
796            format: Some(OutputFormat::Yaml),
797            out_file: None,
798            sql: Some("SELECT 1".to_string()),
799        };
800        assert!(cmd.execute().await.is_err());
801    }
802
803    #[tokio::test]
804    async fn sessions_execute_folds_deprecated_json_flag() {
805        let dir = tempfile::tempdir().unwrap();
806        // Deprecated `--json` folds into `output` before the (absent-daemon)
807        // socket call fails.
808        let cmd = SessionsCommand {
809            socket: Some(dir.path().join("absent.sock")),
810            output: TableOrJson::Table,
811            json: true,
812        };
813        assert!(cmd.execute().await.is_err());
814    }
815
816    #[tokio::test]
817    async fn cancel_execute_errors_without_a_daemon() {
818        let dir = tempfile::tempdir().unwrap();
819        // With no daemon at the socket path, `execute` builds the payload, resolves
820        // the socket, and fails on the socket call.
821        let cmd = CancelCommand {
822            account: None,
823            user: None,
824            id: Some(3),
825            all: false,
826            member: Some(2),
827            socket: Some(dir.path().join("absent.sock")),
828        };
829        assert!(cmd.execute().await.is_err());
830    }
831
832    #[test]
833    fn cancel_selectors_and_member_parse_to_payloads() {
834        let SnowflakeSubcommands::Cancel(cmd) =
835            parse(&["cancel", "--account", "ACCT", "--user", "me"])
836        else {
837            panic!("expected cancel");
838        };
839        assert_eq!(cmd.payload(), json!({ "account": "ACCT", "user": "me" }));
840
841        let SnowflakeSubcommands::Cancel(cmd) = parse(&["cancel", "--id", "3", "--member", "2"])
842        else {
843            panic!("expected cancel");
844        };
845        assert_eq!(cmd.payload(), json!({ "id": 3, "member": 2 }));
846
847        let SnowflakeSubcommands::Cancel(cmd) = parse(&["cancel", "--all"]) else {
848            panic!("expected cancel");
849        };
850        assert!(cmd.all);
851        assert_eq!(cmd.payload(), json!({ "all": true }));
852
853        // The pair form carries `member` too.
854        let SnowflakeSubcommands::Cancel(cmd) =
855            parse(&["cancel", "--account", "A", "--user", "u", "--member", "5"])
856        else {
857            panic!("expected cancel");
858        };
859        assert_eq!(
860            cmd.payload(),
861            json!({ "account": "A", "user": "u", "member": 5 })
862        );
863    }
864
865    #[test]
866    fn cancel_requires_one_selector_and_member_conflicts_with_all() {
867        // No selector at all is an error (the `cancel-target` group is required).
868        assert!(try_parse(&["cancel"]).is_err());
869        // Selectors are mutually exclusive.
870        assert!(try_parse(&["cancel", "--all", "--id", "1"]).is_err());
871        assert!(try_parse(&["cancel", "--account", "A", "--user", "u", "--all"]).is_err());
872        // --account without --user (and vice versa) is a parse error.
873        assert!(try_parse(&["cancel", "--account", "ACCT"]).is_err());
874        assert!(try_parse(&["cancel", "--user", "me"]).is_err());
875        // --member cannot combine with --all.
876        assert!(try_parse(&["cancel", "--all", "--member", "1"]).is_err());
877    }
878
879    #[test]
880    fn cancel_message_reflects_selector_member_and_count() {
881        let all = CancelCommand {
882            account: None,
883            user: None,
884            id: None,
885            all: true,
886            member: None,
887            socket: None,
888        };
889        assert_eq!(
890            all.message(&json!({ "cancelled": 2 })),
891            "Cancelled 2 running query(ies) across all pools."
892        );
893
894        let by_id = CancelCommand {
895            account: None,
896            user: None,
897            id: Some(3),
898            all: false,
899            member: Some(2),
900            socket: None,
901        };
902        assert_eq!(
903            by_id.message(&json!({ "cancelled": 1 })),
904            "Cancelled 1 running query(ies) for pool #3 member #2."
905        );
906        assert_eq!(
907            by_id.message(&json!({ "cancelled": 0 })),
908            "No running query to cancel for pool #3 member #2."
909        );
910
911        let pair = CancelCommand {
912            account: Some("ACME".to_string()),
913            user: Some("me".to_string()),
914            id: None,
915            all: false,
916            member: None,
917            socket: None,
918        };
919        assert_eq!(
920            pair.message(&json!({ "cancelled": 1 })),
921            "Cancelled 1 running query(ies) for ACME / me."
922        );
923    }
924
925    #[test]
926    fn disconnect_pair_parses_and_pairs_are_required_together() {
927        let SnowflakeSubcommands::Disconnect(cmd) =
928            parse(&["disconnect", "--account", "ACCT", "--user", "me"])
929        else {
930            panic!("expected disconnect");
931        };
932        assert_eq!(cmd.account.as_deref(), Some("ACCT"));
933        assert_eq!(cmd.user.as_deref(), Some("me"));
934        assert!(cmd.id.is_none());
935        assert!(!cmd.all);
936        assert_eq!(cmd.payload(), json!({ "account": "ACCT", "user": "me" }));
937
938        // --account without --user (and vice versa) is a parse error.
939        assert!(try_parse(&["disconnect", "--account", "ACCT"]).is_err());
940        assert!(try_parse(&["disconnect", "--user", "me"]).is_err());
941    }
942
943    #[test]
944    fn disconnect_by_id_and_all_selectors_parse() {
945        let SnowflakeSubcommands::Disconnect(cmd) = parse(&["disconnect", "--id", "7"]) else {
946            panic!("expected disconnect");
947        };
948        assert_eq!(cmd.id, Some(7));
949        assert_eq!(cmd.payload(), json!({ "id": 7 }));
950
951        let SnowflakeSubcommands::Disconnect(cmd) = parse(&["disconnect", "--all"]) else {
952            panic!("expected disconnect");
953        };
954        assert!(cmd.all);
955        assert_eq!(cmd.payload(), json!({ "all": true }));
956    }
957
958    #[test]
959    fn disconnect_requires_and_conflicts_selectors() {
960        // No selector at all is an error (the `target` group is required).
961        assert!(try_parse(&["disconnect"]).is_err());
962        // Selectors are mutually exclusive.
963        assert!(try_parse(&["disconnect", "--all", "--id", "1"]).is_err());
964        assert!(try_parse(&["disconnect", "--account", "A", "--user", "u", "--all"]).is_err());
965        assert!(try_parse(&["disconnect", "--id", "1", "--account", "A", "--user", "u"]).is_err());
966    }
967
968    #[test]
969    fn disconnect_message_reflects_selector_and_count() {
970        let all = DisconnectCommand {
971            account: None,
972            user: None,
973            id: None,
974            all: true,
975            socket: None,
976        };
977        assert_eq!(
978            all.message(&json!({ "disconnected": true, "count": 3 })),
979            "Disconnected 3 session pool(s)."
980        );
981
982        let by_id = DisconnectCommand {
983            account: None,
984            user: None,
985            id: Some(5),
986            all: false,
987            socket: None,
988        };
989        assert_eq!(
990            by_id.message(&json!({ "disconnected": true, "count": 1 })),
991            "Disconnected session pool #5."
992        );
993        assert_eq!(
994            by_id.message(&json!({ "disconnected": false, "count": 0 })),
995            "No active session pool with id 5."
996        );
997    }
998
999    #[test]
1000    fn age_secs_handles_absent_and_unparseable_and_past() {
1001        assert_eq!(age_secs(None), 0);
1002        assert_eq!(age_secs(Some("not-a-timestamp")), 0);
1003        assert!(age_secs(Some("2000-01-01T00:00:00Z")) > 0);
1004    }
1005
1006    #[test]
1007    fn context_summary_joins_set_dimensions_or_default() {
1008        assert_eq!(context_summary(&json!({})), "(default)");
1009        assert_eq!(
1010            context_summary(&json!({ "warehouse": "WH", "role": "R" })),
1011            "WH/R"
1012        );
1013        assert_eq!(
1014            context_summary(&json!({ "warehouse": "WH", "database": "DB", "schema": "S" })),
1015            "WH/DB/S"
1016        );
1017    }
1018
1019    #[test]
1020    fn render_sessions_handles_empty_replies() {
1021        assert_eq!(
1022            render_sessions(&json!({ "sessions": [] })),
1023            "No active sessions."
1024        );
1025        assert_eq!(render_sessions(&json!({})), "No active sessions.");
1026    }
1027
1028    #[test]
1029    fn render_sessions_renders_running_busy_and_idle_members() {
1030        let result = json!({ "sessions": [{
1031            "id": 1, "account": "ACME", "user": "me",
1032            "sessions": 2, "max_sessions": 4, "query_count": 9,
1033            "members": [
1034                { "id": 1, "query_count": 3,
1035                  "context": { "warehouse": "WH", "role": "R" },
1036                  "running": { "sql": "SELECT 42", "started_at": "2000-01-01T00:00:00Z" } },
1037                { "id": 2, "query_count": 1, "context": {}, "busy": true },
1038                { "id": 3, "query_count": 0, "context": {}, "last_used": "2000-01-01T00:00:00Z" },
1039            ],
1040        }]});
1041        let table = render_sessions(&result);
1042        assert!(table.contains("ACME"), "{table}");
1043        assert!(table.contains("2/4"), "{table}");
1044        assert!(
1045            table.contains("running") && table.contains("SELECT 42"),
1046            "{table}"
1047        );
1048        assert!(table.contains("WH/R"), "{table}");
1049        assert!(table.contains("busy"), "{table}");
1050        assert!(table.contains("idle"), "{table}");
1051        assert!(table.contains("(default)"), "{table}");
1052    }
1053
1054    #[test]
1055    fn format_output_renders_json_and_yaml() {
1056        let value = json!({ "a": 1 });
1057        let json = format_output(&value, OutputFormat::Json).unwrap();
1058        assert!(json.contains("\"a\": 1"));
1059        assert!(
1060            json.ends_with("}\n"),
1061            "JSON gets a trailing newline: {json:?}"
1062        );
1063        let yaml = format_output(&value, OutputFormat::Yaml).unwrap();
1064        assert!(yaml.contains("a: 1"));
1065        assert!(yaml.ends_with('\n'), "YAML ends in a newline: {yaml:?}");
1066    }
1067
1068    #[test]
1069    fn disconnect_message_varies_on_outcome() {
1070        assert_eq!(
1071            disconnect_message(true, "ACME", "me"),
1072            "Disconnected ACME / me."
1073        );
1074        assert_eq!(
1075            disconnect_message(false, "ACME", "me"),
1076            "No active session for ACME / me."
1077        );
1078    }
1079
1080    #[test]
1081    fn query_parses_csv_tsv_and_out_file() {
1082        let SnowflakeSubcommands::Query(cmd) =
1083            parse(&["query", "-o", "csv", "--out-file", "rows.csv", "SELECT 1"])
1084        else {
1085            panic!("expected query");
1086        };
1087        assert_eq!(cmd.output, OutputFormat::Csv);
1088        assert_eq!(cmd.out_file.as_deref(), Some("rows.csv"));
1089
1090        let SnowflakeSubcommands::Query(cmd) = parse(&["query", "-o", "tsv", "SELECT 1"]) else {
1091            panic!("expected query");
1092        };
1093        assert_eq!(cmd.output, OutputFormat::Tsv);
1094        assert!(cmd.out_file.is_none());
1095    }
1096
1097    /// A two-column, one-row payload in the daemon's self-describing shape.
1098    fn sample_payload() -> Value {
1099        json!({
1100            "columns": [{ "name": "ID", "type": "fixed(38,0)" },
1101                        { "name": "NAME", "type": "text(16777216)" }],
1102            "rows": [{ "ID": 1, "NAME": "hello" }],
1103        })
1104    }
1105
1106    #[test]
1107    fn render_delimited_writes_header_then_rows() {
1108        assert_eq!(
1109            render_delimited(&sample_payload(), ','),
1110            "ID,NAME\n1,hello\n"
1111        );
1112        assert_eq!(
1113            render_delimited(&sample_payload(), '\t'),
1114            "ID\tNAME\n1\thello\n"
1115        );
1116    }
1117
1118    #[test]
1119    fn render_delimited_orders_columns_from_columns_array() {
1120        // Row object key order must not matter: `columns[]` drives order.
1121        let payload = json!({
1122            "columns": [{ "name": "B" }, { "name": "A" }],
1123            "rows": [{ "A": 1, "B": 2 }],
1124        });
1125        assert_eq!(render_delimited(&payload, ','), "B,A\n2,1\n");
1126    }
1127
1128    #[test]
1129    fn render_delimited_quotes_per_rfc4180() {
1130        let payload = json!({
1131            "columns": [{ "name": "C" }],
1132            "rows": [
1133                { "C": "a,b" },
1134                { "C": "he said \"hi\"" },
1135                { "C": "line1\nline2" },
1136                { "C": "plain" },
1137            ],
1138        });
1139        assert_eq!(
1140            render_delimited(&payload, ','),
1141            "C\n\"a,b\"\n\"he said \"\"hi\"\"\"\n\"line1\nline2\"\nplain\n"
1142        );
1143        // A comma is not special in TSV, so `a,b` stays unquoted there…
1144        assert!(render_delimited(&payload, '\t').contains("\na,b\n"));
1145    }
1146
1147    #[test]
1148    fn render_delimited_renders_null_variant_and_scalars() {
1149        let payload = json!({
1150            "columns": [{ "name": "N" }, { "name": "B" }, { "name": "V" }],
1151            "rows": [{ "N": null, "B": true, "V": { "a": 1 } }],
1152        });
1153        // null → empty field; bool → literal; object → compact JSON (quoted for
1154        // its embedded comma).
1155        assert_eq!(
1156            render_delimited(&payload, ','),
1157            "N,B,V\n,true,\"{\"\"a\"\":1}\"\n"
1158        );
1159    }
1160
1161    #[test]
1162    fn render_delimited_disambiguates_duplicate_columns() {
1163        // `SELECT 1, 1` → columns [N, N]; row keys are N and N_2.
1164        let payload = json!({
1165            "columns": [{ "name": "N" }, { "name": "N" }],
1166            "rows": [{ "N": 1, "N_2": 2 }],
1167        });
1168        // Header keeps the original names; both cells survive.
1169        assert_eq!(render_delimited(&payload, ','), "N,N\n1,2\n");
1170    }
1171
1172    #[test]
1173    fn render_delimited_empty_result_is_empty() {
1174        // A zero-row query reports `columns: []`; there is nothing to render.
1175        assert_eq!(
1176            render_delimited(&json!({ "columns": [], "rows": [] }), ','),
1177            ""
1178        );
1179        assert_eq!(render_delimited(&json!({}), ','), "");
1180    }
1181
1182    #[test]
1183    fn format_output_dispatches_to_delimited() {
1184        assert_eq!(
1185            format_output(&sample_payload(), OutputFormat::Csv).unwrap(),
1186            "ID,NAME\n1,hello\n"
1187        );
1188        assert_eq!(
1189            format_output(&sample_payload(), OutputFormat::Tsv).unwrap(),
1190            "ID\tNAME\n1\thello\n"
1191        );
1192    }
1193
1194    /// The query payload wraps result sets in `statements`.
1195    fn statements_payload(statements: Value) -> Value {
1196        json!({ "statements": statements })
1197    }
1198
1199    #[test]
1200    fn delimited_output_renders_a_single_statement() {
1201        let one = statements_payload(json!([sample_payload()]));
1202        assert_eq!(delimited_output(&one, ',').unwrap(), "ID,NAME\n1,hello\n");
1203        // The JSON/YAML paths serialize the whole `statements` payload as-is.
1204        assert!(format_output(&one, OutputFormat::Json)
1205            .unwrap()
1206            .contains("\"statements\""));
1207    }
1208
1209    #[test]
1210    fn delimited_output_errors_on_multiple_statements() {
1211        let two = statements_payload(json!([sample_payload(), sample_payload()]));
1212        let err = delimited_output(&two, ',').unwrap_err();
1213        assert!(err.to_string().contains("multi-statement"), "{err}");
1214        assert!(err.to_string().contains("csv"), "{err}");
1215        let err = delimited_output(&two, '\t').unwrap_err();
1216        assert!(err.to_string().contains("tsv"), "{err}");
1217    }
1218
1219    #[test]
1220    fn delimited_output_empty_statements_is_empty() {
1221        let none = statements_payload(json!([]));
1222        assert_eq!(delimited_output(&none, ',').unwrap(), "");
1223    }
1224
1225    #[test]
1226    fn escape_field_quotes_only_when_needed() {
1227        assert_eq!(escape_field("plain", ','), "plain");
1228        assert_eq!(escape_field("a,b", ','), "\"a,b\"");
1229        assert_eq!(escape_field("a,b", '\t'), "a,b");
1230        assert_eq!(escape_field("a\"b", ','), "\"a\"\"b\"");
1231        assert_eq!(escape_field("a\nb", '\t'), "\"a\nb\"");
1232    }
1233
1234    #[test]
1235    fn write_output_to_file_and_stdout() {
1236        let dir = tempfile::tempdir().unwrap();
1237        let path = dir.path().join("rows.csv");
1238        write_output("ID\n1\n", Some(path.to_str().unwrap())).unwrap();
1239        assert_eq!(std::fs::read_to_string(&path).unwrap(), "ID\n1\n");
1240        // The stdout branch just needs to not error.
1241        write_output("noop", None).unwrap();
1242    }
1243
1244    #[test]
1245    fn write_output_invalid_path_errors() {
1246        let err = write_output("x", Some("/nonexistent_dir_for_test/out.csv")).unwrap_err();
1247        assert!(err.to_string().contains("failed to write"));
1248    }
1249}