1use std::io::{IsTerminal, Write};
2
3use anyhow::{Context, Result, bail};
4use clap::{Parser, Subcommand, ValueEnum};
5use serde_json::Value;
6
7use crate::auth;
8use crate::client::MotherduckClient;
9
10#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
11enum OutputMode {
12 Text,
13 Json,
14}
15
16#[derive(Clone, Copy, Debug, ValueEnum)]
17enum InstanceSize {
18 Pulse,
19 Standard,
20 Jumbo,
21 Mega,
22 Giga,
23}
24
25impl InstanceSize {
26 fn as_api_str(&self) -> &'static str {
27 match self {
28 Self::Pulse => "pulse",
29 Self::Standard => "standard",
30 Self::Jumbo => "jumbo",
31 Self::Mega => "mega",
32 Self::Giga => "giga",
33 }
34 }
35}
36
37#[derive(Clone, Copy, Debug, ValueEnum)]
38enum TokenType {
39 ReadWrite,
40 ReadScaling,
41}
42
43impl TokenType {
44 fn as_api_str(&self) -> &'static str {
45 match self {
46 Self::ReadWrite => "read_write",
47 Self::ReadScaling => "read_scaling",
48 }
49 }
50}
51
52#[derive(Parser)]
53#[command(name = "md", version, about = "CLI for the MotherDuck REST API")]
54struct Cli {
55 #[arg(short, long, global = true, value_enum, default_value_t = OutputMode::Text)]
57 output: OutputMode,
58
59 #[arg(long, global = true)]
61 token: Option<String>,
62
63 #[arg(short = 'y', long = "yes", global = true)]
65 yes: bool,
66
67 #[command(subcommand)]
68 command: Commands,
69}
70
71#[derive(Subcommand)]
72enum Commands {
73 ServiceAccount {
75 #[command(subcommand)]
76 command: ServiceAccountCommands,
77 },
78 Token {
80 #[command(subcommand)]
81 command: TokenCommands,
82 },
83 Duckling {
85 #[command(subcommand)]
86 command: DucklingCommands,
87 },
88 Account {
90 #[command(subcommand)]
91 command: AccountCommands,
92 },
93}
94
95#[derive(Subcommand)]
96enum ServiceAccountCommands {
97 Create {
99 username: String,
101 },
102 Delete {
104 username: String,
106 },
107}
108
109#[derive(Subcommand)]
110enum TokenCommands {
111 List {
113 username: String,
115 },
116 Create {
118 username: String,
120 #[arg(short, long)]
122 name: String,
123 #[arg(long, value_parser = clap::value_parser!(u64).range(300..=31536000))]
125 ttl: Option<u64>,
126 #[arg(long, value_enum, default_value_t = TokenType::ReadWrite)]
128 token_type: TokenType,
129 },
130 Delete {
132 username: String,
134 token_id: String,
136 },
137}
138
139#[derive(Subcommand)]
140enum DucklingCommands {
141 Get {
143 username: String,
145 },
146 #[command(group(clap::ArgGroup::new("overrides").required(true).multiple(true)))]
148 Set {
149 username: String,
151 #[arg(long, value_enum, group = "overrides")]
153 rw_size: Option<InstanceSize>,
154 #[arg(long, value_enum, group = "overrides")]
156 rs_size: Option<InstanceSize>,
157 #[arg(long, group = "overrides", value_parser = clap::value_parser!(u32).range(0..=64))]
159 flock_size: Option<u32>,
160 },
161}
162
163#[derive(Subcommand)]
164enum AccountCommands {
165 ListActive,
167}
168
169fn print_json(value: &Value) {
172 println!(
173 "{}",
174 serde_json::to_string_pretty(value).expect("Value serialization is infallible")
175 );
176}
177
178fn display_field<'a>(value: &'a Value, key: &str) -> &'a str {
180 value[key].as_str().unwrap_or("-")
181}
182
183fn extract_str<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
185 value[key].as_str()
186}
187
188fn print_table(headers: &[&str], rows: &[Vec<String>]) {
190 if rows.is_empty() {
191 return;
192 }
193
194 let widths: Vec<usize> = (0..headers.len())
195 .map(|i| {
196 let header_w = headers[i].len();
197 let max_row_w = rows
198 .iter()
199 .map(|r| r.get(i).map_or(0, |s| s.len()))
200 .max()
201 .unwrap_or(0);
202 header_w.max(max_row_w)
203 })
204 .collect();
205
206 let last = headers.len() - 1;
207
208 for (i, h) in headers.iter().enumerate() {
210 if i < last {
211 print!("{:<width$} ", h, width = widths[i]);
212 } else {
213 println!("{h}");
214 }
215 }
216
217 for row in rows {
219 for (i, val) in row.iter().enumerate() {
220 if i < last {
221 print!("{:<width$} ", val, width = widths[i]);
222 } else {
223 println!("{val}");
224 }
225 }
226 }
227}
228
229fn print_duckling_config(value: &Value) {
230 let rw = display_field(&value["read_write"], "instance_size");
231 let rs = display_field(&value["read_scaling"], "instance_size");
232 let flock = match value["read_scaling"]["flock_size"].as_u64() {
233 Some(n) => n.to_string(),
234 None => "-".to_string(),
235 };
236 println!("read_write: {rw}");
237 println!("read_scaling: {rs} (flock_size: {flock})");
238}
239
240fn confirm(prompt: &str, yes: bool) -> Result<()> {
243 if yes || !std::io::stdin().is_terminal() {
244 return Ok(());
245 }
246 eprint!("{prompt}");
247 std::io::stderr()
248 .flush()
249 .context("failed to flush stderr")?;
250
251 let mut input = String::new();
252 std::io::stdin()
253 .read_line(&mut input)
254 .context("failed to read confirmation")?;
255 let answer = input.trim().to_lowercase();
256 if answer == "y" || answer == "yes" {
257 Ok(())
258 } else {
259 bail!("aborted")
260 }
261}
262
263fn handle_service_account(
266 client: &MotherduckClient,
267 command: ServiceAccountCommands,
268 mode: OutputMode,
269 yes: bool,
270) -> Result<()> {
271 match command {
272 ServiceAccountCommands::Create { username } => {
273 let result = client.create_user(&username)?;
274 match mode {
275 OutputMode::Json => print_json(&result),
276 OutputMode::Text => println!("{}", display_field(&result, "username")),
277 }
278 }
279 ServiceAccountCommands::Delete { username } => {
280 confirm(&format!("Delete service account '{username}'? [y/N] "), yes)?;
281 let result = client.delete_user(&username)?;
282 if mode == OutputMode::Json {
283 print_json(&result);
284 }
285 }
286 }
287 Ok(())
288}
289
290fn handle_token(
291 client: &MotherduckClient,
292 command: TokenCommands,
293 mode: OutputMode,
294 yes: bool,
295) -> Result<()> {
296 match command {
297 TokenCommands::List { username } => {
298 let result = client.list_tokens(&username)?;
299 match mode {
300 OutputMode::Json => print_json(&result),
301 OutputMode::Text => {
302 if let Some(tokens) = result["tokens"].as_array() {
303 let rows: Vec<Vec<String>> = tokens
304 .iter()
305 .map(|t| {
306 vec![
307 display_field(t, "id").to_string(),
308 display_field(t, "name").to_string(),
309 display_field(t, "token_type").to_string(),
310 match t["expire_at"].as_str() {
311 Some(s) if !s.is_empty() => s.to_string(),
312 _ => "never".to_string(),
313 },
314 ]
315 })
316 .collect();
317 print_table(&["ID", "NAME", "TYPE", "EXPIRES"], &rows);
318 }
319 }
320 }
321 }
322 TokenCommands::Create {
323 username,
324 name,
325 ttl,
326 token_type,
327 } => {
328 let result =
329 client.create_token(&username, &name, ttl, Some(token_type.as_api_str()))?;
330 match mode {
331 OutputMode::Json => print_json(&result),
332 OutputMode::Text => println!("{}", display_field(&result, "token")),
333 }
334 }
335 TokenCommands::Delete { username, token_id } => {
336 confirm(&format!("Delete token '{token_id}'? [y/N] "), yes)?;
337 let result = client.delete_token(&username, &token_id)?;
338 if mode == OutputMode::Json {
339 print_json(&result);
340 }
341 }
342 }
343 Ok(())
344}
345
346fn handle_duckling(
347 client: &MotherduckClient,
348 command: DucklingCommands,
349 mode: OutputMode,
350) -> Result<()> {
351 let result = match command {
352 DucklingCommands::Get { username } => client.get_duckling_config(&username)?,
353 DucklingCommands::Set {
354 username,
355 rw_size,
356 rs_size,
357 flock_size,
358 } => {
359 let current = client.get_duckling_config(&username)?;
360 let rw = match rw_size {
361 Some(s) => s.as_api_str(),
362 None => extract_str(¤t["read_write"], "instance_size")
363 .context("current config missing read_write.instance_size")?,
364 };
365 let rs = match rs_size {
366 Some(s) => s.as_api_str(),
367 None => extract_str(¤t["read_scaling"], "instance_size")
368 .context("current config missing read_scaling.instance_size")?,
369 };
370 let flock = match flock_size {
371 Some(n) => n,
372 None => current["read_scaling"]["flock_size"]
373 .as_u64()
374 .and_then(|v| u32::try_from(v).ok())
375 .context("current config missing read_scaling.flock_size")?,
376 };
377 client.set_duckling_config(&username, rw, rs, flock)?
378 }
379 };
380 match mode {
381 OutputMode::Json => print_json(&result),
382 OutputMode::Text => print_duckling_config(&result),
383 }
384 Ok(())
385}
386
387fn handle_account(
388 client: &MotherduckClient,
389 command: AccountCommands,
390 mode: OutputMode,
391) -> Result<()> {
392 match command {
393 AccountCommands::ListActive => {
394 let result = client.list_active_accounts()?;
395 match mode {
396 OutputMode::Json => print_json(&result),
397 OutputMode::Text => {
398 if let Some(accounts) = result["accounts"].as_array() {
399 let rows: Vec<Vec<String>> = accounts
400 .iter()
401 .map(|acct| {
402 let username = display_field(acct, "username").to_string();
403 let ducklings = acct["ducklings"]
404 .as_array()
405 .map(|ds| {
406 ds.iter()
407 .map(|d| {
408 format!(
409 "{} ({})",
410 display_field(d, "type"),
411 display_field(d, "status"),
412 )
413 })
414 .collect::<Vec<_>>()
415 .join(", ")
416 })
417 .unwrap_or_default();
418 vec![username, ducklings]
419 })
420 .collect();
421 print_table(&["USERNAME", "DUCKLINGS"], &rows);
422 }
423 }
424 }
425 }
426 }
427 Ok(())
428}
429
430pub fn run<I, T>(args: I) -> Result<()>
434where
435 I: IntoIterator<Item = T>,
436 T: Into<std::ffi::OsString> + Clone,
437{
438 let cli = Cli::parse_from(args);
439 let mode = cli.output;
440 let yes = cli.yes;
441 let token = auth::resolve_token_or(cli.token.as_deref())?;
442 let client = MotherduckClient::new(&token)?;
443
444 match cli.command {
445 Commands::ServiceAccount { command } => handle_service_account(&client, command, mode, yes),
446 Commands::Token { command } => handle_token(&client, command, mode, yes),
447 Commands::Duckling { command } => handle_duckling(&client, command, mode),
448 Commands::Account { command } => handle_account(&client, command, mode),
449 }
450}
451
452#[cfg(test)]
453mod tests {
454 use super::*;
455
456 fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
457 Cli::try_parse_from(args)
458 }
459
460 #[test]
463 fn instance_size_as_api_str() {
464 assert_eq!(InstanceSize::Pulse.as_api_str(), "pulse");
465 assert_eq!(InstanceSize::Standard.as_api_str(), "standard");
466 assert_eq!(InstanceSize::Jumbo.as_api_str(), "jumbo");
467 assert_eq!(InstanceSize::Mega.as_api_str(), "mega");
468 assert_eq!(InstanceSize::Giga.as_api_str(), "giga");
469 }
470
471 #[test]
472 fn token_type_as_api_str() {
473 assert_eq!(TokenType::ReadWrite.as_api_str(), "read_write");
474 assert_eq!(TokenType::ReadScaling.as_api_str(), "read_scaling");
475 }
476
477 #[test]
480 fn parse_service_account_create() {
481 let cli = parse(&["md", "service-account", "create", "svc_test"]).unwrap();
482 match cli.command {
483 Commands::ServiceAccount {
484 command: ServiceAccountCommands::Create { username },
485 } => assert_eq!(username, "svc_test"),
486 _ => panic!("expected ServiceAccount Create"),
487 }
488 }
489
490 #[test]
491 fn parse_service_account_delete() {
492 let cli = parse(&["md", "service-account", "delete", "svc_test"]).unwrap();
493 match cli.command {
494 Commands::ServiceAccount {
495 command: ServiceAccountCommands::Delete { username },
496 } => assert_eq!(username, "svc_test"),
497 _ => panic!("expected ServiceAccount Delete"),
498 }
499 }
500
501 #[test]
502 fn parse_token_create_all_options() {
503 let cli = parse(&[
504 "md",
505 "token",
506 "create",
507 "svc_test",
508 "--name",
509 "my-tok",
510 "--ttl",
511 "3600",
512 "--token-type",
513 "read-scaling",
514 ])
515 .unwrap();
516 match cli.command {
517 Commands::Token {
518 command:
519 TokenCommands::Create {
520 username,
521 name,
522 ttl,
523 token_type,
524 },
525 } => {
526 assert_eq!(username, "svc_test");
527 assert_eq!(name, "my-tok");
528 assert_eq!(ttl.unwrap(), 3600);
529 assert_eq!(token_type.as_api_str(), "read_scaling");
530 }
531 _ => panic!("expected Token Create"),
532 }
533 }
534
535 #[test]
536 fn parse_token_create_defaults() {
537 let cli = parse(&["md", "token", "create", "u", "--name", "t"]).unwrap();
538 match cli.command {
539 Commands::Token {
540 command:
541 TokenCommands::Create {
542 ttl, token_type, ..
543 },
544 } => {
545 assert!(ttl.is_none());
546 assert_eq!(token_type.as_api_str(), "read_write");
547 }
548 _ => panic!("expected Token Create"),
549 }
550 }
551
552 #[test]
553 fn parse_token_create_missing_name_fails() {
554 assert!(parse(&["md", "token", "create", "u"]).is_err());
555 }
556
557 #[test]
558 fn parse_invalid_instance_size_fails() {
559 assert!(parse(&["md", "duckling", "set", "u", "--rw-size", "tiny"]).is_err());
560 }
561
562 #[test]
563 fn parse_duckling_set_requires_at_least_one_override() {
564 assert!(parse(&["md", "duckling", "set", "u"]).is_err());
565 }
566
567 #[test]
568 fn parse_global_output_flag() {
569 let cli = parse(&["md", "-o", "json", "account", "list-active"]).unwrap();
570 assert_eq!(cli.output, OutputMode::Json);
571 }
572
573 #[test]
574 fn parse_default_output_is_text() {
575 let cli = parse(&["md", "account", "list-active"]).unwrap();
576 assert_eq!(cli.output, OutputMode::Text);
577 }
578
579 #[test]
582 fn parse_global_token_flag() {
583 let cli = parse(&["md", "--token", "my-secret", "account", "list-active"]).unwrap();
584 assert_eq!(cli.token.as_deref(), Some("my-secret"));
585 }
586
587 #[test]
588 fn parse_token_flag_defaults_to_none() {
589 let cli = parse(&["md", "account", "list-active"]).unwrap();
590 assert!(cli.token.is_none());
591 }
592
593 #[test]
594 fn parse_token_dash_for_stdin() {
595 let cli = parse(&["md", "--token", "-", "account", "list-active"]).unwrap();
596 assert_eq!(cli.token.as_deref(), Some("-"));
597 }
598
599 #[test]
602 fn parse_yes_short_flag() {
603 let cli = parse(&["md", "-y", "service-account", "delete", "svc_test"]).unwrap();
604 assert!(cli.yes);
605 }
606
607 #[test]
608 fn parse_yes_long_flag() {
609 let cli = parse(&["md", "--yes", "token", "delete", "u", "t123"]).unwrap();
610 assert!(cli.yes);
611 }
612
613 #[test]
614 fn parse_yes_after_args() {
615 let cli = parse(&["md", "service-account", "delete", "svc_test", "--yes"]).unwrap();
616 assert!(cli.yes);
617 }
618
619 #[test]
620 fn parse_token_after_args() {
621 let cli = parse(&["md", "account", "list-active", "--token", "tok"]).unwrap();
622 assert_eq!(cli.token.as_deref(), Some("tok"));
623 }
624
625 #[test]
626 fn parse_yes_defaults_to_false() {
627 let cli = parse(&["md", "account", "list-active"]).unwrap();
628 assert!(!cli.yes);
629 }
630
631 #[test]
634 fn display_field_returns_value() {
635 let v = serde_json::json!({"name": "alice"});
636 assert_eq!(display_field(&v, "name"), "alice");
637 }
638
639 #[test]
640 fn display_field_returns_dash_for_missing() {
641 let v = serde_json::json!({});
642 assert_eq!(display_field(&v, "name"), "-");
643 }
644
645 #[test]
646 fn print_table_empty_rows_no_output() {
647 print_table(&["A", "B"], &[]);
649 }
650
651 #[test]
652 fn print_table_single_row() {
653 print_table(&["A", "B"], &[vec!["short".into(), "x".into()]]);
654 }
655
656 #[test]
657 fn print_table_varying_widths() {
658 print_table(
659 &["ID", "NAME"],
660 &[
661 vec!["1".into(), "alice".into()],
662 vec!["1000".into(), "b".into()],
663 ],
664 );
665 }
666}