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 Disconnect(DisconnectCommand),
47}
48
49impl SnowflakeCommand {
50 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)]
62#[value(rename_all = "kebab-case")]
63pub enum OutputFormat {
64 #[default]
66 Json,
67 Yaml,
69 Csv,
72 Tsv,
74}
75
76#[derive(Parser)]
79pub struct QueryCommand {
80 #[arg(long)]
82 pub account: Option<String>,
83 #[arg(long)]
85 pub user: Option<String>,
86 #[arg(long)]
88 pub warehouse: Option<String>,
89 #[arg(long)]
91 pub role: Option<String>,
92 #[arg(long)]
94 pub database: Option<String>,
95 #[arg(long)]
97 pub schema: Option<String>,
98 #[arg(long, value_name = "PATH")]
100 pub socket: Option<PathBuf>,
101 #[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Json)]
103 pub output: OutputFormat,
104 #[arg(long = "format", hide = true)]
106 pub format: Option<OutputFormat>,
107 #[arg(long = "out-file", value_name = "PATH")]
109 pub out_file: Option<String>,
110 pub sql: Option<String>,
112}
113
114impl QueryCommand {
115 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 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 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#[derive(Parser)]
162pub struct SessionsCommand {
163 #[arg(long, value_name = "PATH")]
165 pub socket: Option<PathBuf>,
166 #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
168 pub output: TableOrJson,
169 #[arg(long, hide = true)]
171 pub json: bool,
172}
173
174impl SessionsCommand {
175 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
191fn 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 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
235fn 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#[derive(Parser)]
263#[command(group(
264 ArgGroup::new("target")
265 .required(true)
266 .args(["account", "id", "all"]),
267))]
268pub struct DisconnectCommand {
269 #[arg(long, requires = "user")]
271 pub account: Option<String>,
272 #[arg(long, requires = "account")]
274 pub user: Option<String>,
275 #[arg(long)]
277 pub id: Option<u64>,
278 #[arg(long)]
280 pub all: bool,
281 #[arg(long, value_name = "PATH")]
283 pub socket: Option<PathBuf>,
284}
285
286impl DisconnectCommand {
287 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 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 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 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
334fn 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
344async 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
365fn 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
373fn 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
387fn 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
396fn format_output(value: &Value, format: OutputFormat) -> Result<String> {
403 Ok(match format {
404 OutputFormat::Json => format!("{}\n", serde_json::to_string_pretty(value)?),
406 OutputFormat::Yaml => serde_yaml::to_string(value)?,
408 OutputFormat::Csv => render_delimited(value, ','),
409 OutputFormat::Tsv => render_delimited(value, '\t'),
410 })
411}
412
413fn 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
423fn 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 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
469fn 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
487fn 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
501fn 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
515fn 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 #[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 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 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 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 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 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 assert!(try_parse(&["disconnect"]).is_err());
731 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 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 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 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 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 let payload = json!({
934 "columns": [{ "name": "N" }, { "name": "N" }],
935 "rows": [{ "N": 1, "N_2": 2 }],
936 });
937 assert_eq!(render_delimited(&payload, ','), "N,N\n1,2\n");
939 }
940
941 #[test]
942 fn render_delimited_empty_result_is_empty() {
943 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 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}