Skip to main content

dkdc_md_cli/
cli.rs

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    /// Output format
56    #[arg(short, long, global = true, value_enum, default_value_t = OutputMode::Text)]
57    output: OutputMode,
58
59    /// API token (overrides env vars; use '-' to read from stdin)
60    #[arg(long, global = true)]
61    token: Option<String>,
62
63    /// Skip confirmation prompts
64    #[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    /// Manage service accounts
74    ServiceAccount {
75        #[command(subcommand)]
76        command: ServiceAccountCommands,
77    },
78    /// Manage access tokens
79    Token {
80        #[command(subcommand)]
81        command: TokenCommands,
82    },
83    /// Manage duckling configuration
84    Duckling {
85        #[command(subcommand)]
86        command: DucklingCommands,
87    },
88    /// Manage accounts
89    Account {
90        #[command(subcommand)]
91        command: AccountCommands,
92    },
93}
94
95#[derive(Subcommand)]
96enum ServiceAccountCommands {
97    /// Create a new service account
98    Create {
99        /// Username
100        username: String,
101    },
102    /// Delete a service account
103    Delete {
104        /// Username
105        username: String,
106    },
107}
108
109#[derive(Subcommand)]
110enum TokenCommands {
111    /// List tokens for a user
112    List {
113        /// Username
114        username: String,
115    },
116    /// Create a new access token
117    Create {
118        /// Username
119        username: String,
120        /// Token name
121        #[arg(short, long)]
122        name: String,
123        /// Time-to-live in seconds (300-31536000)
124        #[arg(long, value_parser = clap::value_parser!(u64).range(300..=31536000))]
125        ttl: Option<u64>,
126        /// Token type
127        #[arg(long, value_enum, default_value_t = TokenType::ReadWrite)]
128        token_type: TokenType,
129    },
130    /// Delete an access token
131    Delete {
132        /// Username
133        username: String,
134        /// Token ID
135        token_id: String,
136    },
137}
138
139#[derive(Subcommand)]
140enum DucklingCommands {
141    /// Get duckling configuration for a user
142    Get {
143        /// Username
144        username: String,
145    },
146    /// Set duckling configuration for a user (fetches current config, merges overrides)
147    #[command(group(clap::ArgGroup::new("overrides").required(true).multiple(true)))]
148    Set {
149        /// Username
150        username: String,
151        /// Read-write instance size
152        #[arg(long, value_enum, group = "overrides")]
153        rw_size: Option<InstanceSize>,
154        /// Read-scaling instance size
155        #[arg(long, value_enum, group = "overrides")]
156        rs_size: Option<InstanceSize>,
157        /// Read-scaling flock size (0-64)
158        #[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    /// List active accounts
166    ListActive,
167}
168
169// -- helpers --
170
171fn print_json(value: &Value) {
172    println!(
173        "{}",
174        serde_json::to_string_pretty(value).expect("Value serialization is infallible")
175    );
176}
177
178/// Extract a string field for display. Returns "-" for missing/null fields.
179fn display_field<'a>(value: &'a Value, key: &str) -> &'a str {
180    value[key].as_str().unwrap_or("-")
181}
182
183/// Extract a string field for use as data. Returns None for missing/null fields.
184fn extract_str<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
185    value[key].as_str()
186}
187
188/// Print rows as a fixed-width table with a header.
189fn 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    // Header
209    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    // Rows
218    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
240/// Ask the user for confirmation on stderr. Returns Ok(()) if confirmed, Err if declined.
241/// Auto-confirms if `--yes` was passed or if stdin is not a terminal.
242fn 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
263// -- command handlers --
264
265fn 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(&current["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(&current["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
430// -- main dispatch --
431
432/// Parse CLI arguments and execute the corresponding MotherDuck API command.
433pub 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    // -- InstanceSize / TokenType --
461
462    #[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    // -- CLI parsing --
478
479    #[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    // -- --token flag --
580
581    #[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    // -- --yes flag --
600
601    #[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    // -- helpers --
632
633    #[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        // Should not panic or print anything
648        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}