1use 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
25const SERVICE: &str = "snowflake";
27
28#[derive(Parser)]
31pub struct SnowflakeCommand {
32 #[command(subcommand)]
34 pub command: SnowflakeSubcommands,
35}
36
37#[derive(Subcommand)]
39pub enum SnowflakeSubcommands {
40 Query(QueryCommand),
42 Sessions(SessionsCommand),
44 Cancel(CancelCommand),
47 Disconnect(DisconnectCommand),
50}
51
52impl SnowflakeCommand {
53 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)]
66#[value(rename_all = "kebab-case")]
67pub enum OutputFormat {
68 #[default]
70 Json,
71 Yaml,
73 Csv,
76 Tsv,
78}
79
80#[derive(Parser)]
83pub struct QueryCommand {
84 #[arg(long)]
86 pub account: Option<String>,
87 #[arg(long)]
89 pub user: Option<String>,
90 #[arg(long)]
92 pub warehouse: Option<String>,
93 #[arg(long)]
95 pub role: Option<String>,
96 #[arg(long)]
98 pub database: Option<String>,
99 #[arg(long)]
101 pub schema: Option<String>,
102 #[arg(long, value_name = "PATH")]
104 pub socket: Option<PathBuf>,
105 #[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Json)]
107 pub output: OutputFormat,
108 #[arg(long = "format", hide = true)]
110 pub format: Option<OutputFormat>,
111 #[arg(long = "out-file", value_name = "PATH")]
113 pub out_file: Option<String>,
114 pub sql: Option<String>,
116}
117
118impl QueryCommand {
119 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 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 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#[derive(Parser)]
166pub struct SessionsCommand {
167 #[arg(long, value_name = "PATH")]
169 pub socket: Option<PathBuf>,
170 #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
172 pub output: TableOrJson,
173 #[arg(long, hide = true)]
175 pub json: bool,
176}
177
178impl SessionsCommand {
179 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
195fn 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 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
239fn 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#[derive(Parser)]
272#[command(group(
273 ArgGroup::new("cancel-target")
274 .required(true)
275 .args(["account", "id", "all"]),
276))]
277pub struct CancelCommand {
278 #[arg(long, requires = "user")]
280 pub account: Option<String>,
281 #[arg(long, requires = "account")]
283 pub user: Option<String>,
284 #[arg(long)]
286 pub id: Option<u64>,
287 #[arg(long)]
289 pub all: bool,
290 #[arg(long, conflicts_with = "all")]
293 pub member: Option<u64>,
294 #[arg(long, value_name = "PATH")]
296 pub socket: Option<PathBuf>,
297}
298
299impl CancelCommand {
300 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 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 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 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
345fn 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#[derive(Parser)]
363#[command(group(
364 ArgGroup::new("target")
365 .required(true)
366 .args(["account", "id", "all"]),
367))]
368pub struct DisconnectCommand {
369 #[arg(long, requires = "user")]
371 pub account: Option<String>,
372 #[arg(long, requires = "account")]
374 pub user: Option<String>,
375 #[arg(long)]
377 pub id: Option<u64>,
378 #[arg(long)]
380 pub all: bool,
381 #[arg(long, value_name = "PATH")]
383 pub socket: Option<PathBuf>,
384}
385
386impl DisconnectCommand {
387 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 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 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 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
434fn 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
444async 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
465fn 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
473fn 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
487fn 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
496fn format_output(value: &Value, format: OutputFormat) -> Result<String> {
504 match format {
505 OutputFormat::Json => Ok(format!("{}\n", serde_json::to_string_pretty(value)?)),
507 OutputFormat::Yaml => Ok(serde_yaml::to_string(value)?),
509 OutputFormat::Csv => delimited_output(value, ','),
510 OutputFormat::Tsv => delimited_output(value, '\t'),
511 }
512}
513
514fn 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
535fn 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
545fn 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 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
591fn 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
609fn 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
623fn 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
637fn 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 #[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 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 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 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 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 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 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 assert!(try_parse(&["cancel"]).is_err());
869 assert!(try_parse(&["cancel", "--all", "--id", "1"]).is_err());
871 assert!(try_parse(&["cancel", "--account", "A", "--user", "u", "--all"]).is_err());
872 assert!(try_parse(&["cancel", "--account", "ACCT"]).is_err());
874 assert!(try_parse(&["cancel", "--user", "me"]).is_err());
875 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 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 assert!(try_parse(&["disconnect"]).is_err());
962 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 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 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 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 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 let payload = json!({
1165 "columns": [{ "name": "N" }, { "name": "N" }],
1166 "rows": [{ "N": 1, "N_2": 2 }],
1167 });
1168 assert_eq!(render_delimited(&payload, ','), "N,N\n1,2\n");
1170 }
1171
1172 #[test]
1173 fn render_delimited_empty_result_is_empty() {
1174 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 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 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 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}