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_remove_subcommand() {
823        let cli = Cli::try_parse_from(["rc", "bucket", "remove", "local/my-bucket"])
824            .expect("parse bucket remove");
825
826        match cli.command {
827            Commands::Bucket(args) => match args.command {
828                bucket::BucketCommands::Remove(arg) => {
829                    assert_eq!(arg.target, "local/my-bucket");
830                }
831                other => panic!("expected bucket remove command, got {:?}", other),
832            },
833            other => panic!("expected bucket command, got {:?}", other),
834        }
835    }
836
837    #[test]
838    fn cli_accepts_object_remove_subcommand() {
839        let cli = Cli::try_parse_from([
840            "rc",
841            "object",
842            "remove",
843            "local/my-bucket/report.csv",
844            "--dry-run",
845        ])
846        .expect("parse object remove");
847
848        match cli.command {
849            Commands::Object(args) => match args.command {
850                object::ObjectCommands::Remove(arg) => {
851                    assert_eq!(arg.paths, vec!["local/my-bucket/report.csv".to_string()]);
852                    assert!(arg.dry_run);
853                }
854                other => panic!("expected object remove command, got {:?}", other),
855            },
856            other => panic!("expected object command, got {:?}", other),
857        }
858    }
859
860    #[test]
861    fn cli_accepts_bucket_event_remove_subcommand() {
862        let cli = Cli::try_parse_from([
863            "rc",
864            "bucket",
865            "event",
866            "remove",
867            "local/my-bucket",
868            "arn:aws:sns:us-east-1:123456789012:alerts",
869        ])
870        .expect("parse bucket event remove");
871
872        match cli.command {
873            Commands::Bucket(args) => match args.command {
874                bucket::BucketCommands::Event(event::EventCommands::Remove(arg)) => {
875                    assert_eq!(arg.path, "local/my-bucket");
876                    assert_eq!(arg.arn, "arn:aws:sns:us-east-1:123456789012:alerts");
877                }
878                other => panic!("expected bucket event remove command, got {:?}", other),
879            },
880            other => panic!("expected bucket command, got {:?}", other),
881        }
882    }
883
884    #[test]
885    fn cli_accepts_rm_purge_flag() {
886        let cli = Cli::try_parse_from(["rc", "rm", "local/my-bucket/object.txt", "--purge"])
887            .expect("parse rm purge");
888
889        match cli.command {
890            Commands::Rm(arg) => {
891                assert_eq!(arg.paths, vec!["local/my-bucket/object.txt".to_string()]);
892                assert!(arg.purge);
893            }
894            other => panic!("expected rm command, got {:?}", other),
895        }
896    }
897
898    #[test]
899    fn cli_accepts_object_remove_purge_flag() {
900        let cli = Cli::try_parse_from([
901            "rc",
902            "object",
903            "remove",
904            "local/my-bucket/object.txt",
905            "--purge",
906        ])
907        .expect("parse object remove purge");
908
909        match cli.command {
910            Commands::Object(args) => match args.command {
911                object::ObjectCommands::Remove(arg) => {
912                    assert_eq!(arg.paths, vec!["local/my-bucket/object.txt".to_string()]);
913                    assert!(arg.purge);
914                }
915                other => panic!("expected object remove command, got {:?}", other),
916            },
917            other => panic!("expected object command, got {:?}", other),
918        }
919    }
920
921    #[test]
922    fn cli_accepts_object_stat_subcommand() {
923        let cli = Cli::try_parse_from(["rc", "object", "stat", "local/my-bucket/report.json"])
924            .expect("parse object stat");
925
926        match cli.command {
927            Commands::Object(args) => match args.command {
928                object::ObjectCommands::Stat(arg) => {
929                    assert_eq!(arg.path, "local/my-bucket/report.json");
930                }
931                other => panic!("expected object stat command, got {:?}", other),
932            },
933            other => panic!("expected object command, got {:?}", other),
934        }
935    }
936
937    #[test]
938    fn cli_accepts_object_copy_with_transfer_options() {
939        let cli = Cli::try_parse_from([
940            "rc",
941            "object",
942            "copy",
943            "./report.json",
944            "local/my-bucket/reports/",
945            "--content-type",
946            "application/json",
947            "--storage-class",
948            "STANDARD_IA",
949            "--dry-run",
950        ])
951        .expect("parse object copy with transfer options");
952
953        match cli.command {
954            Commands::Object(args) => match args.command {
955                object::ObjectCommands::Copy(arg) => {
956                    assert_eq!(arg.source, "./report.json");
957                    assert_eq!(arg.target, "local/my-bucket/reports/");
958                    assert_eq!(arg.content_type.as_deref(), Some("application/json"));
959                    assert_eq!(arg.storage_class.as_deref(), Some("STANDARD_IA"));
960                    assert!(arg.dry_run);
961                }
962                other => panic!("expected object copy command, got {:?}", other),
963            },
964            other => panic!("expected object command, got {:?}", other),
965        }
966    }
967
968    #[test]
969    fn cli_accepts_object_move_with_recursive_dry_run() {
970        let cli = Cli::try_parse_from([
971            "rc",
972            "object",
973            "move",
974            "local/source-bucket/logs/",
975            "local/archive-bucket/logs/",
976            "--recursive",
977            "--dry-run",
978            "--continue-on-error",
979        ])
980        .expect("parse object move with recursive dry-run");
981
982        match cli.command {
983            Commands::Object(args) => match args.command {
984                object::ObjectCommands::Move(arg) => {
985                    assert_eq!(arg.source, "local/source-bucket/logs/");
986                    assert_eq!(arg.target, "local/archive-bucket/logs/");
987                    assert!(arg.recursive);
988                    assert!(arg.dry_run);
989                    assert!(arg.continue_on_error);
990                }
991                other => panic!("expected object move command, got {:?}", other),
992            },
993            other => panic!("expected object command, got {:?}", other),
994        }
995    }
996
997    #[test]
998    fn cli_accepts_object_show_and_head_options() {
999        let show_cli = Cli::try_parse_from([
1000            "rc",
1001            "object",
1002            "show",
1003            "local/my-bucket/report.json",
1004            "--version-id",
1005            "v1",
1006            "--rewind",
1007            "1h",
1008        ])
1009        .expect("parse object show options");
1010
1011        match show_cli.command {
1012            Commands::Object(args) => match args.command {
1013                object::ObjectCommands::Show(arg) => {
1014                    assert_eq!(arg.path, "local/my-bucket/report.json");
1015                    assert_eq!(arg.version_id.as_deref(), Some("v1"));
1016                    assert_eq!(arg.rewind.as_deref(), Some("1h"));
1017                }
1018                other => panic!("expected object show command, got {:?}", other),
1019            },
1020            other => panic!("expected object command, got {:?}", other),
1021        }
1022
1023        let head_cli = Cli::try_parse_from([
1024            "rc",
1025            "object",
1026            "head",
1027            "local/my-bucket/report.json",
1028            "--bytes",
1029            "128",
1030            "--version-id",
1031            "v2",
1032        ])
1033        .expect("parse object head options");
1034
1035        match head_cli.command {
1036            Commands::Object(args) => match args.command {
1037                object::ObjectCommands::Head(arg) => {
1038                    assert_eq!(arg.path, "local/my-bucket/report.json");
1039                    assert_eq!(arg.bytes, Some(128));
1040                    assert_eq!(arg.version_id.as_deref(), Some("v2"));
1041                }
1042                other => panic!("expected object head command, got {:?}", other),
1043            },
1044            other => panic!("expected object command, got {:?}", other),
1045        }
1046    }
1047
1048    #[test]
1049    fn cli_accepts_object_find_and_tree_options() {
1050        let find_cli = Cli::try_parse_from([
1051            "rc",
1052            "object",
1053            "find",
1054            "local/my-bucket/logs/",
1055            "--name",
1056            "*.json",
1057            "--maxdepth",
1058            "2",
1059            "--count",
1060            "--print",
1061        ])
1062        .expect("parse object find options");
1063
1064        match find_cli.command {
1065            Commands::Object(args) => match args.command {
1066                object::ObjectCommands::Find(arg) => {
1067                    assert_eq!(arg.path, "local/my-bucket/logs/");
1068                    assert_eq!(arg.name.as_deref(), Some("*.json"));
1069                    assert_eq!(arg.maxdepth, 2);
1070                    assert!(arg.count);
1071                    assert!(arg.print);
1072                }
1073                other => panic!("expected object find command, got {:?}", other),
1074            },
1075            other => panic!("expected object command, got {:?}", other),
1076        }
1077
1078        let tree_cli = Cli::try_parse_from([
1079            "rc",
1080            "object",
1081            "tree",
1082            "local/my-bucket/logs/",
1083            "--level",
1084            "4",
1085            "--size",
1086            "--pattern",
1087            "*.json",
1088            "--full-path",
1089        ])
1090        .expect("parse object tree options");
1091
1092        match tree_cli.command {
1093            Commands::Object(args) => match args.command {
1094                object::ObjectCommands::Tree(arg) => {
1095                    assert_eq!(arg.path, "local/my-bucket/logs/");
1096                    assert_eq!(arg.level, 4);
1097                    assert!(arg.size);
1098                    assert_eq!(arg.pattern.as_deref(), Some("*.json"));
1099                    assert!(arg.full_path);
1100                }
1101                other => panic!("expected object tree command, got {:?}", other),
1102            },
1103            other => panic!("expected object command, got {:?}", other),
1104        }
1105    }
1106}