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::io::Read as _;
9use std::path::{Path, PathBuf};
10
11use anyhow::{bail, Context, Result};
12use chrono::Utc;
13use clap::{Parser, Subcommand, ValueEnum};
14use serde_json::{json, Value};
15
16use crate::daemon::client::DaemonClient;
17use crate::daemon::protocol::DaemonEnvelope;
18use crate::daemon::server;
19
20/// The `snowflake` service routing key on the daemon control socket.
21const SERVICE: &str = "snowflake";
22
23/// Snowflake: authenticate once via external-browser SSO and run arbitrary SQL
24/// across any account, multiplexed by the daemon.
25#[derive(Parser)]
26pub struct SnowflakeCommand {
27    /// The Snowflake subcommand to execute.
28    #[command(subcommand)]
29    pub command: SnowflakeSubcommands,
30}
31
32/// Snowflake subcommands.
33#[derive(Subcommand)]
34pub enum SnowflakeSubcommands {
35    /// Run SQL (from an argument or stdin) through a multiplexed session.
36    Query(QueryCommand),
37    /// List active multiplexed sessions.
38    Sessions(SessionsCommand),
39    /// Disconnect (evict) one session.
40    Disconnect(DisconnectCommand),
41}
42
43impl SnowflakeCommand {
44    /// Executes the Snowflake command.
45    pub async fn execute(self) -> Result<()> {
46        match self.command {
47            SnowflakeSubcommands::Query(cmd) => cmd.execute().await,
48            SnowflakeSubcommands::Sessions(cmd) => cmd.execute().await,
49            SnowflakeSubcommands::Disconnect(cmd) => cmd.execute().await,
50        }
51    }
52}
53
54/// Output format for query results.
55#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)]
56#[value(rename_all = "kebab-case")]
57pub enum OutputFormat {
58    /// Pretty-printed JSON (default).
59    #[default]
60    Json,
61    /// YAML.
62    Yaml,
63}
64
65/// Runs arbitrary SQL through the `(account, user)` session, authenticating it
66/// on first use (a browser may open for sign-in).
67#[derive(Parser)]
68pub struct QueryCommand {
69    /// Target account. Falls back to `SNOWFLAKE_ACCOUNT` / settings.json.
70    #[arg(long)]
71    pub account: Option<String>,
72    /// Authenticating user. Falls back to `SNOWFLAKE_USER` / settings.json.
73    #[arg(long)]
74    pub user: Option<String>,
75    /// Per-query warehouse (`USE WAREHOUSE`).
76    #[arg(long)]
77    pub warehouse: Option<String>,
78    /// Per-query role (`USE ROLE`).
79    #[arg(long)]
80    pub role: Option<String>,
81    /// Per-query database (`USE DATABASE`).
82    #[arg(long)]
83    pub database: Option<String>,
84    /// Per-query schema (`USE SCHEMA`).
85    #[arg(long)]
86    pub schema: Option<String>,
87    /// Control-socket path. Defaults to the per-user runtime location.
88    #[arg(long, value_name = "PATH")]
89    pub socket: Option<PathBuf>,
90    /// Output format.
91    #[arg(long, value_enum, default_value_t = OutputFormat::Json)]
92    pub format: OutputFormat,
93    /// SQL to run. Read from stdin when omitted.
94    pub sql: Option<String>,
95}
96
97impl QueryCommand {
98    /// Executes the query command.
99    pub async fn execute(self) -> Result<()> {
100        let sql = match self.sql {
101            Some(sql) => sql,
102            None => read_stdin()?,
103        };
104        if sql.trim().is_empty() {
105            bail!("no SQL provided (pass it as an argument or on stdin)");
106        }
107
108        let payload = json!({
109            "account": self.account,
110            "user": self.user,
111            "warehouse": self.warehouse,
112            "role": self.role,
113            "database": self.database,
114            "schema": self.schema,
115            "sql": sql,
116        });
117
118        // First-time auth for an (account, user) opens a browser; warn on stderr
119        // so it doesn't pollute the JSON/YAML on stdout.
120        eprintln!("snowflake: a browser may open for first-time sign-in…");
121
122        let socket = server::resolve_socket(self.socket)?;
123        let result = call(&socket, "query", payload).await?;
124        print_value(&result, self.format)
125    }
126}
127
128/// Lists the daemon's active multiplexed sessions.
129#[derive(Parser)]
130pub struct SessionsCommand {
131    /// Control-socket path. Defaults to the per-user runtime location.
132    #[arg(long, value_name = "PATH")]
133    pub socket: Option<PathBuf>,
134    /// Emit machine-readable JSON instead of a table.
135    #[arg(long)]
136    pub json: bool,
137}
138
139impl SessionsCommand {
140    /// Executes the sessions command.
141    pub async fn execute(self) -> Result<()> {
142        let socket = server::resolve_socket(self.socket)?;
143        let result = call(&socket, "sessions", Value::Null).await?;
144        if self.json {
145            println!("{}", serde_json::to_string_pretty(&result)?);
146            return Ok(());
147        }
148        println!("{}", render_sessions(&result));
149        Ok(())
150    }
151}
152
153/// Renders a `sessions` reply as a human-readable table: a header, one row per
154/// pool, and an indented line per authenticated session (what each is doing).
155/// Returns a placeholder line when there are no active sessions.
156fn render_sessions(result: &Value) -> String {
157    let sessions = result
158        .get("sessions")
159        .and_then(Value::as_array)
160        .map(Vec::as_slice)
161        .unwrap_or_default();
162    if sessions.is_empty() {
163        return "No active sessions.".to_string();
164    }
165    let mut out = format!(
166        "{:<4} {:<28} {:<28} {:>8} {:>7}",
167        "ID", "ACCOUNT", "USER", "SESSIONS", "QUERIES"
168    );
169    for session in sessions {
170        let id = session.get("id").and_then(Value::as_u64).unwrap_or(0);
171        let account = session.get("account").and_then(Value::as_str).unwrap_or("");
172        let user = session.get("user").and_then(Value::as_str).unwrap_or("");
173        let live = session.get("sessions").and_then(Value::as_u64).unwrap_or(0);
174        let max = session
175            .get("max_sessions")
176            .and_then(Value::as_u64)
177            .unwrap_or(0);
178        let count = session
179            .get("query_count")
180            .and_then(Value::as_u64)
181            .unwrap_or(0);
182        let pool = format!("{live}/{max}");
183        out.push_str(&format!(
184            "\n{id:<4} {account:<28} {user:<28} {pool:>8} {count:>7}"
185        ));
186        // One indented line per individual authenticated session (auth), with
187        // what it's doing.
188        if let Some(members) = session.get("members").and_then(Value::as_array) {
189            for member in members {
190                out.push_str(&format!("\n       {}", render_member(member)));
191            }
192        }
193    }
194    out
195}
196
197/// Renders one authenticated-session line: id, context, current state (running
198/// query + elapsed, busy, or idle time), and lifetime query count.
199fn render_member(member: &Value) -> String {
200    let mid = member.get("id").and_then(Value::as_u64).unwrap_or(0);
201    let qc = member
202        .get("query_count")
203        .and_then(Value::as_u64)
204        .unwrap_or(0);
205    let context = member
206        .get("context")
207        .map_or_else(|| "(default)".to_string(), context_summary);
208    let state = if let Some(running) = member.get("running").filter(|r| !r.is_null()) {
209        let sql = running.get("sql").and_then(Value::as_str).unwrap_or("");
210        let secs = age_secs(running.get("started_at").and_then(Value::as_str));
211        format!("running {secs}s: {sql}")
212    } else if member.get("busy").and_then(Value::as_bool).unwrap_or(false) {
213        "busy".to_string()
214    } else {
215        let secs = age_secs(member.get("last_used").and_then(Value::as_str));
216        format!("idle {secs}s")
217    };
218    format!("#{mid} {context} · {state} · {qc} queries")
219}
220
221/// Evicts a single multiplexed session.
222#[derive(Parser)]
223pub struct DisconnectCommand {
224    /// Account of the session to evict.
225    #[arg(long)]
226    pub account: String,
227    /// User of the session to evict.
228    #[arg(long)]
229    pub user: String,
230    /// Control-socket path. Defaults to the per-user runtime location.
231    #[arg(long, value_name = "PATH")]
232    pub socket: Option<PathBuf>,
233}
234
235impl DisconnectCommand {
236    /// Executes the disconnect command.
237    pub async fn execute(self) -> Result<()> {
238        let socket = server::resolve_socket(self.socket)?;
239        let payload = json!({ "account": self.account, "user": self.user });
240        let result = call(&socket, "disconnect", payload).await?;
241        let disconnected = result
242            .get("disconnected")
243            .and_then(Value::as_bool)
244            .unwrap_or(false);
245        println!(
246            "{}",
247            disconnect_message(disconnected, &self.account, &self.user)
248        );
249        Ok(())
250    }
251}
252
253/// The message printed after a `disconnect`, depending on whether a session was
254/// actually evicted.
255fn disconnect_message(disconnected: bool, account: &str, user: &str) -> String {
256    if disconnected {
257        format!("Disconnected {account} / {user}.")
258    } else {
259        format!("No active session for {account} / {user}.")
260    }
261}
262
263/// Sends one `snowflake` service op over the control socket, returning its
264/// payload or turning an `ok: false` reply into an error.
265async fn call(socket: &Path, op: &str, payload: Value) -> Result<Value> {
266    let reply = DaemonClient::new(socket)
267        .request(DaemonEnvelope::service(SERVICE, op, payload))
268        .await?;
269    if reply.ok {
270        Ok(reply.payload)
271    } else {
272        bail!(
273            "daemon returned an error: {}",
274            reply.error.as_deref().unwrap_or("unknown error")
275        )
276    }
277}
278
279/// Seconds elapsed since an RFC 3339 timestamp (0 if absent/unparseable).
280fn age_secs(ts: Option<&str>) -> i64 {
281    ts.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
282        .map_or(0, |t| {
283            (Utc::now() - t.with_timezone(&Utc)).num_seconds().max(0)
284        })
285}
286
287/// A compact `wh/role/db/schema` label from a serialized session context
288/// (`(default)` when none are set).
289fn context_summary(context: &Value) -> String {
290    let parts: Vec<&str> = ["warehouse", "role", "database", "schema"]
291        .iter()
292        .filter_map(|key| context.get(*key).and_then(Value::as_str))
293        .collect();
294    if parts.is_empty() {
295        "(default)".to_string()
296    } else {
297        parts.join("/")
298    }
299}
300
301/// Reads SQL from stdin (the lumon pipe path).
302fn read_stdin() -> Result<String> {
303    let mut buf = String::new();
304    std::io::stdin()
305        .read_to_string(&mut buf)
306        .context("failed to read SQL from stdin")?;
307    Ok(buf)
308}
309
310/// Formats a JSON value in the requested output format.
311fn format_value(value: &Value, format: OutputFormat) -> Result<String> {
312    Ok(match format {
313        OutputFormat::Json => serde_json::to_string_pretty(value)?,
314        OutputFormat::Yaml => serde_yaml::to_string(value)?,
315    })
316}
317
318/// Prints a JSON value in the requested format (JSON gets a trailing newline;
319/// `serde_yaml` already emits one).
320fn print_value(value: &Value, format: OutputFormat) -> Result<()> {
321    let text = format_value(value, format)?;
322    match format {
323        OutputFormat::Json => println!("{text}"),
324        OutputFormat::Yaml => print!("{text}"),
325    }
326    Ok(())
327}
328
329#[cfg(test)]
330#[allow(clippy::unwrap_used, clippy::expect_used)]
331mod tests {
332    use super::*;
333
334    /// Mirrors the `omni-dev snowflake` argv surface for parse tests.
335    #[derive(Parser)]
336    struct Wrapper {
337        #[command(subcommand)]
338        cmd: SnowflakeSubcommands,
339    }
340
341    fn parse(args: &[&str]) -> SnowflakeSubcommands {
342        let mut full = vec!["omni-dev"];
343        full.extend_from_slice(args);
344        Wrapper::try_parse_from(full).unwrap().cmd
345    }
346
347    #[test]
348    fn query_parses_sql_and_flags() {
349        let SnowflakeSubcommands::Query(cmd) = parse(&[
350            "query",
351            "--account",
352            "ACCT",
353            "--user",
354            "me",
355            "--warehouse",
356            "WH",
357            "--format",
358            "yaml",
359            "SELECT 1",
360        ]) else {
361            panic!("expected query");
362        };
363        assert_eq!(cmd.account.as_deref(), Some("ACCT"));
364        assert_eq!(cmd.user.as_deref(), Some("me"));
365        assert_eq!(cmd.warehouse.as_deref(), Some("WH"));
366        assert_eq!(cmd.sql.as_deref(), Some("SELECT 1"));
367        assert_eq!(cmd.format, OutputFormat::Yaml);
368    }
369
370    #[test]
371    fn query_sql_optional_and_format_defaults_to_json() {
372        let SnowflakeSubcommands::Query(cmd) = parse(&["query"]) else {
373            panic!("expected query");
374        };
375        assert!(cmd.sql.is_none());
376        assert_eq!(cmd.format, OutputFormat::Json);
377        assert!(cmd.socket.is_none());
378    }
379
380    #[test]
381    fn sessions_json_flag_parses() {
382        let SnowflakeSubcommands::Sessions(cmd) = parse(&["sessions", "--json"]) else {
383            panic!("expected sessions");
384        };
385        assert!(cmd.json);
386    }
387
388    #[test]
389    fn disconnect_requires_account_and_user() {
390        let SnowflakeSubcommands::Disconnect(cmd) =
391            parse(&["disconnect", "--account", "ACCT", "--user", "me"])
392        else {
393            panic!("expected disconnect");
394        };
395        assert_eq!(cmd.account, "ACCT");
396        assert_eq!(cmd.user, "me");
397
398        // Missing required flags is a parse error.
399        let mut full = vec!["omni-dev", "disconnect", "--account", "ACCT"];
400        assert!(Wrapper::try_parse_from(std::mem::take(&mut full)).is_err());
401    }
402
403    #[test]
404    fn age_secs_handles_absent_and_unparseable_and_past() {
405        assert_eq!(age_secs(None), 0);
406        assert_eq!(age_secs(Some("not-a-timestamp")), 0);
407        assert!(age_secs(Some("2000-01-01T00:00:00Z")) > 0);
408    }
409
410    #[test]
411    fn context_summary_joins_set_dimensions_or_default() {
412        assert_eq!(context_summary(&json!({})), "(default)");
413        assert_eq!(
414            context_summary(&json!({ "warehouse": "WH", "role": "R" })),
415            "WH/R"
416        );
417        assert_eq!(
418            context_summary(&json!({ "warehouse": "WH", "database": "DB", "schema": "S" })),
419            "WH/DB/S"
420        );
421    }
422
423    #[test]
424    fn render_sessions_handles_empty_replies() {
425        assert_eq!(
426            render_sessions(&json!({ "sessions": [] })),
427            "No active sessions."
428        );
429        assert_eq!(render_sessions(&json!({})), "No active sessions.");
430    }
431
432    #[test]
433    fn render_sessions_renders_running_busy_and_idle_members() {
434        let result = json!({ "sessions": [{
435            "id": 1, "account": "ACME", "user": "me",
436            "sessions": 2, "max_sessions": 4, "query_count": 9,
437            "members": [
438                { "id": 1, "query_count": 3,
439                  "context": { "warehouse": "WH", "role": "R" },
440                  "running": { "sql": "SELECT 42", "started_at": "2000-01-01T00:00:00Z" } },
441                { "id": 2, "query_count": 1, "context": {}, "busy": true },
442                { "id": 3, "query_count": 0, "context": {}, "last_used": "2000-01-01T00:00:00Z" },
443            ],
444        }]});
445        let table = render_sessions(&result);
446        assert!(table.contains("ACME"), "{table}");
447        assert!(table.contains("2/4"), "{table}");
448        assert!(
449            table.contains("running") && table.contains("SELECT 42"),
450            "{table}"
451        );
452        assert!(table.contains("WH/R"), "{table}");
453        assert!(table.contains("busy"), "{table}");
454        assert!(table.contains("idle"), "{table}");
455        assert!(table.contains("(default)"), "{table}");
456    }
457
458    #[test]
459    fn format_value_renders_json_and_yaml() {
460        let value = json!({ "a": 1 });
461        assert!(format_value(&value, OutputFormat::Json)
462            .unwrap()
463            .contains("\"a\": 1"));
464        assert!(format_value(&value, OutputFormat::Yaml)
465            .unwrap()
466            .contains("a: 1"));
467    }
468
469    #[test]
470    fn disconnect_message_varies_on_outcome() {
471        assert_eq!(
472            disconnect_message(true, "ACME", "me"),
473            "Disconnected ACME / me."
474        );
475        assert_eq!(
476            disconnect_message(false, "ACME", "me"),
477            "No active session for ACME / me."
478        );
479    }
480
481    #[test]
482    fn print_value_emits_both_formats() {
483        // Exercises both arms; output goes to the test harness's captured stdout.
484        print_value(&json!({ "a": 1 }), OutputFormat::Json).unwrap();
485        print_value(&json!({ "a": 1 }), OutputFormat::Yaml).unwrap();
486    }
487}