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