1use 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::cli::format::TableOrJson;
17use crate::daemon::client::DaemonClient;
18use crate::daemon::protocol::DaemonEnvelope;
19use crate::daemon::server;
20use crate::snowflake::QueryRequest;
21use crate::utils::env::EnvSource;
22use crate::utils::settings::SettingsEnv;
23
24const SERVICE: &str = "snowflake";
26
27#[derive(Parser)]
30pub struct SnowflakeCommand {
31 #[command(subcommand)]
33 pub command: SnowflakeSubcommands,
34}
35
36#[derive(Subcommand)]
38pub enum SnowflakeSubcommands {
39 Query(QueryCommand),
41 Sessions(SessionsCommand),
43 Disconnect(DisconnectCommand),
45}
46
47impl SnowflakeCommand {
48 pub async fn execute(self) -> Result<()> {
50 match self.command {
51 SnowflakeSubcommands::Query(cmd) => cmd.execute().await,
52 SnowflakeSubcommands::Sessions(cmd) => cmd.execute().await,
53 SnowflakeSubcommands::Disconnect(cmd) => cmd.execute().await,
54 }
55 }
56}
57
58#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)]
60#[value(rename_all = "kebab-case")]
61pub enum OutputFormat {
62 #[default]
64 Json,
65 Yaml,
67}
68
69#[derive(Parser)]
72pub struct QueryCommand {
73 #[arg(long)]
75 pub account: Option<String>,
76 #[arg(long)]
78 pub user: Option<String>,
79 #[arg(long)]
81 pub warehouse: Option<String>,
82 #[arg(long)]
84 pub role: Option<String>,
85 #[arg(long)]
87 pub database: Option<String>,
88 #[arg(long)]
90 pub schema: Option<String>,
91 #[arg(long, value_name = "PATH")]
93 pub socket: Option<PathBuf>,
94 #[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Json)]
96 pub output: OutputFormat,
97 #[arg(long = "format", hide = true)]
99 pub format: Option<OutputFormat>,
100 pub sql: Option<String>,
102}
103
104impl QueryCommand {
105 pub async fn execute(mut self) -> Result<()> {
107 if let Some(format) = self.format.take() {
108 eprintln!("warning: --format is deprecated; use -o/--output instead");
109 self.output = format;
110 }
111 let sql = match self.sql.take() {
112 Some(sql) => sql,
113 None => read_stdin()?,
114 };
115 if sql.trim().is_empty() {
116 bail!("no SQL provided (pass it as an argument or on stdin)");
117 }
118
119 let payload = serde_json::to_value(self.request(sql, &SettingsEnv::load()))?;
120
121 eprintln!("snowflake: a browser may open for first-time sign-in…");
124
125 let socket = server::resolve_socket(self.socket)?;
126 let result = call(&socket, "query", payload).await?;
127 print_value(&result, self.output)
128 }
129
130 fn request(&self, sql: String, env: &impl EnvSource) -> QueryRequest {
135 let mut req = QueryRequest {
136 account: self.account.clone(),
137 user: self.user.clone(),
138 warehouse: self.warehouse.clone(),
139 role: self.role.clone(),
140 database: self.database.clone(),
141 schema: self.schema.clone(),
142 sql,
143 };
144 req.fill_defaults_from(env);
145 req
146 }
147}
148
149#[derive(Parser)]
151pub struct SessionsCommand {
152 #[arg(long, value_name = "PATH")]
154 pub socket: Option<PathBuf>,
155 #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
157 pub output: TableOrJson,
158 #[arg(long, hide = true)]
160 pub json: bool,
161}
162
163impl SessionsCommand {
164 pub async fn execute(mut self) -> Result<()> {
166 if self.json {
167 eprintln!("warning: --json is deprecated; use -o/--output json instead");
168 self.output = TableOrJson::Json;
169 }
170 let socket = server::resolve_socket(self.socket)?;
171 let result = call(&socket, "sessions", Value::Null).await?;
172 match self.output {
173 TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&result)?),
174 TableOrJson::Table => println!("{}", render_sessions(&result)),
175 }
176 Ok(())
177 }
178}
179
180fn render_sessions(result: &Value) -> String {
184 let sessions = result
185 .get("sessions")
186 .and_then(Value::as_array)
187 .map(Vec::as_slice)
188 .unwrap_or_default();
189 if sessions.is_empty() {
190 return "No active sessions.".to_string();
191 }
192 let mut out = format!(
193 "{:<4} {:<28} {:<28} {:>8} {:>7}",
194 "ID", "ACCOUNT", "USER", "SESSIONS", "QUERIES"
195 );
196 for session in sessions {
197 let id = session.get("id").and_then(Value::as_u64).unwrap_or(0);
198 let account = session.get("account").and_then(Value::as_str).unwrap_or("");
199 let user = session.get("user").and_then(Value::as_str).unwrap_or("");
200 let live = session.get("sessions").and_then(Value::as_u64).unwrap_or(0);
201 let max = session
202 .get("max_sessions")
203 .and_then(Value::as_u64)
204 .unwrap_or(0);
205 let count = session
206 .get("query_count")
207 .and_then(Value::as_u64)
208 .unwrap_or(0);
209 let pool = format!("{live}/{max}");
210 out.push_str(&format!(
211 "\n{id:<4} {account:<28} {user:<28} {pool:>8} {count:>7}"
212 ));
213 if let Some(members) = session.get("members").and_then(Value::as_array) {
216 for member in members {
217 out.push_str(&format!("\n {}", render_member(member)));
218 }
219 }
220 }
221 out
222}
223
224fn render_member(member: &Value) -> String {
227 let mid = member.get("id").and_then(Value::as_u64).unwrap_or(0);
228 let qc = member
229 .get("query_count")
230 .and_then(Value::as_u64)
231 .unwrap_or(0);
232 let context = member
233 .get("context")
234 .map_or_else(|| "(default)".to_string(), context_summary);
235 let state = if let Some(running) = member.get("running").filter(|r| !r.is_null()) {
236 let sql = running.get("sql").and_then(Value::as_str).unwrap_or("");
237 let secs = age_secs(running.get("started_at").and_then(Value::as_str));
238 format!("running {secs}s: {sql}")
239 } else if member.get("busy").and_then(Value::as_bool).unwrap_or(false) {
240 "busy".to_string()
241 } else {
242 let secs = age_secs(member.get("last_used").and_then(Value::as_str));
243 format!("idle {secs}s")
244 };
245 format!("#{mid} {context} · {state} · {qc} queries")
246}
247
248#[derive(Parser)]
250pub struct DisconnectCommand {
251 #[arg(long)]
253 pub account: String,
254 #[arg(long)]
256 pub user: String,
257 #[arg(long, value_name = "PATH")]
259 pub socket: Option<PathBuf>,
260}
261
262impl DisconnectCommand {
263 pub async fn execute(self) -> Result<()> {
265 let socket = server::resolve_socket(self.socket)?;
266 let payload = json!({ "account": self.account, "user": self.user });
267 let result = call(&socket, "disconnect", payload).await?;
268 let disconnected = result
269 .get("disconnected")
270 .and_then(Value::as_bool)
271 .unwrap_or(false);
272 println!(
273 "{}",
274 disconnect_message(disconnected, &self.account, &self.user)
275 );
276 Ok(())
277 }
278}
279
280fn disconnect_message(disconnected: bool, account: &str, user: &str) -> String {
283 if disconnected {
284 format!("Disconnected {account} / {user}.")
285 } else {
286 format!("No active session for {account} / {user}.")
287 }
288}
289
290async fn call(socket: &Path, op: &str, payload: Value) -> Result<Value> {
293 let reply = DaemonClient::new(socket)
294 .request(DaemonEnvelope::service(SERVICE, op, payload))
295 .await?;
296 if reply.ok {
297 Ok(reply.payload)
298 } else {
299 bail!(
300 "daemon returned an error: {}",
301 reply.error.as_deref().unwrap_or("unknown error")
302 )
303 }
304}
305
306fn age_secs(ts: Option<&str>) -> i64 {
308 ts.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
309 .map_or(0, |t| {
310 (Utc::now() - t.with_timezone(&Utc)).num_seconds().max(0)
311 })
312}
313
314fn context_summary(context: &Value) -> String {
317 let parts: Vec<&str> = ["warehouse", "role", "database", "schema"]
318 .iter()
319 .filter_map(|key| context.get(*key).and_then(Value::as_str))
320 .collect();
321 if parts.is_empty() {
322 "(default)".to_string()
323 } else {
324 parts.join("/")
325 }
326}
327
328fn read_stdin() -> Result<String> {
330 let mut buf = String::new();
331 std::io::stdin()
332 .read_to_string(&mut buf)
333 .context("failed to read SQL from stdin")?;
334 Ok(buf)
335}
336
337fn format_value(value: &Value, format: OutputFormat) -> Result<String> {
339 Ok(match format {
340 OutputFormat::Json => serde_json::to_string_pretty(value)?,
341 OutputFormat::Yaml => serde_yaml::to_string(value)?,
342 })
343}
344
345fn print_value(value: &Value, format: OutputFormat) -> Result<()> {
348 let text = format_value(value, format)?;
349 match format {
350 OutputFormat::Json => println!("{text}"),
351 OutputFormat::Yaml => print!("{text}"),
352 }
353 Ok(())
354}
355
356#[cfg(test)]
357#[allow(clippy::unwrap_used, clippy::expect_used)]
358mod tests {
359 use super::*;
360 use crate::test_support::env::MapEnv;
361
362 #[derive(Parser)]
364 struct Wrapper {
365 #[command(subcommand)]
366 cmd: SnowflakeSubcommands,
367 }
368
369 fn parse(args: &[&str]) -> SnowflakeSubcommands {
370 let mut full = vec!["omni-dev"];
371 full.extend_from_slice(args);
372 Wrapper::try_parse_from(full).unwrap().cmd
373 }
374
375 #[test]
376 fn query_parses_sql_and_flags() {
377 let SnowflakeSubcommands::Query(cmd) = parse(&[
378 "query",
379 "--account",
380 "ACCT",
381 "--user",
382 "me",
383 "--warehouse",
384 "WH",
385 "-o",
386 "yaml",
387 "SELECT 1",
388 ]) else {
389 panic!("expected query");
390 };
391 assert_eq!(cmd.account.as_deref(), Some("ACCT"));
392 assert_eq!(cmd.user.as_deref(), Some("me"));
393 assert_eq!(cmd.warehouse.as_deref(), Some("WH"));
394 assert_eq!(cmd.sql.as_deref(), Some("SELECT 1"));
395 assert_eq!(cmd.output, OutputFormat::Yaml);
396 }
397
398 #[test]
399 fn query_deprecated_format_alias_still_parses() {
400 let SnowflakeSubcommands::Query(cmd) = parse(&["query", "--format", "yaml", "SELECT 1"])
401 else {
402 panic!("expected query");
403 };
404 assert_eq!(cmd.format, Some(OutputFormat::Yaml));
407 assert_eq!(cmd.output, OutputFormat::Json);
408 }
409
410 #[test]
411 fn query_sql_optional_and_format_defaults_to_json() {
412 let SnowflakeSubcommands::Query(cmd) = parse(&["query"]) else {
413 panic!("expected query");
414 };
415 assert!(cmd.sql.is_none());
416 assert_eq!(cmd.output, OutputFormat::Json);
417 assert!(cmd.format.is_none());
418 assert!(cmd.socket.is_none());
419 }
420
421 #[test]
422 fn query_request_resolves_defaults_from_env_source() {
423 let SnowflakeSubcommands::Query(cmd) = parse(&["query", "SELECT 1"]) else {
424 panic!("expected query");
425 };
426 let env = MapEnv::new()
427 .with("SNOWFLAKE_ACCOUNT", "PROFILE_ACCT")
428 .with("SNOWFLAKE_USER", "profile_user")
429 .with("SNOWFLAKE_ROLE", "PROFILE_ROLE");
430 let payload = serde_json::to_value(cmd.request("SELECT 1".to_string(), &env)).unwrap();
431 assert_eq!(
432 payload,
433 json!({
434 "account": "PROFILE_ACCT",
435 "user": "profile_user",
436 "role": "PROFILE_ROLE",
437 "sql": "SELECT 1",
438 })
439 );
440 }
441
442 #[test]
443 fn query_request_explicit_flags_beat_env_source() {
444 let SnowflakeSubcommands::Query(cmd) =
445 parse(&["query", "--account", "FLAG_ACCT", "SELECT 1"])
446 else {
447 panic!("expected query");
448 };
449 let env = MapEnv::new()
450 .with("SNOWFLAKE_ACCOUNT", "ENV_ACCT")
451 .with("SNOWFLAKE_USER", "env_user");
452 let req = cmd.request("SELECT 1".to_string(), &env);
453 assert_eq!(req.account.as_deref(), Some("FLAG_ACCT"));
454 assert_eq!(req.user.as_deref(), Some("env_user"));
455 }
456
457 #[test]
458 fn query_request_omits_unresolved_fields_from_payload() {
459 let SnowflakeSubcommands::Query(cmd) = parse(&["query", "SELECT 1"]) else {
460 panic!("expected query");
461 };
462 let payload =
463 serde_json::to_value(cmd.request("SELECT 1".to_string(), &MapEnv::new())).unwrap();
464 assert_eq!(payload, json!({ "sql": "SELECT 1" }));
465 }
466
467 #[test]
468 fn sessions_output_flag_parses() {
469 let SnowflakeSubcommands::Sessions(cmd) = parse(&["sessions", "-o", "json"]) else {
470 panic!("expected sessions");
471 };
472 assert_eq!(cmd.output, TableOrJson::Json);
473 assert!(!cmd.json);
474 }
475
476 #[test]
477 fn sessions_deprecated_json_flag_still_parses() {
478 let SnowflakeSubcommands::Sessions(cmd) = parse(&["sessions", "--json"]) else {
479 panic!("expected sessions");
480 };
481 assert!(cmd.json);
483 assert_eq!(cmd.output, TableOrJson::Table);
484 }
485
486 #[tokio::test]
487 async fn query_execute_folds_deprecated_format_flag() {
488 let dir = tempfile::tempdir().unwrap();
489 let cmd = QueryCommand {
492 account: None,
493 user: None,
494 warehouse: None,
495 role: None,
496 database: None,
497 schema: None,
498 socket: Some(dir.path().join("absent.sock")),
499 output: OutputFormat::Json,
500 format: Some(OutputFormat::Yaml),
501 sql: Some("SELECT 1".to_string()),
502 };
503 assert!(cmd.execute().await.is_err());
504 }
505
506 #[tokio::test]
507 async fn sessions_execute_folds_deprecated_json_flag() {
508 let dir = tempfile::tempdir().unwrap();
509 let cmd = SessionsCommand {
512 socket: Some(dir.path().join("absent.sock")),
513 output: TableOrJson::Table,
514 json: true,
515 };
516 assert!(cmd.execute().await.is_err());
517 }
518
519 #[test]
520 fn disconnect_requires_account_and_user() {
521 let SnowflakeSubcommands::Disconnect(cmd) =
522 parse(&["disconnect", "--account", "ACCT", "--user", "me"])
523 else {
524 panic!("expected disconnect");
525 };
526 assert_eq!(cmd.account, "ACCT");
527 assert_eq!(cmd.user, "me");
528
529 let mut full = vec!["omni-dev", "disconnect", "--account", "ACCT"];
531 assert!(Wrapper::try_parse_from(std::mem::take(&mut full)).is_err());
532 }
533
534 #[test]
535 fn age_secs_handles_absent_and_unparseable_and_past() {
536 assert_eq!(age_secs(None), 0);
537 assert_eq!(age_secs(Some("not-a-timestamp")), 0);
538 assert!(age_secs(Some("2000-01-01T00:00:00Z")) > 0);
539 }
540
541 #[test]
542 fn context_summary_joins_set_dimensions_or_default() {
543 assert_eq!(context_summary(&json!({})), "(default)");
544 assert_eq!(
545 context_summary(&json!({ "warehouse": "WH", "role": "R" })),
546 "WH/R"
547 );
548 assert_eq!(
549 context_summary(&json!({ "warehouse": "WH", "database": "DB", "schema": "S" })),
550 "WH/DB/S"
551 );
552 }
553
554 #[test]
555 fn render_sessions_handles_empty_replies() {
556 assert_eq!(
557 render_sessions(&json!({ "sessions": [] })),
558 "No active sessions."
559 );
560 assert_eq!(render_sessions(&json!({})), "No active sessions.");
561 }
562
563 #[test]
564 fn render_sessions_renders_running_busy_and_idle_members() {
565 let result = json!({ "sessions": [{
566 "id": 1, "account": "ACME", "user": "me",
567 "sessions": 2, "max_sessions": 4, "query_count": 9,
568 "members": [
569 { "id": 1, "query_count": 3,
570 "context": { "warehouse": "WH", "role": "R" },
571 "running": { "sql": "SELECT 42", "started_at": "2000-01-01T00:00:00Z" } },
572 { "id": 2, "query_count": 1, "context": {}, "busy": true },
573 { "id": 3, "query_count": 0, "context": {}, "last_used": "2000-01-01T00:00:00Z" },
574 ],
575 }]});
576 let table = render_sessions(&result);
577 assert!(table.contains("ACME"), "{table}");
578 assert!(table.contains("2/4"), "{table}");
579 assert!(
580 table.contains("running") && table.contains("SELECT 42"),
581 "{table}"
582 );
583 assert!(table.contains("WH/R"), "{table}");
584 assert!(table.contains("busy"), "{table}");
585 assert!(table.contains("idle"), "{table}");
586 assert!(table.contains("(default)"), "{table}");
587 }
588
589 #[test]
590 fn format_value_renders_json_and_yaml() {
591 let value = json!({ "a": 1 });
592 assert!(format_value(&value, OutputFormat::Json)
593 .unwrap()
594 .contains("\"a\": 1"));
595 assert!(format_value(&value, OutputFormat::Yaml)
596 .unwrap()
597 .contains("a: 1"));
598 }
599
600 #[test]
601 fn disconnect_message_varies_on_outcome() {
602 assert_eq!(
603 disconnect_message(true, "ACME", "me"),
604 "Disconnected ACME / me."
605 );
606 assert_eq!(
607 disconnect_message(false, "ACME", "me"),
608 "No active session for ACME / me."
609 );
610 }
611
612 #[test]
613 fn print_value_emits_both_formats() {
614 print_value(&json!({ "a": 1 }), OutputFormat::Json).unwrap();
616 print_value(&json!({ "a": 1 }), OutputFormat::Yaml).unwrap();
617 }
618}