Skip to main content

rustfs_cli/commands/
mod.rs

1//! CLI command definitions and execution
2//!
3//! This module contains all CLI commands and their implementations.
4//! Commands are organized by functionality and follow the pattern established
5//! in the command implementation template.
6
7use std::io::{IsTerminal, stderr, stdout};
8
9use clap::{Parser, Subcommand, ValueEnum};
10use rc_core::{RequestHeader, set_global_request_headers};
11
12use crate::exit_code::ExitCode;
13use crate::output::OutputConfig;
14
15mod admin;
16mod alias;
17mod anonymous;
18mod bucket;
19mod cat;
20mod completions;
21mod cors;
22pub mod cp;
23pub mod diff;
24mod event;
25mod find;
26mod head;
27mod ilm;
28mod ls;
29mod mb;
30mod mirror;
31mod mv;
32mod object;
33mod pipe;
34mod quota;
35mod rb;
36mod replicate;
37mod rm;
38mod share;
39mod sql;
40mod stat;
41mod tag;
42mod tree;
43mod version;
44
45/// rc - Rust S3 CLI Client
46///
47/// A command-line interface for S3-compatible object storage services.
48/// Supports RustFS, AWS S3, and other S3-compatible backends.
49#[derive(Parser, Debug)]
50#[command(name = "rc")]
51#[command(author, version, about, long_about = None)]
52#[command(propagate_version = true)]
53pub struct Cli {
54    /// Output format: auto-detect, human-readable, or JSON
55    #[arg(long, global = true, value_enum)]
56    pub format: Option<OutputFormat>,
57
58    /// Output format: human-readable or JSON
59    #[arg(long, global = true, default_value = "false")]
60    pub json: bool,
61
62    /// Disable colored output
63    #[arg(long, global = true, default_value = "false")]
64    pub no_color: bool,
65
66    /// Disable progress bar
67    #[arg(long, global = true, default_value = "false")]
68    pub no_progress: bool,
69
70    /// Suppress non-error output
71    #[arg(short, long, global = true, default_value = "false")]
72    pub quiet: bool,
73
74    /// Enable debug logging
75    #[arg(long, global = true, default_value = "false")]
76    pub debug: bool,
77
78    /// Add an x-amz-* request header to signed S3 requests
79    #[arg(short = 'H', long = "header", global = true, value_parser = parse_request_header)]
80    pub request_headers: Vec<RequestHeader>,
81
82    #[command(subcommand)]
83    pub command: Commands,
84}
85
86fn parse_request_header(value: &str) -> Result<RequestHeader, String> {
87    RequestHeader::parse(value).map_err(|error| error.to_string())
88}
89
90#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
91pub enum OutputFormat {
92    Auto,
93    Human,
94    Json,
95}
96
97#[derive(Copy, Clone, Debug, Eq, PartialEq)]
98enum OutputBehavior {
99    HumanDefault,
100    StructuredDefault,
101}
102
103#[derive(Copy, Clone, Debug)]
104struct GlobalOutputOptions {
105    format: Option<OutputFormat>,
106    json: bool,
107    no_color: bool,
108    no_progress: bool,
109    quiet: bool,
110}
111
112impl GlobalOutputOptions {
113    fn from_cli(cli: &Cli) -> Self {
114        Self {
115            format: cli.format,
116            json: cli.json,
117            no_color: cli.no_color,
118            no_progress: cli.no_progress,
119            quiet: cli.quiet,
120        }
121    }
122
123    fn resolve(self, behavior: OutputBehavior) -> OutputConfig {
124        let stdout_is_tty = stdout().is_terminal();
125        let stderr_is_tty = stderr().is_terminal();
126
127        let selected_format = if self.json {
128            OutputFormat::Json
129        } else {
130            self.format.unwrap_or(match behavior {
131                OutputBehavior::HumanDefault => OutputFormat::Human,
132                OutputBehavior::StructuredDefault => OutputFormat::Auto,
133            })
134        };
135
136        let json = match selected_format {
137            OutputFormat::Json => true,
138            OutputFormat::Human => false,
139            OutputFormat::Auto => !stdout_is_tty,
140        };
141
142        OutputConfig {
143            json,
144            no_color: self.no_color || !stdout_is_tty || json,
145            no_progress: self.no_progress || !stderr_is_tty || json,
146            quiet: self.quiet,
147        }
148    }
149}
150
151#[derive(Subcommand, Debug)]
152pub enum Commands {
153    /// Manage storage service aliases
154    #[command(subcommand)]
155    Alias(alias::AliasCommands),
156
157    /// Manage IAM users, policies, groups, and service accounts
158    #[command(subcommand)]
159    Admin(admin::AdminCommands),
160
161    /// Manage bucket-oriented workflows
162    Bucket(bucket::BucketArgs),
163
164    /// Manage object-oriented workflows
165    Object(object::ObjectArgs),
166
167    // Phase 2: Basic commands
168    /// Deprecated: use `rc bucket list` or `rc object list`
169    Ls(ls::LsArgs),
170
171    /// Deprecated: use `rc bucket create`
172    Mb(mb::MbArgs),
173
174    /// Deprecated: use `rc bucket remove`
175    Rb(rb::RbArgs),
176
177    /// Deprecated: use `rc object show`
178    Cat(cat::CatArgs),
179
180    /// Deprecated: use `rc object head`
181    Head(head::HeadArgs),
182
183    /// Deprecated: use `rc object stat`
184    Stat(stat::StatArgs),
185
186    // Phase 3: Transfer commands
187    /// Deprecated: use `rc object copy`
188    Cp(cp::CpArgs),
189
190    /// Deprecated: use `rc object move`
191    Mv(mv::MvArgs),
192
193    /// Deprecated: use `rc object remove`
194    Rm(rm::RmArgs),
195
196    /// Stream stdin to an object
197    Pipe(pipe::PipeArgs),
198
199    // Phase 4: Advanced commands
200    /// Deprecated: use `rc object find`
201    Find(find::FindArgs),
202
203    /// Deprecated: use `rc bucket event`
204    Event(event::EventArgs),
205
206    /// Deprecated: use `rc bucket cors`
207    #[command(subcommand)]
208    Cors(cors::CorsCommands),
209
210    /// Show differences between locations
211    Diff(diff::DiffArgs),
212
213    /// Mirror objects between locations
214    Mirror(mirror::MirrorArgs),
215
216    /// Deprecated: use `rc object tree`
217    Tree(tree::TreeArgs),
218
219    /// Deprecated: use `rc object share`
220    Share(share::ShareArgs),
221
222    /// Run S3 Select SQL on an object
223    Sql(sql::SqlArgs),
224
225    // Phase 5: Optional commands (capability-dependent)
226    /// Deprecated: use `rc bucket version`
227    #[command(subcommand)]
228    Version(version::VersionCommands),
229
230    /// Manage bucket and object tags
231    #[command(subcommand)]
232    Tag(tag::TagCommands),
233
234    /// Deprecated: use `rc bucket anonymous`
235    #[command(subcommand)]
236    Anonymous(anonymous::AnonymousCommands),
237
238    /// Deprecated: use `rc bucket quota`
239    #[command(subcommand)]
240    Quota(quota::QuotaCommands),
241
242    /// Deprecated: use `rc bucket lifecycle`
243    Ilm(ilm::IlmArgs),
244
245    /// Deprecated: use `rc bucket replication`
246    Replicate(replicate::ReplicateArgs),
247
248    // Phase 6: Utilities
249    /// Generate shell completion scripts
250    Completions(completions::CompletionsArgs),
251    // /// Manage object retention
252    // Retention(retention::RetentionArgs),
253    // /// Watch for object events
254    // Watch(watch::WatchArgs),
255}
256
257/// Execute the CLI command and return an exit code
258pub async fn execute(cli: Cli) -> ExitCode {
259    set_global_request_headers(cli.request_headers.clone());
260    let output_options = GlobalOutputOptions::from_cli(&cli);
261
262    match cli.command {
263        Commands::Alias(cmd) => {
264            alias::execute(cmd, output_options.resolve(OutputBehavior::HumanDefault)).await
265        }
266        Commands::Admin(cmd) => {
267            admin::execute(cmd, output_options.resolve(OutputBehavior::HumanDefault)).await
268        }
269        Commands::Bucket(args) => {
270            bucket::execute(
271                args,
272                output_options.resolve(OutputBehavior::StructuredDefault),
273            )
274            .await
275        }
276        Commands::Object(args) => {
277            let behavior = match &args.command {
278                object::ObjectCommands::Show(_) | object::ObjectCommands::Head(_) => {
279                    OutputBehavior::HumanDefault
280                }
281                _ => OutputBehavior::StructuredDefault,
282            };
283            object::execute(args, output_options.resolve(behavior)).await
284        }
285        Commands::Ls(args) => {
286            ls::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
287        }
288        Commands::Mb(args) => {
289            mb::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
290        }
291        Commands::Rb(args) => {
292            rb::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
293        }
294        Commands::Cat(args) => {
295            cat::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
296        }
297        Commands::Head(args) => {
298            head::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
299        }
300        Commands::Stat(args) => {
301            stat::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
302        }
303        Commands::Cp(args) => {
304            cp::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
305        }
306        Commands::Mv(args) => {
307            mv::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
308        }
309        Commands::Rm(args) => {
310            rm::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
311        }
312        Commands::Pipe(args) => {
313            pipe::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
314        }
315        Commands::Find(args) => {
316            find::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
317        }
318        Commands::Event(args) => {
319            event::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
320        }
321        Commands::Cors(cmd) => {
322            cors::execute(
323                cors::CorsArgs { command: cmd },
324                output_options.resolve(OutputBehavior::HumanDefault),
325            )
326            .await
327        }
328        Commands::Diff(args) => {
329            diff::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
330        }
331        Commands::Mirror(args) => {
332            mirror::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
333        }
334        Commands::Tree(args) => {
335            tree::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
336        }
337        Commands::Share(args) => {
338            share::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
339        }
340        Commands::Sql(args) => {
341            sql::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
342        }
343        Commands::Version(cmd) => {
344            version::execute(
345                version::VersionArgs { command: cmd },
346                output_options.resolve(OutputBehavior::HumanDefault),
347            )
348            .await
349        }
350        Commands::Tag(cmd) => {
351            tag::execute(
352                tag::TagArgs { command: cmd },
353                output_options.resolve(OutputBehavior::HumanDefault),
354            )
355            .await
356        }
357        Commands::Anonymous(cmd) => {
358            anonymous::execute(
359                anonymous::AnonymousArgs { command: cmd },
360                output_options.resolve(OutputBehavior::HumanDefault),
361            )
362            .await
363        }
364        Commands::Quota(cmd) => {
365            quota::execute(
366                quota::QuotaArgs { command: cmd },
367                output_options.resolve(OutputBehavior::HumanDefault),
368            )
369            .await
370        }
371        Commands::Ilm(args) => {
372            ilm::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
373        }
374        Commands::Replicate(args) => {
375            replicate::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
376        }
377        Commands::Completions(args) => completions::execute(args),
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384    use clap::Parser;
385
386    #[test]
387    fn structured_default_uses_auto_format_when_not_explicit() {
388        let options = GlobalOutputOptions {
389            format: None,
390            json: false,
391            no_color: false,
392            no_progress: false,
393            quiet: false,
394        };
395
396        let resolved = options.resolve(OutputBehavior::StructuredDefault);
397        assert_eq!(resolved.json, !std::io::stdout().is_terminal());
398    }
399
400    #[test]
401    fn human_default_keeps_human_format_when_not_explicit() {
402        let options = GlobalOutputOptions {
403            format: None,
404            json: false,
405            no_color: false,
406            no_progress: false,
407            quiet: false,
408        };
409
410        let resolved = options.resolve(OutputBehavior::HumanDefault);
411        assert!(!resolved.json);
412    }
413
414    #[test]
415    fn explicit_json_overrides_behavior_defaults() {
416        let options = GlobalOutputOptions {
417            format: Some(OutputFormat::Human),
418            json: true,
419            no_color: false,
420            no_progress: false,
421            quiet: false,
422        };
423
424        let resolved = options.resolve(OutputBehavior::HumanDefault);
425        assert!(resolved.json);
426    }
427
428    #[test]
429    fn explicit_human_overrides_structured_default() {
430        let options = GlobalOutputOptions {
431            format: Some(OutputFormat::Human),
432            json: false,
433            no_color: false,
434            no_progress: false,
435            quiet: false,
436        };
437
438        let resolved = options.resolve(OutputBehavior::StructuredDefault);
439        assert!(!resolved.json);
440    }
441
442    #[test]
443    fn explicit_auto_overrides_human_default() {
444        let options = GlobalOutputOptions {
445            format: Some(OutputFormat::Auto),
446            json: false,
447            no_color: false,
448            no_progress: false,
449            quiet: false,
450        };
451
452        let resolved = options.resolve(OutputBehavior::HumanDefault);
453        assert_eq!(resolved.json, !std::io::stdout().is_terminal());
454    }
455
456    #[test]
457    fn cli_accepts_global_custom_amz_header() {
458        let cli = Cli::try_parse_from([
459            "rc",
460            "-H",
461            "x-amz-bucket-encrypt-enabled:1",
462            "bucket",
463            "list",
464            "local/",
465        ])
466        .expect("parse custom header");
467
468        assert_eq!(cli.request_headers.len(), 1);
469        assert_eq!(cli.request_headers[0].name, "x-amz-bucket-encrypt-enabled");
470        assert_eq!(cli.request_headers[0].value, "1");
471    }
472
473    #[test]
474    fn cli_rejects_non_amz_custom_header() {
475        let error = Cli::try_parse_from(["rc", "-H", "authorization:secret", "ls", "local/"])
476            .expect_err("non amz header should fail");
477
478        assert!(
479            error
480                .to_string()
481                .contains("Only x-amz-* custom request headers are supported")
482        );
483    }
484
485    #[test]
486    fn cli_accepts_bucket_cors_subcommand() {
487        let cli = Cli::try_parse_from(["rc", "bucket", "cors", "list", "local/my-bucket"])
488            .expect("parse bucket cors");
489
490        match cli.command {
491            Commands::Bucket(args) => match args.command {
492                bucket::BucketCommands::Cors(cors::CorsCommands::List(arg)) => {
493                    assert_eq!(arg.path, "local/my-bucket");
494                }
495                other => panic!("expected bucket cors list command, got {:?}", other),
496            },
497            other => panic!("expected bucket command, got {:?}", other),
498        }
499    }
500
501    #[test]
502    fn cli_accepts_bucket_list_alias() {
503        let cli =
504            Cli::try_parse_from(["rc", "bucket", "ls", "local/"]).expect("parse bucket ls alias");
505
506        match cli.command {
507            Commands::Bucket(args) => match args.command {
508                bucket::BucketCommands::List(arg) => {
509                    assert_eq!(arg.path, "local/");
510                }
511                other => panic!("expected bucket list alias, got {:?}", other),
512            },
513            other => panic!("expected bucket command, got {:?}", other),
514        }
515    }
516
517    #[test]
518    fn cli_accepts_top_level_cors_subcommand() {
519        let cli = Cli::try_parse_from(["rc", "cors", "remove", "local/my-bucket"])
520            .expect("parse top-level cors");
521
522        match cli.command {
523            Commands::Cors(cors::CorsCommands::Remove(arg)) => {
524                assert_eq!(arg.path, "local/my-bucket");
525            }
526            other => panic!("expected top-level cors remove command, got {:?}", other),
527        }
528    }
529
530    #[test]
531    fn cli_accepts_top_level_cors_get_alias() {
532        let cli =
533            Cli::try_parse_from(["rc", "cors", "get", "local/my-bucket"]).expect("parse cors get");
534
535        match cli.command {
536            Commands::Cors(cors::CorsCommands::List(arg)) => {
537                assert_eq!(arg.path, "local/my-bucket");
538            }
539            other => panic!("expected top-level cors get alias, got {:?}", other),
540        }
541    }
542
543    #[test]
544    fn cli_accepts_top_level_event_subcommand() {
545        let cli = Cli::try_parse_from(["rc", "event", "list", "local/my-bucket"])
546            .expect("parse top-level event");
547
548        match cli.command {
549            Commands::Event(event::EventArgs {
550                command: event::EventCommands::List(arg),
551            }) => {
552                assert_eq!(arg.path, "local/my-bucket");
553            }
554            other => panic!("expected top-level event list command, got {:?}", other),
555        }
556    }
557
558    #[test]
559    fn cli_accepts_top_level_event_add_subcommand() {
560        let cli = Cli::try_parse_from([
561            "rc",
562            "event",
563            "add",
564            "local/my-bucket",
565            "arn:aws:sqs:us-east-1:123456789012:jobs",
566            "--event",
567            "put,delete",
568            "--force",
569        ])
570        .expect("parse top-level event add");
571
572        match cli.command {
573            Commands::Event(event::EventArgs {
574                command: event::EventCommands::Add(arg),
575            }) => {
576                assert_eq!(arg.path, "local/my-bucket");
577                assert_eq!(arg.arn, "arn:aws:sqs:us-east-1:123456789012:jobs");
578                assert_eq!(arg.events, vec!["put,delete".to_string()]);
579                assert!(arg.force);
580            }
581            other => panic!("expected top-level event add command, got {:?}", other),
582        }
583    }
584
585    #[test]
586    fn cli_accepts_sql_select_options() {
587        let cli = Cli::try_parse_from([
588            "rc",
589            "sql",
590            "local/reports/data.jsonl",
591            "--query",
592            "SELECT * FROM S3Object",
593            "--input-format",
594            "json",
595            "--output-format",
596            "json",
597            "--compression",
598            "gzip",
599        ])
600        .expect("parse sql command");
601
602        match cli.command {
603            Commands::Sql(arg) => {
604                assert_eq!(arg.path, "local/reports/data.jsonl");
605                assert_eq!(arg.query, "SELECT * FROM S3Object");
606                assert!(matches!(arg.input_format, sql::InputFormatArg::Json));
607                assert!(matches!(arg.output_format, sql::OutputFormatArg::Json));
608                assert!(matches!(arg.compression, sql::CompressionArg::Gzip));
609            }
610            other => panic!("expected sql command, got {:?}", other),
611        }
612    }
613
614    #[test]
615    fn cli_accepts_sql_defaults() {
616        let cli = Cli::try_parse_from([
617            "rc",
618            "sql",
619            "local/reports/data.csv",
620            "--query",
621            "SELECT s._1 FROM S3Object s",
622        ])
623        .expect("parse sql command defaults");
624
625        match cli.command {
626            Commands::Sql(arg) => {
627                assert_eq!(arg.path, "local/reports/data.csv");
628                assert_eq!(arg.query, "SELECT s._1 FROM S3Object s");
629                assert!(matches!(arg.input_format, sql::InputFormatArg::Csv));
630                assert!(matches!(arg.output_format, sql::OutputFormatArg::Csv));
631                assert!(matches!(arg.compression, sql::CompressionArg::None));
632            }
633            other => panic!("expected sql command, got {:?}", other),
634        }
635    }
636
637    #[test]
638    fn cli_accepts_object_list_alias() {
639        let cli = Cli::try_parse_from(["rc", "object", "ls", "local/my-bucket/logs/"])
640            .expect("parse object ls alias");
641
642        match cli.command {
643            Commands::Object(args) => match args.command {
644                object::ObjectCommands::List(arg) => {
645                    assert_eq!(arg.path, "local/my-bucket/logs/");
646                }
647                other => panic!("expected object list alias, got {:?}", other),
648            },
649            other => panic!("expected object command, got {:?}", other),
650        }
651    }
652
653    #[test]
654    fn cli_accepts_top_level_event_remove_subcommand() {
655        let cli = Cli::try_parse_from([
656            "rc",
657            "event",
658            "remove",
659            "local/my-bucket",
660            "arn:aws:sns:us-east-1:123456789012:alerts",
661            "--force",
662        ])
663        .expect("parse top-level event remove");
664
665        match cli.command {
666            Commands::Event(event::EventArgs {
667                command: event::EventCommands::Remove(arg),
668            }) => {
669                assert_eq!(arg.path, "local/my-bucket");
670                assert_eq!(arg.arn, "arn:aws:sns:us-east-1:123456789012:alerts");
671                assert!(arg.force);
672            }
673            other => panic!("expected top-level event remove command, got {:?}", other),
674        }
675    }
676
677    #[test]
678    fn cli_accepts_bucket_cors_get_alias() {
679        let cli = Cli::try_parse_from(["rc", "bucket", "cors", "get", "local/my-bucket"])
680            .expect("parse bucket cors get");
681
682        match cli.command {
683            Commands::Bucket(args) => match args.command {
684                bucket::BucketCommands::Cors(cors::CorsCommands::List(arg)) => {
685                    assert_eq!(arg.path, "local/my-bucket");
686                }
687                other => panic!("expected bucket cors get alias, got {:?}", other),
688            },
689            other => panic!("expected bucket command, got {:?}", other),
690        }
691    }
692
693    #[test]
694    fn cli_accepts_bucket_cors_set_with_positional_source() {
695        let cli =
696            Cli::try_parse_from(["rc", "bucket", "cors", "set", "local/my-bucket", "cors.xml"])
697                .expect("parse bucket cors set with positional source");
698
699        match cli.command {
700            Commands::Bucket(args) => match args.command {
701                bucket::BucketCommands::Cors(cors::CorsCommands::Set(arg)) => {
702                    assert_eq!(arg.path, "local/my-bucket");
703                    assert_eq!(arg.source.as_deref(), Some("cors.xml"));
704                }
705                other => panic!("expected bucket cors set command, got {:?}", other),
706            },
707            other => panic!("expected bucket command, got {:?}", other),
708        }
709    }
710
711    #[test]
712    fn cli_accepts_top_level_cors_set_with_positional_source() {
713        let cli = Cli::try_parse_from(["rc", "cors", "set", "local/my-bucket", "cors.xml"])
714            .expect("parse top-level cors set with positional source");
715
716        match cli.command {
717            Commands::Cors(cors::CorsCommands::Set(arg)) => {
718                assert_eq!(arg.path, "local/my-bucket");
719                assert_eq!(arg.source.as_deref(), Some("cors.xml"));
720                assert_eq!(arg.file, None);
721                assert!(!arg.force);
722            }
723            other => panic!("expected top-level cors set command, got {:?}", other),
724        }
725    }
726
727    #[test]
728    fn cli_accepts_top_level_cors_set_with_legacy_file_flag() {
729        let cli = Cli::try_parse_from([
730            "rc",
731            "cors",
732            "set",
733            "local/my-bucket",
734            "--file",
735            "cors.json",
736            "--force",
737        ])
738        .expect("parse top-level cors set with --file");
739
740        match cli.command {
741            Commands::Cors(cors::CorsCommands::Set(arg)) => {
742                assert_eq!(arg.path, "local/my-bucket");
743                assert_eq!(arg.source, None);
744                assert_eq!(arg.file.as_deref(), Some("cors.json"));
745                assert!(arg.force);
746            }
747            other => panic!("expected top-level cors set command, got {:?}", other),
748        }
749    }
750
751    #[test]
752    fn cli_accepts_bucket_cors_list_force_flag() {
753        let cli =
754            Cli::try_parse_from(["rc", "bucket", "cors", "list", "local/my-bucket", "--force"])
755                .expect("parse bucket cors list with force");
756
757        match cli.command {
758            Commands::Bucket(args) => match args.command {
759                bucket::BucketCommands::Cors(cors::CorsCommands::List(arg)) => {
760                    assert_eq!(arg.path, "local/my-bucket");
761                    assert!(arg.force);
762                }
763                other => panic!("expected bucket cors list command, got {:?}", other),
764            },
765            other => panic!("expected bucket command, got {:?}", other),
766        }
767    }
768
769    #[test]
770    fn cli_accepts_bucket_lifecycle_subcommand() {
771        let cli = Cli::try_parse_from([
772            "rc",
773            "bucket",
774            "lifecycle",
775            "rule",
776            "list",
777            "local/my-bucket",
778        ])
779        .expect("parse bucket lifecycle rule list");
780
781        match cli.command {
782            Commands::Bucket(args) => match args.command {
783                bucket::BucketCommands::Lifecycle(ilm::IlmArgs {
784                    command: ilm::IlmCommands::Rule(ilm::rule::RuleCommands::List(arg)),
785                }) => {
786                    assert_eq!(arg.path, "local/my-bucket");
787                    assert!(!arg.force);
788                }
789                other => panic!(
790                    "expected bucket lifecycle rule list command, got {:?}",
791                    other
792                ),
793            },
794            other => panic!("expected bucket command, got {:?}", other),
795        }
796    }
797
798    #[test]
799    fn cli_accepts_bucket_replication_subcommand() {
800        let cli = Cli::try_parse_from(["rc", "bucket", "replication", "status", "local/my-bucket"])
801            .expect("parse bucket replication status");
802
803        match cli.command {
804            Commands::Bucket(args) => match args.command {
805                bucket::BucketCommands::Replication(replicate::ReplicateArgs {
806                    command: replicate::ReplicateCommands::Status(arg),
807                }) => {
808                    assert_eq!(arg.path, "local/my-bucket");
809                    assert!(!arg.force);
810                }
811                other => panic!(
812                    "expected bucket replication status command, got {:?}",
813                    other
814                ),
815            },
816            other => panic!("expected bucket command, got {:?}", other),
817        }
818    }
819
820    #[test]
821    fn cli_accepts_bucket_remove_subcommand() {
822        let cli = Cli::try_parse_from(["rc", "bucket", "remove", "local/my-bucket"])
823            .expect("parse bucket remove");
824
825        match cli.command {
826            Commands::Bucket(args) => match args.command {
827                bucket::BucketCommands::Remove(arg) => {
828                    assert_eq!(arg.target, "local/my-bucket");
829                }
830                other => panic!("expected bucket remove command, got {:?}", other),
831            },
832            other => panic!("expected bucket command, got {:?}", other),
833        }
834    }
835
836    #[test]
837    fn cli_accepts_object_remove_subcommand() {
838        let cli = Cli::try_parse_from([
839            "rc",
840            "object",
841            "remove",
842            "local/my-bucket/report.csv",
843            "--dry-run",
844        ])
845        .expect("parse object remove");
846
847        match cli.command {
848            Commands::Object(args) => match args.command {
849                object::ObjectCommands::Remove(arg) => {
850                    assert_eq!(arg.paths, vec!["local/my-bucket/report.csv".to_string()]);
851                    assert!(arg.dry_run);
852                }
853                other => panic!("expected object remove command, got {:?}", other),
854            },
855            other => panic!("expected object command, got {:?}", other),
856        }
857    }
858
859    #[test]
860    fn cli_accepts_bucket_event_remove_subcommand() {
861        let cli = Cli::try_parse_from([
862            "rc",
863            "bucket",
864            "event",
865            "remove",
866            "local/my-bucket",
867            "arn:aws:sns:us-east-1:123456789012:alerts",
868        ])
869        .expect("parse bucket event remove");
870
871        match cli.command {
872            Commands::Bucket(args) => match args.command {
873                bucket::BucketCommands::Event(event::EventCommands::Remove(arg)) => {
874                    assert_eq!(arg.path, "local/my-bucket");
875                    assert_eq!(arg.arn, "arn:aws:sns:us-east-1:123456789012:alerts");
876                }
877                other => panic!("expected bucket event remove command, got {:?}", other),
878            },
879            other => panic!("expected bucket command, got {:?}", other),
880        }
881    }
882
883    #[test]
884    fn cli_accepts_rm_purge_flag() {
885        let cli = Cli::try_parse_from(["rc", "rm", "local/my-bucket/object.txt", "--purge"])
886            .expect("parse rm purge");
887
888        match cli.command {
889            Commands::Rm(arg) => {
890                assert_eq!(arg.paths, vec!["local/my-bucket/object.txt".to_string()]);
891                assert!(arg.purge);
892            }
893            other => panic!("expected rm command, got {:?}", other),
894        }
895    }
896
897    #[test]
898    fn cli_accepts_object_remove_purge_flag() {
899        let cli = Cli::try_parse_from([
900            "rc",
901            "object",
902            "remove",
903            "local/my-bucket/object.txt",
904            "--purge",
905        ])
906        .expect("parse object remove purge");
907
908        match cli.command {
909            Commands::Object(args) => match args.command {
910                object::ObjectCommands::Remove(arg) => {
911                    assert_eq!(arg.paths, vec!["local/my-bucket/object.txt".to_string()]);
912                    assert!(arg.purge);
913                }
914                other => panic!("expected object remove command, got {:?}", other),
915            },
916            other => panic!("expected object command, got {:?}", other),
917        }
918    }
919
920    #[test]
921    fn cli_accepts_object_stat_subcommand() {
922        let cli = Cli::try_parse_from(["rc", "object", "stat", "local/my-bucket/report.json"])
923            .expect("parse object stat");
924
925        match cli.command {
926            Commands::Object(args) => match args.command {
927                object::ObjectCommands::Stat(arg) => {
928                    assert_eq!(arg.path, "local/my-bucket/report.json");
929                }
930                other => panic!("expected object stat command, got {:?}", other),
931            },
932            other => panic!("expected object command, got {:?}", other),
933        }
934    }
935
936    #[test]
937    fn cli_accepts_object_copy_with_transfer_options() {
938        let cli = Cli::try_parse_from([
939            "rc",
940            "object",
941            "copy",
942            "./report.json",
943            "local/my-bucket/reports/",
944            "--content-type",
945            "application/json",
946            "--storage-class",
947            "STANDARD_IA",
948            "--dry-run",
949        ])
950        .expect("parse object copy with transfer options");
951
952        match cli.command {
953            Commands::Object(args) => match args.command {
954                object::ObjectCommands::Copy(arg) => {
955                    assert_eq!(arg.source, "./report.json");
956                    assert_eq!(arg.target, "local/my-bucket/reports/");
957                    assert_eq!(arg.content_type.as_deref(), Some("application/json"));
958                    assert_eq!(arg.storage_class.as_deref(), Some("STANDARD_IA"));
959                    assert!(arg.dry_run);
960                }
961                other => panic!("expected object copy command, got {:?}", other),
962            },
963            other => panic!("expected object command, got {:?}", other),
964        }
965    }
966
967    #[test]
968    fn cli_accepts_object_move_with_recursive_dry_run() {
969        let cli = Cli::try_parse_from([
970            "rc",
971            "object",
972            "move",
973            "local/source-bucket/logs/",
974            "local/archive-bucket/logs/",
975            "--recursive",
976            "--dry-run",
977            "--continue-on-error",
978        ])
979        .expect("parse object move with recursive dry-run");
980
981        match cli.command {
982            Commands::Object(args) => match args.command {
983                object::ObjectCommands::Move(arg) => {
984                    assert_eq!(arg.source, "local/source-bucket/logs/");
985                    assert_eq!(arg.target, "local/archive-bucket/logs/");
986                    assert!(arg.recursive);
987                    assert!(arg.dry_run);
988                    assert!(arg.continue_on_error);
989                }
990                other => panic!("expected object move command, got {:?}", other),
991            },
992            other => panic!("expected object command, got {:?}", other),
993        }
994    }
995
996    #[test]
997    fn cli_accepts_object_show_and_head_options() {
998        let show_cli = Cli::try_parse_from([
999            "rc",
1000            "object",
1001            "show",
1002            "local/my-bucket/report.json",
1003            "--version-id",
1004            "v1",
1005            "--rewind",
1006            "1h",
1007        ])
1008        .expect("parse object show options");
1009
1010        match show_cli.command {
1011            Commands::Object(args) => match args.command {
1012                object::ObjectCommands::Show(arg) => {
1013                    assert_eq!(arg.path, "local/my-bucket/report.json");
1014                    assert_eq!(arg.version_id.as_deref(), Some("v1"));
1015                    assert_eq!(arg.rewind.as_deref(), Some("1h"));
1016                }
1017                other => panic!("expected object show command, got {:?}", other),
1018            },
1019            other => panic!("expected object command, got {:?}", other),
1020        }
1021
1022        let head_cli = Cli::try_parse_from([
1023            "rc",
1024            "object",
1025            "head",
1026            "local/my-bucket/report.json",
1027            "--bytes",
1028            "128",
1029            "--version-id",
1030            "v2",
1031        ])
1032        .expect("parse object head options");
1033
1034        match head_cli.command {
1035            Commands::Object(args) => match args.command {
1036                object::ObjectCommands::Head(arg) => {
1037                    assert_eq!(arg.path, "local/my-bucket/report.json");
1038                    assert_eq!(arg.bytes, Some(128));
1039                    assert_eq!(arg.version_id.as_deref(), Some("v2"));
1040                }
1041                other => panic!("expected object head command, got {:?}", other),
1042            },
1043            other => panic!("expected object command, got {:?}", other),
1044        }
1045    }
1046
1047    #[test]
1048    fn cli_accepts_object_find_and_tree_options() {
1049        let find_cli = Cli::try_parse_from([
1050            "rc",
1051            "object",
1052            "find",
1053            "local/my-bucket/logs/",
1054            "--name",
1055            "*.json",
1056            "--maxdepth",
1057            "2",
1058            "--count",
1059            "--print",
1060        ])
1061        .expect("parse object find options");
1062
1063        match find_cli.command {
1064            Commands::Object(args) => match args.command {
1065                object::ObjectCommands::Find(arg) => {
1066                    assert_eq!(arg.path, "local/my-bucket/logs/");
1067                    assert_eq!(arg.name.as_deref(), Some("*.json"));
1068                    assert_eq!(arg.maxdepth, 2);
1069                    assert!(arg.count);
1070                    assert!(arg.print);
1071                }
1072                other => panic!("expected object find command, got {:?}", other),
1073            },
1074            other => panic!("expected object command, got {:?}", other),
1075        }
1076
1077        let tree_cli = Cli::try_parse_from([
1078            "rc",
1079            "object",
1080            "tree",
1081            "local/my-bucket/logs/",
1082            "--level",
1083            "4",
1084            "--size",
1085            "--pattern",
1086            "*.json",
1087            "--full-path",
1088        ])
1089        .expect("parse object tree options");
1090
1091        match tree_cli.command {
1092            Commands::Object(args) => match args.command {
1093                object::ObjectCommands::Tree(arg) => {
1094                    assert_eq!(arg.path, "local/my-bucket/logs/");
1095                    assert_eq!(arg.level, 4);
1096                    assert!(arg.size);
1097                    assert_eq!(arg.pattern.as_deref(), Some("*.json"));
1098                    assert!(arg.full_path);
1099                }
1100                other => panic!("expected object tree command, got {:?}", other),
1101            },
1102            other => panic!("expected object command, got {:?}", other),
1103        }
1104    }
1105}