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