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::daemon::client::DaemonClient;
17use crate::daemon::protocol::DaemonEnvelope;
18use crate::daemon::server;
19
20const SERVICE: &str = "snowflake";
22
23#[derive(Parser)]
26pub struct SnowflakeCommand {
27 #[command(subcommand)]
29 pub command: SnowflakeSubcommands,
30}
31
32#[derive(Subcommand)]
34pub enum SnowflakeSubcommands {
35 Query(QueryCommand),
37 Sessions(SessionsCommand),
39 Disconnect(DisconnectCommand),
41}
42
43impl SnowflakeCommand {
44 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)]
56#[value(rename_all = "kebab-case")]
57pub enum OutputFormat {
58 #[default]
60 Json,
61 Yaml,
63}
64
65#[derive(Parser)]
68pub struct QueryCommand {
69 #[arg(long)]
71 pub account: Option<String>,
72 #[arg(long)]
74 pub user: Option<String>,
75 #[arg(long)]
77 pub warehouse: Option<String>,
78 #[arg(long)]
80 pub role: Option<String>,
81 #[arg(long)]
83 pub database: Option<String>,
84 #[arg(long)]
86 pub schema: Option<String>,
87 #[arg(long, value_name = "PATH")]
89 pub socket: Option<PathBuf>,
90 #[arg(long, value_enum, default_value_t = OutputFormat::Json)]
92 pub format: OutputFormat,
93 pub sql: Option<String>,
95}
96
97impl QueryCommand {
98 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 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#[derive(Parser)]
130pub struct SessionsCommand {
131 #[arg(long, value_name = "PATH")]
133 pub socket: Option<PathBuf>,
134 #[arg(long)]
136 pub json: bool,
137}
138
139impl SessionsCommand {
140 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
153fn 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 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
197fn 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#[derive(Parser)]
223pub struct DisconnectCommand {
224 #[arg(long)]
226 pub account: String,
227 #[arg(long)]
229 pub user: String,
230 #[arg(long, value_name = "PATH")]
232 pub socket: Option<PathBuf>,
233}
234
235impl DisconnectCommand {
236 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
253fn 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
263async 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
279fn 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
287fn 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
301fn 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
310fn 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
318fn 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 #[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 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 print_value(&json!({ "a": 1 }), OutputFormat::Json).unwrap();
485 print_value(&json!({ "a": 1 }), OutputFormat::Yaml).unwrap();
486 }
487}