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