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
16const COMPATIBILITY_AFTER_HELP: &str = "\
17Compatibility:
18  Canonical commands: alias, admin, bucket, object, and operational utilities
19  Compatibility aliases: mc-style names delegate to canonical rc implementations
20  Version gated: commands fail closed when RustFS does not advertise required support
21  Server blocked: unavailable mc families are listed with blockers in the compatibility matrix
22
23See: https://github.com/rustfs/cli/blob/main/docs/reference/rc/mc-compatibility.md";
24
25mod admin;
26mod alias;
27mod anonymous;
28mod bucket;
29mod cat;
30mod completions;
31mod cors;
32pub mod cp;
33pub mod diff;
34mod du;
35mod encryption;
36mod event;
37mod find;
38mod head;
39mod ilm;
40mod legalhold;
41mod lock;
42mod ls;
43mod mb;
44mod mirror;
45mod multipart;
46mod mv;
47mod object;
48mod object_identity;
49mod ops_output;
50mod ping;
51mod pipe;
52mod quota;
53mod rb;
54mod ready;
55mod replicate;
56mod retention;
57mod rm;
58mod share;
59mod sql;
60mod stat;
61mod table;
62mod tag;
63mod transfer_fidelity;
64mod tree;
65mod undo;
66mod version;
67mod watch;
68
69fn exit_code_for_core_error(error: &rc_core::Error) -> ExitCode {
70    ExitCode::from_i32(error.exit_code()).unwrap_or(ExitCode::GeneralError)
71}
72
73fn validate_version_selector(version_id: Option<&str>, rewind: Option<&str>) -> Result<(), String> {
74    if version_id.is_some() && rewind.is_some() {
75        return Err("--version-id cannot be combined with --rewind".to_string());
76    }
77    if version_id.is_some_and(str::is_empty) {
78        return Err("--version-id cannot be empty".to_string());
79    }
80    Ok(())
81}
82
83/// rc - Rust S3 CLI Client
84///
85/// A command-line interface for S3-compatible object storage services.
86/// Supports RustFS, AWS S3, and other S3-compatible backends.
87#[derive(Parser, Debug)]
88#[command(name = "rc")]
89#[command(author, version, about, long_about = None)]
90#[command(after_help = COMPATIBILITY_AFTER_HELP)]
91#[command(propagate_version = true)]
92pub struct Cli {
93    /// Output format: auto-detect, human-readable, or JSON
94    #[arg(long, global = true, value_enum)]
95    pub format: Option<OutputFormat>,
96
97    /// Output format: human-readable or JSON
98    #[arg(long, global = true, default_value = "false")]
99    pub json: bool,
100
101    /// Disable colored output
102    #[arg(long, global = true, default_value = "false")]
103    pub no_color: bool,
104
105    /// Disable progress bar
106    #[arg(long, global = true, default_value = "false")]
107    pub no_progress: bool,
108
109    /// Suppress non-error output
110    #[arg(short, long, global = true, default_value = "false")]
111    pub quiet: bool,
112
113    /// Enable debug logging
114    #[arg(long, global = true, default_value = "false")]
115    pub debug: bool,
116
117    /// Add an x-amz-* request header to signed S3 requests
118    #[arg(short = 'H', long = "header", global = true, value_parser = parse_request_header)]
119    pub request_headers: Vec<RequestHeader>,
120
121    #[command(subcommand)]
122    pub command: Commands,
123}
124
125fn parse_request_header(value: &str) -> Result<RequestHeader, String> {
126    let header = RequestHeader::parse(value).map_err(|error| error.to_string())?;
127    if header
128        .name
129        .eq_ignore_ascii_case("x-amz-bypass-governance-retention")
130    {
131        return Err(
132            "Use the retention or remove command's explicit --bypass flag for governance retention bypass"
133                .to_string(),
134        );
135    }
136    Ok(header)
137}
138
139#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
140pub enum OutputFormat {
141    Auto,
142    Human,
143    Json,
144}
145
146#[derive(Copy, Clone, Debug, Eq, PartialEq)]
147enum OutputBehavior {
148    HumanDefault,
149    StructuredDefault,
150}
151
152#[derive(Copy, Clone, Debug)]
153struct GlobalOutputOptions {
154    format: Option<OutputFormat>,
155    json: bool,
156    no_color: bool,
157    force_color: bool,
158    no_progress: bool,
159    quiet: bool,
160}
161
162impl GlobalOutputOptions {
163    fn from_cli(cli: &Cli) -> Self {
164        Self {
165            format: cli.format,
166            json: cli.json,
167            no_color: cli.no_color,
168            force_color: false,
169            no_progress: cli.no_progress,
170            quiet: cli.quiet,
171        }
172    }
173
174    fn apply_defaults(mut self, defaults: &Defaults) -> Result<Self, String> {
175        if self.format.is_none() && !self.json {
176            self.format = Some(match defaults.output.as_str() {
177                "human" => OutputFormat::Human,
178                "json" => OutputFormat::Json,
179                value => return Err(format!("Invalid default output format '{value}'")),
180            });
181        }
182
183        if !self.no_color {
184            match defaults.color.as_str() {
185                "auto" => {}
186                "always" => self.force_color = true,
187                "never" => self.no_color = true,
188                value => return Err(format!("Invalid default color mode '{value}'")),
189            }
190        }
191        if !defaults.progress {
192            self.no_progress = true;
193        }
194
195        Ok(self)
196    }
197
198    fn resolve(self, behavior: OutputBehavior) -> OutputConfig {
199        let stdout_is_tty = stdout().is_terminal();
200        let stderr_is_tty = stderr().is_terminal();
201
202        let selected_format = if self.json {
203            OutputFormat::Json
204        } else {
205            self.format.unwrap_or(match behavior {
206                OutputBehavior::HumanDefault => OutputFormat::Human,
207                OutputBehavior::StructuredDefault => OutputFormat::Auto,
208            })
209        };
210
211        let json = match selected_format {
212            OutputFormat::Json => true,
213            OutputFormat::Human => false,
214            OutputFormat::Auto => !stdout_is_tty,
215        };
216
217        OutputConfig {
218            json,
219            no_color: self.no_color || (!self.force_color && !stdout_is_tty) || json,
220            no_progress: self.no_progress || !stderr_is_tty || json,
221            quiet: self.quiet,
222        }
223    }
224}
225
226#[derive(Subcommand, Debug)]
227pub enum Commands {
228    /// Manage RustFS Iceberg tables and namespaces
229    #[command(subcommand)]
230    Table(table::TableCommands),
231    /// Manage storage service aliases
232    #[command(subcommand)]
233    Alias(alias::AliasCommands),
234
235    /// Manage IAM users, policies, groups, and service accounts
236    #[command(subcommand)]
237    Admin(admin::AdminCommands),
238
239    /// Manage bucket-oriented workflows
240    Bucket(bucket::BucketArgs),
241
242    /// Manage object-oriented workflows
243    Object(object::ObjectArgs),
244
245    // Phase 2: Basic commands
246    /// Deprecated: use `rc bucket list` or `rc object list`
247    Ls(ls::LsArgs),
248
249    /// Deprecated: use `rc bucket create`
250    Mb(mb::MbArgs),
251
252    /// Deprecated: use `rc bucket remove`
253    Rb(rb::RbArgs),
254
255    /// Deprecated: use `rc object show`
256    Cat(cat::CatArgs),
257
258    /// Deprecated: use `rc object head`
259    Head(head::HeadArgs),
260
261    /// Deprecated: use `rc object stat`
262    Stat(stat::StatArgs),
263
264    // Phase 3: Transfer commands
265    /// Deprecated: use `rc object copy`
266    Cp(Box<cp::CpArgs>),
267
268    /// Download one remote object to the local filesystem
269    Get(Box<cp::GetArgs>),
270
271    /// Upload one or more local paths to remote object storage
272    Put(Box<cp::PutArgs>),
273
274    /// Deprecated: use `rc object move`
275    Mv(mv::MvArgs),
276
277    /// Deprecated: use `rc object remove`
278    Rm(rm::RmArgs),
279
280    /// Restore a versioned PUT or DELETE operation without removing data history
281    Undo(undo::UndoArgs),
282
283    /// Stream stdin to an object
284    Pipe(pipe::PipeArgs),
285
286    // Phase 4: Advanced commands
287    /// Deprecated: use `rc object find`
288    Find(find::FindArgs),
289
290    /// Deprecated: use `rc bucket event`
291    Event(event::EventArgs),
292
293    /// Deprecated: use `rc bucket cors`
294    #[command(subcommand)]
295    Cors(cors::CorsCommands),
296
297    /// Show differences between locations
298    Diff(diff::DiffArgs),
299
300    /// Mirror objects between locations
301    Mirror(mirror::MirrorArgs),
302
303    /// Deprecated: use `rc object tree`
304    Tree(tree::TreeArgs),
305
306    /// Deprecated: use `rc object share`
307    Share(share::ShareArgs),
308
309    /// Run S3 Select SQL on an object
310    Sql(sql::SqlArgs),
311
312    // Phase 5: Optional commands (capability-dependent)
313    /// Deprecated: use `rc bucket version`
314    #[command(subcommand)]
315    Version(version::VersionCommands),
316
317    /// Manage bucket and object tags
318    #[command(subcommand)]
319    Tag(tag::TagCommands),
320
321    /// Deprecated: use `rc bucket anonymous`
322    #[command(subcommand)]
323    Anonymous(anonymous::AnonymousCommands),
324
325    /// Deprecated: use `rc bucket quota`
326    #[command(subcommand)]
327    Quota(quota::QuotaCommands),
328
329    /// Deprecated: use `rc bucket lifecycle`
330    Ilm(ilm::IlmArgs),
331
332    /// Deprecated: use `rc bucket replication`
333    Replicate(replicate::ReplicateArgs),
334
335    /// Manage object retention with mc-compatible command syntax
336    Retention(retention::RetentionArgs),
337
338    /// Manage object legal hold with mc-compatible command syntax
339    Legalhold(legalhold::LegalHoldArgs),
340
341    // Phase 6: Utilities
342    /// Generate shell completion scripts
343    Completions(completions::CompletionsArgs),
344
345    /// Summarize storage usage
346    Du(du::DuArgs),
347
348    /// Check service liveness and round-trip latency
349    Ping(ping::PingArgs),
350
351    /// Check whether required dependencies are ready
352    Ready(ready::ReadyArgs),
353
354    /// Stream live object notifications
355    Watch(watch::WatchArgs),
356}
357
358/// Execute the CLI command and return an exit code
359pub async fn execute(cli: Cli) -> ExitCode {
360    set_global_request_headers(cli.request_headers.clone());
361    let cli_output_options = GlobalOutputOptions::from_cli(&cli);
362    let defaults = match ConfigManager::new() {
363        Ok(manager) if manager.config_path().exists() => match manager.load() {
364            Ok(config) => Some(config.defaults),
365            Err(error) => {
366                return Formatter::new(cli_output_options.resolve(OutputBehavior::HumanDefault))
367                    .fail(
368                        ExitCode::GeneralError,
369                        &format!("Failed to load configuration: {error}"),
370                    );
371            }
372        },
373        Ok(_) => None,
374        Err(error) => {
375            return Formatter::new(cli_output_options.resolve(OutputBehavior::HumanDefault)).fail(
376                ExitCode::GeneralError,
377                &format!("Failed to load configuration: {error}"),
378            );
379        }
380    };
381    let output_options = cli_output_options;
382    let output_options = if let Some(defaults) = &defaults {
383        match output_options.apply_defaults(defaults) {
384            Ok(options) => options,
385            Err(error) => {
386                return Formatter::new(cli_output_options.resolve(OutputBehavior::HumanDefault))
387                    .fail(
388                        ExitCode::GeneralError,
389                        &format!("Failed to load configuration: {error}"),
390                    );
391            }
392        }
393    } else {
394        output_options
395    };
396
397    match cli.command {
398        Commands::Table(cmd) => {
399            table::execute(
400                cmd,
401                &crate::output::Formatter::new(
402                    output_options.resolve(OutputBehavior::StructuredDefault),
403                ),
404            )
405            .await
406        }
407        Commands::Alias(cmd) => {
408            alias::execute(cmd, output_options.resolve(OutputBehavior::HumanDefault)).await
409        }
410        Commands::Admin(cmd) => {
411            admin::execute(cmd, output_options.resolve(OutputBehavior::HumanDefault)).await
412        }
413        Commands::Bucket(args) => {
414            bucket::execute(
415                args,
416                output_options.resolve(OutputBehavior::StructuredDefault),
417            )
418            .await
419        }
420        Commands::Object(args) => {
421            let behavior = match &args.command {
422                object::ObjectCommands::Show(_) | object::ObjectCommands::Head(_) => {
423                    OutputBehavior::HumanDefault
424                }
425                _ => OutputBehavior::StructuredDefault,
426            };
427            object::execute(args, output_options.resolve(behavior)).await
428        }
429        Commands::Ls(args) => {
430            ls::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
431        }
432        Commands::Mb(args) => {
433            mb::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
434        }
435        Commands::Rb(args) => {
436            rb::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
437        }
438        Commands::Cat(args) => {
439            cat::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
440        }
441        Commands::Head(args) => {
442            head::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
443        }
444        Commands::Stat(args) => {
445            stat::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
446        }
447        Commands::Cp(args) => {
448            cp::execute(*args, output_options.resolve(OutputBehavior::HumanDefault)).await
449        }
450        Commands::Get(args) => {
451            cp::execute_get(*args, output_options.resolve(OutputBehavior::HumanDefault)).await
452        }
453        Commands::Put(args) => {
454            cp::execute_put(*args, output_options.resolve(OutputBehavior::HumanDefault)).await
455        }
456        Commands::Mv(args) => {
457            mv::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
458        }
459        Commands::Rm(args) => {
460            rm::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
461        }
462        Commands::Undo(args) => {
463            undo::execute(
464                args,
465                output_options.resolve(OutputBehavior::StructuredDefault),
466            )
467            .await
468        }
469        Commands::Pipe(args) => {
470            pipe::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
471        }
472        Commands::Find(args) => {
473            find::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
474        }
475        Commands::Event(args) => {
476            event::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
477        }
478        Commands::Cors(cmd) => {
479            cors::execute(
480                cors::CorsArgs { command: cmd },
481                output_options.resolve(OutputBehavior::HumanDefault),
482            )
483            .await
484        }
485        Commands::Diff(args) => {
486            diff::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
487        }
488        Commands::Mirror(args) => {
489            mirror::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
490        }
491        Commands::Tree(args) => {
492            tree::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
493        }
494        Commands::Share(args) => {
495            share::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
496        }
497        Commands::Sql(args) => {
498            sql::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
499        }
500        Commands::Version(cmd) => {
501            version::execute(
502                version::VersionArgs { command: cmd },
503                output_options.resolve(OutputBehavior::HumanDefault),
504            )
505            .await
506        }
507        Commands::Tag(cmd) => {
508            tag::execute(
509                tag::TagArgs { command: cmd },
510                output_options.resolve(OutputBehavior::HumanDefault),
511            )
512            .await
513        }
514        Commands::Anonymous(cmd) => {
515            anonymous::execute(
516                anonymous::AnonymousArgs { command: cmd },
517                output_options.resolve(OutputBehavior::HumanDefault),
518            )
519            .await
520        }
521        Commands::Quota(cmd) => {
522            quota::execute(
523                quota::QuotaArgs { command: cmd },
524                output_options.resolve(OutputBehavior::HumanDefault),
525            )
526            .await
527        }
528        Commands::Ilm(args) => {
529            ilm::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
530        }
531        Commands::Replicate(args) => {
532            replicate::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
533        }
534        Commands::Retention(args) => {
535            retention::execute(
536                args,
537                output_options.resolve(OutputBehavior::StructuredDefault),
538            )
539            .await
540        }
541        Commands::Legalhold(args) => {
542            legalhold::execute(
543                args,
544                output_options.resolve(OutputBehavior::StructuredDefault),
545            )
546            .await
547        }
548        Commands::Completions(args) => completions::execute(args),
549        Commands::Watch(args) => {
550            watch::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
551        }
552        Commands::Du(args) => {
553            du::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
554        }
555        Commands::Ping(args) => {
556            ping::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
557        }
558        Commands::Ready(args) => {
559            ready::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
560        }
561    }
562}
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567    use clap::Parser;
568
569    #[test]
570    fn structured_default_uses_auto_format_when_not_explicit() {
571        let options = GlobalOutputOptions {
572            format: None,
573            json: false,
574            no_color: false,
575            force_color: false,
576            no_progress: false,
577            quiet: false,
578        };
579
580        let resolved = options.resolve(OutputBehavior::StructuredDefault);
581        assert_eq!(resolved.json, !std::io::stdout().is_terminal());
582    }
583
584    #[test]
585    fn human_default_keeps_human_format_when_not_explicit() {
586        let options = GlobalOutputOptions {
587            format: None,
588            json: false,
589            no_color: false,
590            force_color: false,
591            no_progress: false,
592            quiet: false,
593        };
594
595        let resolved = options.resolve(OutputBehavior::HumanDefault);
596        assert!(!resolved.json);
597    }
598
599    #[test]
600    fn explicit_json_overrides_behavior_defaults() {
601        let options = GlobalOutputOptions {
602            format: Some(OutputFormat::Human),
603            json: true,
604            no_color: false,
605            force_color: false,
606            no_progress: false,
607            quiet: false,
608        };
609
610        let resolved = options.resolve(OutputBehavior::HumanDefault);
611        assert!(resolved.json);
612    }
613
614    #[test]
615    fn explicit_human_overrides_structured_default() {
616        let options = GlobalOutputOptions {
617            format: Some(OutputFormat::Human),
618            json: false,
619            no_color: false,
620            force_color: false,
621            no_progress: false,
622            quiet: false,
623        };
624
625        let resolved = options.resolve(OutputBehavior::StructuredDefault);
626        assert!(!resolved.json);
627    }
628
629    #[test]
630    fn explicit_auto_overrides_human_default() {
631        let options = GlobalOutputOptions {
632            format: Some(OutputFormat::Auto),
633            json: false,
634            no_color: false,
635            force_color: false,
636            no_progress: false,
637            quiet: false,
638        };
639
640        let resolved = options.resolve(OutputBehavior::HumanDefault);
641        assert_eq!(resolved.json, !std::io::stdout().is_terminal());
642    }
643
644    #[test]
645    fn configured_defaults_control_output_color_and_progress() {
646        let options = GlobalOutputOptions {
647            format: None,
648            json: false,
649            no_color: false,
650            force_color: false,
651            no_progress: false,
652            quiet: false,
653        };
654        let defaults = Defaults {
655            output: "json".to_string(),
656            color: "never".to_string(),
657            progress: false,
658        };
659
660        let resolved = options
661            .apply_defaults(&defaults)
662            .expect("valid defaults")
663            .resolve(OutputBehavior::HumanDefault);
664
665        assert!(resolved.json);
666        assert!(resolved.no_color);
667        assert!(resolved.no_progress);
668    }
669
670    #[test]
671    fn invalid_configured_defaults_are_rejected() {
672        let options = GlobalOutputOptions {
673            format: None,
674            json: false,
675            no_color: false,
676            force_color: false,
677            no_progress: false,
678            quiet: false,
679        };
680        let defaults = Defaults {
681            output: "yaml".to_string(),
682            ..Defaults::default()
683        };
684
685        assert!(options.apply_defaults(&defaults).is_err());
686    }
687
688    #[test]
689    fn cli_accepts_global_custom_amz_header() {
690        let cli = Cli::try_parse_from([
691            "rc",
692            "-H",
693            "x-amz-bucket-encrypt-enabled:1",
694            "bucket",
695            "list",
696            "local/",
697        ])
698        .expect("parse custom header");
699
700        assert_eq!(cli.request_headers.len(), 1);
701        assert_eq!(cli.request_headers[0].name, "x-amz-bucket-encrypt-enabled");
702        assert_eq!(cli.request_headers[0].value, "1");
703    }
704
705    #[test]
706    fn cli_rejects_non_amz_custom_header() {
707        let error = Cli::try_parse_from(["rc", "-H", "authorization:secret", "ls", "local/"])
708            .expect_err("non amz header should fail");
709
710        assert!(
711            error
712                .to_string()
713                .contains("Only x-amz-* custom request headers are supported")
714        );
715    }
716
717    #[test]
718    fn cli_accepts_repeatable_watch_event_filters() {
719        let cli = Cli::try_parse_from([
720            "rc",
721            "watch",
722            "local/photos",
723            "--event",
724            "put,delete",
725            "--event",
726            "get",
727            "--prefix",
728            "incoming/",
729        ])
730        .expect("parse watch command");
731
732        let Commands::Watch(args) = cli.command else {
733            panic!("expected watch command");
734        };
735        assert_eq!(args.events, vec!["put,delete", "get"]);
736        assert_eq!(args.prefix.as_deref(), Some("incoming/"));
737    }
738
739    #[test]
740    fn cli_requires_bypass_flag_instead_of_custom_governance_header() {
741        let error = Cli::try_parse_from([
742            "rc",
743            "-H",
744            "x-amz-bypass-governance-retention:true",
745            "rm",
746            "local/bucket/key.txt",
747        ])
748        .expect_err("governance bypass header should require --bypass");
749
750        assert!(error.to_string().contains("explicit --bypass flag"));
751    }
752
753    #[test]
754    fn cli_accepts_bucket_cors_subcommand() {
755        let cli = Cli::try_parse_from(["rc", "bucket", "cors", "list", "local/my-bucket"])
756            .expect("parse bucket cors");
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                }
763                other => panic!("expected bucket cors list command, got {:?}", other),
764            },
765            other => panic!("expected bucket command, got {:?}", other),
766        }
767    }
768
769    #[test]
770    fn cli_accepts_bucket_list_alias() {
771        let cli =
772            Cli::try_parse_from(["rc", "bucket", "ls", "local/"]).expect("parse bucket ls alias");
773
774        match cli.command {
775            Commands::Bucket(args) => match args.command {
776                bucket::BucketCommands::List(arg) => {
777                    assert_eq!(arg.path, "local/");
778                }
779                other => panic!("expected bucket list alias, got {:?}", other),
780            },
781            other => panic!("expected bucket command, got {:?}", other),
782        }
783    }
784
785    #[test]
786    fn cli_accepts_top_level_cors_subcommand() {
787        let cli = Cli::try_parse_from(["rc", "cors", "remove", "local/my-bucket"])
788            .expect("parse top-level cors");
789
790        match cli.command {
791            Commands::Cors(cors::CorsCommands::Remove(arg)) => {
792                assert_eq!(arg.path, "local/my-bucket");
793            }
794            other => panic!("expected top-level cors remove command, got {:?}", other),
795        }
796    }
797
798    #[test]
799    fn cli_accepts_top_level_cors_get_alias() {
800        let cli =
801            Cli::try_parse_from(["rc", "cors", "get", "local/my-bucket"]).expect("parse cors get");
802
803        match cli.command {
804            Commands::Cors(cors::CorsCommands::List(arg)) => {
805                assert_eq!(arg.path, "local/my-bucket");
806            }
807            other => panic!("expected top-level cors get alias, got {:?}", other),
808        }
809    }
810
811    #[test]
812    fn cli_accepts_top_level_event_subcommand() {
813        let cli = Cli::try_parse_from(["rc", "event", "list", "local/my-bucket"])
814            .expect("parse top-level event");
815
816        match cli.command {
817            Commands::Event(event::EventArgs {
818                command: event::EventCommands::List(arg),
819            }) => {
820                assert_eq!(arg.path, "local/my-bucket");
821            }
822            other => panic!("expected top-level event list command, got {:?}", other),
823        }
824    }
825
826    #[test]
827    fn cli_accepts_top_level_event_add_subcommand() {
828        let cli = Cli::try_parse_from([
829            "rc",
830            "event",
831            "add",
832            "local/my-bucket",
833            "arn:aws:sqs:us-east-1:123456789012:jobs",
834            "--event",
835            "put,delete",
836            "--force",
837        ])
838        .expect("parse top-level event add");
839
840        match cli.command {
841            Commands::Event(event::EventArgs {
842                command: event::EventCommands::Add(arg),
843            }) => {
844                assert_eq!(arg.path, "local/my-bucket");
845                assert_eq!(arg.arn, "arn:aws:sqs:us-east-1:123456789012:jobs");
846                assert_eq!(arg.events, vec!["put,delete".to_string()]);
847                assert!(arg.force);
848            }
849            other => panic!("expected top-level event add command, got {:?}", other),
850        }
851    }
852
853    #[test]
854    fn cli_accepts_sql_select_options() {
855        let cli = Cli::try_parse_from([
856            "rc",
857            "sql",
858            "local/reports/data.jsonl",
859            "--query",
860            "SELECT * FROM S3Object",
861            "--input-format",
862            "json",
863            "--output-format",
864            "json",
865            "--compression",
866            "gzip",
867        ])
868        .expect("parse sql command");
869
870        match cli.command {
871            Commands::Sql(arg) => {
872                assert_eq!(arg.path, "local/reports/data.jsonl");
873                assert_eq!(arg.query, "SELECT * FROM S3Object");
874                assert!(matches!(arg.input_format, sql::InputFormatArg::Json));
875                assert!(matches!(arg.output_format, sql::OutputFormatArg::Json));
876                assert!(matches!(arg.compression, sql::CompressionArg::Gzip));
877            }
878            other => panic!("expected sql command, got {:?}", other),
879        }
880    }
881
882    #[test]
883    fn cli_accepts_sql_defaults() {
884        let cli = Cli::try_parse_from([
885            "rc",
886            "sql",
887            "local/reports/data.csv",
888            "--query",
889            "SELECT s._1 FROM S3Object s",
890        ])
891        .expect("parse sql command defaults");
892
893        match cli.command {
894            Commands::Sql(arg) => {
895                assert_eq!(arg.path, "local/reports/data.csv");
896                assert_eq!(arg.query, "SELECT s._1 FROM S3Object s");
897                assert!(matches!(arg.input_format, sql::InputFormatArg::Csv));
898                assert!(matches!(arg.output_format, sql::OutputFormatArg::Csv));
899                assert!(matches!(arg.compression, sql::CompressionArg::None));
900            }
901            other => panic!("expected sql command, got {:?}", other),
902        }
903    }
904
905    #[test]
906    fn cli_accepts_object_list_alias() {
907        let cli = Cli::try_parse_from(["rc", "object", "ls", "local/my-bucket/logs/"])
908            .expect("parse object ls alias");
909
910        match cli.command {
911            Commands::Object(args) => match args.command {
912                object::ObjectCommands::List(arg) => {
913                    assert_eq!(arg.path, "local/my-bucket/logs/");
914                }
915                other => panic!("expected object list alias, got {:?}", other),
916            },
917            other => panic!("expected object command, got {:?}", other),
918        }
919    }
920
921    #[test]
922    fn cli_accepts_top_level_event_remove_subcommand() {
923        let cli = Cli::try_parse_from([
924            "rc",
925            "event",
926            "remove",
927            "local/my-bucket",
928            "arn:aws:sns:us-east-1:123456789012:alerts",
929            "--force",
930        ])
931        .expect("parse top-level event remove");
932
933        match cli.command {
934            Commands::Event(event::EventArgs {
935                command: event::EventCommands::Remove(arg),
936            }) => {
937                assert_eq!(arg.path, "local/my-bucket");
938                assert_eq!(arg.arn, "arn:aws:sns:us-east-1:123456789012:alerts");
939                assert!(arg.force);
940            }
941            other => panic!("expected top-level event remove command, got {:?}", other),
942        }
943    }
944
945    #[test]
946    fn cli_accepts_bucket_cors_get_alias() {
947        let cli = Cli::try_parse_from(["rc", "bucket", "cors", "get", "local/my-bucket"])
948            .expect("parse bucket cors get");
949
950        match cli.command {
951            Commands::Bucket(args) => match args.command {
952                bucket::BucketCommands::Cors(cors::CorsCommands::List(arg)) => {
953                    assert_eq!(arg.path, "local/my-bucket");
954                }
955                other => panic!("expected bucket cors get alias, got {:?}", other),
956            },
957            other => panic!("expected bucket command, got {:?}", other),
958        }
959    }
960
961    #[test]
962    fn cli_accepts_bucket_cors_set_with_positional_source() {
963        let cli =
964            Cli::try_parse_from(["rc", "bucket", "cors", "set", "local/my-bucket", "cors.xml"])
965                .expect("parse bucket cors set with positional source");
966
967        match cli.command {
968            Commands::Bucket(args) => match args.command {
969                bucket::BucketCommands::Cors(cors::CorsCommands::Set(arg)) => {
970                    assert_eq!(arg.path, "local/my-bucket");
971                    assert_eq!(arg.source.as_deref(), Some("cors.xml"));
972                }
973                other => panic!("expected bucket cors set command, got {:?}", other),
974            },
975            other => panic!("expected bucket command, got {:?}", other),
976        }
977    }
978
979    #[test]
980    fn cli_accepts_top_level_cors_set_with_positional_source() {
981        let cli = Cli::try_parse_from(["rc", "cors", "set", "local/my-bucket", "cors.xml"])
982            .expect("parse top-level cors set with positional source");
983
984        match cli.command {
985            Commands::Cors(cors::CorsCommands::Set(arg)) => {
986                assert_eq!(arg.path, "local/my-bucket");
987                assert_eq!(arg.source.as_deref(), Some("cors.xml"));
988                assert_eq!(arg.file, None);
989                assert!(!arg.force);
990            }
991            other => panic!("expected top-level cors set command, got {:?}", other),
992        }
993    }
994
995    #[test]
996    fn cli_accepts_top_level_cors_set_with_legacy_file_flag() {
997        let cli = Cli::try_parse_from([
998            "rc",
999            "cors",
1000            "set",
1001            "local/my-bucket",
1002            "--file",
1003            "cors.json",
1004            "--force",
1005        ])
1006        .expect("parse top-level cors set with --file");
1007
1008        match cli.command {
1009            Commands::Cors(cors::CorsCommands::Set(arg)) => {
1010                assert_eq!(arg.path, "local/my-bucket");
1011                assert_eq!(arg.source, None);
1012                assert_eq!(arg.file.as_deref(), Some("cors.json"));
1013                assert!(arg.force);
1014            }
1015            other => panic!("expected top-level cors set command, got {:?}", other),
1016        }
1017    }
1018
1019    #[test]
1020    fn cli_accepts_bucket_cors_list_force_flag() {
1021        let cli =
1022            Cli::try_parse_from(["rc", "bucket", "cors", "list", "local/my-bucket", "--force"])
1023                .expect("parse bucket cors list with force");
1024
1025        match cli.command {
1026            Commands::Bucket(args) => match args.command {
1027                bucket::BucketCommands::Cors(cors::CorsCommands::List(arg)) => {
1028                    assert_eq!(arg.path, "local/my-bucket");
1029                    assert!(arg.force);
1030                }
1031                other => panic!("expected bucket cors list command, got {:?}", other),
1032            },
1033            other => panic!("expected bucket command, got {:?}", other),
1034        }
1035    }
1036
1037    #[test]
1038    fn cli_accepts_bucket_lifecycle_subcommand() {
1039        let cli = Cli::try_parse_from([
1040            "rc",
1041            "bucket",
1042            "lifecycle",
1043            "rule",
1044            "list",
1045            "local/my-bucket",
1046        ])
1047        .expect("parse bucket lifecycle rule list");
1048
1049        match cli.command {
1050            Commands::Bucket(args) => match args.command {
1051                bucket::BucketCommands::Lifecycle(ilm::IlmArgs {
1052                    command: ilm::IlmCommands::Rule(ilm::rule::RuleCommands::List(arg)),
1053                }) => {
1054                    assert_eq!(arg.path, "local/my-bucket");
1055                    assert!(!arg.force);
1056                }
1057                other => panic!(
1058                    "expected bucket lifecycle rule list command, got {:?}",
1059                    other
1060                ),
1061            },
1062            other => panic!("expected bucket command, got {:?}", other),
1063        }
1064    }
1065
1066    #[test]
1067    fn cli_accepts_bucket_replication_subcommand() {
1068        let cli = Cli::try_parse_from(["rc", "bucket", "replication", "status", "local/my-bucket"])
1069            .expect("parse bucket replication status");
1070
1071        match cli.command {
1072            Commands::Bucket(args) => match args.command {
1073                bucket::BucketCommands::Replication(replicate::ReplicateArgs {
1074                    command: replicate::ReplicateCommands::Status(arg),
1075                }) => {
1076                    assert_eq!(arg.path, "local/my-bucket");
1077                    assert!(!arg.force);
1078                }
1079                other => panic!(
1080                    "expected bucket replication status command, got {:?}",
1081                    other
1082                ),
1083            },
1084            other => panic!("expected bucket command, got {:?}", other),
1085        }
1086    }
1087
1088    #[test]
1089    fn cli_accepts_bucket_replication_diff_prefix() {
1090        let cli = Cli::try_parse_from([
1091            "rc",
1092            "bucket",
1093            "replication",
1094            "diff",
1095            "local/my-bucket",
1096            "--prefix",
1097            "reports/2026/",
1098        ])
1099        .expect("parse bucket replication diff");
1100
1101        match cli.command {
1102            Commands::Bucket(args) => match args.command {
1103                bucket::BucketCommands::Replication(replicate::ReplicateArgs {
1104                    command: replicate::ReplicateCommands::Diff(arg),
1105                }) => {
1106                    assert_eq!(arg.path, "local/my-bucket");
1107                    assert_eq!(arg.prefix.as_deref(), Some("reports/2026/"));
1108                }
1109                other => panic!("expected bucket replication diff command, got {:?}", other),
1110            },
1111            other => panic!("expected bucket command, got {:?}", other),
1112        }
1113    }
1114
1115    #[test]
1116    fn cli_accepts_bucket_replication_add_tls_flags() {
1117        let cli = Cli::try_parse_from([
1118            "rc",
1119            "bucket",
1120            "replication",
1121            "add",
1122            "local/my-bucket",
1123            "--remote-bucket",
1124            "backup/archive",
1125            "--insecure",
1126        ])
1127        .expect("parse bucket replication add with insecure");
1128
1129        match cli.command {
1130            Commands::Bucket(args) => match args.command {
1131                bucket::BucketCommands::Replication(replicate::ReplicateArgs {
1132                    command: replicate::ReplicateCommands::Add(arg),
1133                }) => {
1134                    assert_eq!(arg.path, "local/my-bucket");
1135                    assert_eq!(arg.remote_bucket, "backup/archive");
1136                    assert!(arg.insecure);
1137                }
1138                other => panic!("expected bucket replication add command, got {:?}", other),
1139            },
1140            other => panic!("expected bucket command, got {:?}", other),
1141        }
1142    }
1143
1144    #[test]
1145    fn cli_accepts_bucket_replication_check_confirmation() {
1146        let cli = Cli::try_parse_from([
1147            "rc",
1148            "bucket",
1149            "replication",
1150            "check",
1151            "local/my-bucket",
1152            "--yes",
1153            "--force",
1154        ])
1155        .expect("parse bucket replication check");
1156
1157        match cli.command {
1158            Commands::Bucket(args) => match args.command {
1159                bucket::BucketCommands::Replication(replicate::ReplicateArgs {
1160                    command: replicate::ReplicateCommands::Check(arg),
1161                }) => {
1162                    assert_eq!(arg.path, "local/my-bucket");
1163                    assert!(arg.yes);
1164                    assert!(arg.force);
1165                }
1166                other => panic!("expected bucket replication check command, got {other:?}"),
1167            },
1168            other => panic!("expected bucket command, got {other:?}"),
1169        }
1170    }
1171
1172    #[test]
1173    fn cli_accepts_bucket_replication_resync_lifecycle() {
1174        let start = Cli::try_parse_from([
1175            "rc",
1176            "bucket",
1177            "replication",
1178            "resync",
1179            "start",
1180            "local/my-bucket",
1181            "--target-arn",
1182            "arn:rustfs:replication::id:backup",
1183            "--older-than",
1184            "7d",
1185            "--reset-id",
1186            "caller-id",
1187            "--yes",
1188        ])
1189        .expect("parse bucket replication resync start");
1190
1191        match start.command {
1192            Commands::Bucket(args) => match args.command {
1193                bucket::BucketCommands::Replication(replicate::ReplicateArgs {
1194                    command:
1195                        replicate::ReplicateCommands::Resync(replicate::ResyncCommands::Start(arg)),
1196                }) => {
1197                    assert_eq!(arg.path, "local/my-bucket");
1198                    assert_eq!(
1199                        arg.target_arn.as_deref(),
1200                        Some("arn:rustfs:replication::id:backup")
1201                    );
1202                    assert_eq!(arg.older_than.as_deref(), Some("7d"));
1203                    assert_eq!(arg.reset_id.as_deref(), Some("caller-id"));
1204                    assert!(arg.yes);
1205                }
1206                other => panic!("expected bucket replication resync start, got {other:?}"),
1207            },
1208            other => panic!("expected bucket command, got {other:?}"),
1209        }
1210
1211        let status = Cli::try_parse_from([
1212            "rc",
1213            "bucket",
1214            "replication",
1215            "resync",
1216            "status",
1217            "local/my-bucket",
1218            "--target-arn",
1219            "arn:rustfs:replication::id:backup",
1220        ])
1221        .expect("parse bucket replication resync status");
1222
1223        match status.command {
1224            Commands::Bucket(args) => match args.command {
1225                bucket::BucketCommands::Replication(replicate::ReplicateArgs {
1226                    command:
1227                        replicate::ReplicateCommands::Resync(replicate::ResyncCommands::Status(arg)),
1228                }) => {
1229                    assert_eq!(arg.path, "local/my-bucket");
1230                    assert_eq!(
1231                        arg.target_arn.as_deref(),
1232                        Some("arn:rustfs:replication::id:backup")
1233                    );
1234                }
1235                other => panic!("expected bucket replication resync status, got {other:?}"),
1236            },
1237            other => panic!("expected bucket command, got {other:?}"),
1238        }
1239    }
1240
1241    #[test]
1242    fn cli_accepts_bucket_remove_subcommand() {
1243        let cli = Cli::try_parse_from(["rc", "bucket", "remove", "local/my-bucket"])
1244            .expect("parse bucket remove");
1245
1246        match cli.command {
1247            Commands::Bucket(args) => match args.command {
1248                bucket::BucketCommands::Remove(arg) => {
1249                    assert_eq!(arg.target, "local/my-bucket");
1250                    assert!(!arg.force);
1251                    assert!(!arg.dangerous);
1252                    assert!(!arg.yes);
1253                }
1254                other => panic!("expected bucket remove command, got {:?}", other),
1255            },
1256            other => panic!("expected bucket command, got {:?}", other),
1257        }
1258    }
1259
1260    #[test]
1261    fn cli_requires_complete_dangerous_bucket_remove_guards() {
1262        let cli = Cli::try_parse_from([
1263            "rc",
1264            "bucket",
1265            "remove",
1266            "local/my-bucket",
1267            "--force",
1268            "--dangerous",
1269            "--yes",
1270        ])
1271        .expect("parse guarded bucket remove");
1272        match cli.command {
1273            Commands::Bucket(args) => match args.command {
1274                bucket::BucketCommands::Remove(arg) => {
1275                    assert!(arg.force);
1276                    assert!(arg.dangerous);
1277                    assert!(arg.yes);
1278                }
1279                other => panic!("expected bucket remove command, got {other:?}"),
1280            },
1281            other => panic!("expected bucket command, got {other:?}"),
1282        }
1283
1284        assert!(
1285            Cli::try_parse_from([
1286                "rc",
1287                "bucket",
1288                "remove",
1289                "local/my-bucket",
1290                "--dangerous",
1291                "--yes",
1292            ])
1293            .is_err()
1294        );
1295        assert!(
1296            Cli::try_parse_from([
1297                "rc",
1298                "bucket",
1299                "remove",
1300                "local/my-bucket",
1301                "--force",
1302                "--dangerous",
1303            ])
1304            .is_err()
1305        );
1306    }
1307
1308    #[test]
1309    fn cli_accepts_object_remove_subcommand() {
1310        let cli = Cli::try_parse_from([
1311            "rc",
1312            "object",
1313            "remove",
1314            "local/my-bucket/report.csv",
1315            "--dry-run",
1316        ])
1317        .expect("parse object remove");
1318
1319        match cli.command {
1320            Commands::Object(args) => match args.command {
1321                object::ObjectCommands::Remove(arg) => {
1322                    assert_eq!(arg.paths, vec!["local/my-bucket/report.csv".to_string()]);
1323                    assert!(arg.dry_run);
1324                }
1325                other => panic!("expected object remove command, got {:?}", other),
1326            },
1327            other => panic!("expected object command, got {:?}", other),
1328        }
1329    }
1330
1331    #[test]
1332    fn cli_accepts_bucket_event_remove_subcommand() {
1333        let cli = Cli::try_parse_from([
1334            "rc",
1335            "bucket",
1336            "event",
1337            "remove",
1338            "local/my-bucket",
1339            "arn:aws:sns:us-east-1:123456789012:alerts",
1340        ])
1341        .expect("parse bucket event remove");
1342
1343        match cli.command {
1344            Commands::Bucket(args) => match args.command {
1345                bucket::BucketCommands::Event(event::EventCommands::Remove(arg)) => {
1346                    assert_eq!(arg.path, "local/my-bucket");
1347                    assert_eq!(arg.arn, "arn:aws:sns:us-east-1:123456789012:alerts");
1348                }
1349                other => panic!("expected bucket event remove command, got {:?}", other),
1350            },
1351            other => panic!("expected bucket command, got {:?}", other),
1352        }
1353    }
1354
1355    #[test]
1356    fn cli_accepts_rm_purge_flag() {
1357        let cli = Cli::try_parse_from(["rc", "rm", "local/my-bucket/object.txt", "--purge"])
1358            .expect("parse rm purge");
1359
1360        match cli.command {
1361            Commands::Rm(arg) => {
1362                assert_eq!(arg.paths, vec!["local/my-bucket/object.txt".to_string()]);
1363                assert!(arg.purge);
1364            }
1365            other => panic!("expected rm command, got {:?}", other),
1366        }
1367    }
1368
1369    #[test]
1370    fn cli_accepts_object_remove_purge_flag() {
1371        let cli = Cli::try_parse_from([
1372            "rc",
1373            "object",
1374            "remove",
1375            "local/my-bucket/object.txt",
1376            "--purge",
1377        ])
1378        .expect("parse object remove purge");
1379
1380        match cli.command {
1381            Commands::Object(args) => match args.command {
1382                object::ObjectCommands::Remove(arg) => {
1383                    assert_eq!(arg.paths, vec!["local/my-bucket/object.txt".to_string()]);
1384                    assert!(arg.purge);
1385                }
1386                other => panic!("expected object remove command, got {:?}", other),
1387            },
1388            other => panic!("expected object command, got {:?}", other),
1389        }
1390    }
1391
1392    #[test]
1393    fn cli_accepts_object_stat_subcommand() {
1394        let cli = Cli::try_parse_from(["rc", "object", "stat", "local/my-bucket/report.json"])
1395            .expect("parse object stat");
1396
1397        match cli.command {
1398            Commands::Object(args) => match args.command {
1399                object::ObjectCommands::Stat(arg) => {
1400                    assert_eq!(arg.path, "local/my-bucket/report.json");
1401                }
1402                other => panic!("expected object stat command, got {:?}", other),
1403            },
1404            other => panic!("expected object command, got {:?}", other),
1405        }
1406    }
1407
1408    #[test]
1409    fn cli_accepts_object_copy_with_transfer_options() {
1410        let cli = Cli::try_parse_from([
1411            "rc",
1412            "object",
1413            "copy",
1414            "./report.json",
1415            "local/my-bucket/reports/",
1416            "--content-type",
1417            "application/json",
1418            "--storage-class",
1419            "STANDARD",
1420            "--metadata-directive",
1421            "replace",
1422            "--tagging-directive",
1423            "replace",
1424            "--cache-control",
1425            "max-age=3600",
1426            "--metadata",
1427            "owner=analytics",
1428            "--tags",
1429            "env=prod",
1430            "--checksum",
1431            "sha256",
1432            "--retention-mode",
1433            "governance",
1434            "--retain-until",
1435            "2031-01-02T03:04:05Z",
1436            "--legal-hold",
1437            "on",
1438            "--dry-run",
1439        ])
1440        .expect("parse object copy with transfer options");
1441
1442        match cli.command {
1443            Commands::Object(args) => match args.command {
1444                object::ObjectCommands::Copy(arg) => {
1445                    assert_eq!(arg.sources, ["./report.json"]);
1446                    assert_eq!(arg.target, "local/my-bucket/reports/");
1447                    assert_eq!(arg.content_type.as_deref(), Some("application/json"));
1448                    assert_eq!(arg.storage_class.as_deref(), Some("STANDARD"));
1449                    assert_eq!(
1450                        arg.metadata_directive,
1451                        Some(transfer_fidelity::MetadataDirectiveArg::Replace)
1452                    );
1453                    assert_eq!(
1454                        arg.tagging_directive,
1455                        Some(transfer_fidelity::TaggingDirectiveArg::Replace)
1456                    );
1457                    assert_eq!(arg.fidelity.cache_control.as_deref(), Some("max-age=3600"));
1458                    assert_eq!(arg.fidelity.metadata, ["owner=analytics"]);
1459                    assert_eq!(arg.fidelity.tags, ["env=prod"]);
1460                    assert_eq!(arg.fidelity.checksum.as_deref(), Some("sha256"));
1461                    assert_eq!(arg.fidelity.retention_mode.as_deref(), Some("governance"));
1462                    assert_eq!(arg.fidelity.legal_hold.as_deref(), Some("on"));
1463                    assert!(arg.dry_run);
1464                }
1465                other => panic!("expected object copy command, got {:?}", other),
1466            },
1467            other => panic!("expected object command, got {:?}", other),
1468        }
1469    }
1470
1471    #[test]
1472    fn cli_accepts_multiple_copy_sources_and_global_transfer_controls() {
1473        let cli = Cli::try_parse_from([
1474            "rc",
1475            "cp",
1476            "./a.csv",
1477            "./b.csv",
1478            "local/reports/",
1479            "--include",
1480            "*.csv",
1481            "--exclude",
1482            "private-*",
1483            "--newer-than",
1484            "1h",
1485            "--concurrency",
1486            "8",
1487            "--rate-limit",
1488            "10MiB/s",
1489            "--retry-attempts",
1490            "5",
1491            "--continue-on-error",
1492            "--summary",
1493        ])
1494        .expect("parse multi-source transfer controls");
1495
1496        match cli.command {
1497            Commands::Cp(args) => {
1498                assert_eq!(args.sources, ["./a.csv", "./b.csv"]);
1499                assert_eq!(args.target, "local/reports/");
1500                assert_eq!(args.include, ["*.csv"]);
1501                assert_eq!(args.exclude, ["private-*"]);
1502                assert_eq!(args.newer_than.as_deref(), Some("1h"));
1503                assert_eq!(args.concurrency, Some(8));
1504                assert_eq!(args.rate_limit.as_deref(), Some("10MiB/s"));
1505                assert_eq!(args.retry_attempts, Some(5));
1506                assert!(args.continue_on_error);
1507                assert!(args.summary);
1508            }
1509            other => panic!("expected cp command, got {other:?}"),
1510        }
1511    }
1512
1513    #[test]
1514    fn cli_accepts_get_with_copy_options() {
1515        let cli = Cli::try_parse_from([
1516            "rc",
1517            "get",
1518            "local/reports/report.json",
1519            "./report.json",
1520            "--enc-c-source-key-env",
1521            "RC_SSE_C_KEY",
1522            "--retry-attempts",
1523            "5",
1524        ])
1525        .expect("parse get compatibility command");
1526
1527        match cli.command {
1528            Commands::Get(args) => {
1529                assert_eq!(args.transfer.sources, ["local/reports/report.json"]);
1530                assert_eq!(args.transfer.target, "./report.json");
1531                assert_eq!(
1532                    args.transfer.enc_c_source_key_env.as_deref(),
1533                    Some("RC_SSE_C_KEY")
1534                );
1535                assert_eq!(args.transfer.retry_attempts, Some(5));
1536            }
1537            other => panic!("expected get command, got {other:?}"),
1538        }
1539    }
1540
1541    #[test]
1542    fn cli_accepts_put_with_multiple_sources_and_copy_options() {
1543        let cli = Cli::try_parse_from([
1544            "rc",
1545            "put",
1546            "./january.csv",
1547            "./february.csv",
1548            "local/reports/",
1549            "--storage-class",
1550            "STANDARD",
1551            "--concurrency",
1552            "8",
1553            "--summary",
1554        ])
1555        .expect("parse put compatibility command");
1556
1557        match cli.command {
1558            Commands::Put(args) => {
1559                assert_eq!(args.transfer.sources, ["./january.csv", "./february.csv"]);
1560                assert_eq!(args.transfer.target, "local/reports/");
1561                assert_eq!(args.transfer.storage_class.as_deref(), Some("STANDARD"));
1562                assert_eq!(args.transfer.concurrency, Some(8));
1563                assert!(args.transfer.summary);
1564            }
1565            other => panic!("expected put command, got {other:?}"),
1566        }
1567    }
1568
1569    #[test]
1570    fn cli_accepts_object_move_with_recursive_dry_run() {
1571        let cli = Cli::try_parse_from([
1572            "rc",
1573            "object",
1574            "move",
1575            "local/source-bucket/logs/",
1576            "local/archive-bucket/logs/",
1577            "--recursive",
1578            "--dry-run",
1579            "--continue-on-error",
1580        ])
1581        .expect("parse object move with recursive dry-run");
1582
1583        match cli.command {
1584            Commands::Object(args) => match args.command {
1585                object::ObjectCommands::Move(arg) => {
1586                    assert_eq!(arg.source, "local/source-bucket/logs/");
1587                    assert_eq!(arg.target, "local/archive-bucket/logs/");
1588                    assert!(arg.recursive);
1589                    assert!(arg.dry_run);
1590                    assert!(arg.continue_on_error);
1591                }
1592                other => panic!("expected object move command, got {:?}", other),
1593            },
1594            other => panic!("expected object command, got {:?}", other),
1595        }
1596    }
1597
1598    #[test]
1599    fn cli_accepts_object_show_and_head_options() {
1600        let show_cli = Cli::try_parse_from([
1601            "rc",
1602            "object",
1603            "show",
1604            "local/my-bucket/report.json",
1605            "--version-id",
1606            "v1",
1607            "--rewind",
1608            "1h",
1609        ])
1610        .expect("parse object show options");
1611
1612        match show_cli.command {
1613            Commands::Object(args) => match args.command {
1614                object::ObjectCommands::Show(arg) => {
1615                    assert_eq!(arg.path, "local/my-bucket/report.json");
1616                    assert_eq!(arg.version_id.as_deref(), Some("v1"));
1617                    assert_eq!(arg.rewind.as_deref(), Some("1h"));
1618                }
1619                other => panic!("expected object show command, got {:?}", other),
1620            },
1621            other => panic!("expected object command, got {:?}", other),
1622        }
1623
1624        let head_cli = Cli::try_parse_from([
1625            "rc",
1626            "object",
1627            "head",
1628            "local/my-bucket/report.json",
1629            "--bytes",
1630            "128",
1631            "--version-id",
1632            "v2",
1633        ])
1634        .expect("parse object head options");
1635
1636        match head_cli.command {
1637            Commands::Object(args) => match args.command {
1638                object::ObjectCommands::Head(arg) => {
1639                    assert_eq!(arg.path, "local/my-bucket/report.json");
1640                    assert_eq!(arg.bytes, Some(128));
1641                    assert_eq!(arg.version_id.as_deref(), Some("v2"));
1642                }
1643                other => panic!("expected object head command, got {:?}", other),
1644            },
1645            other => panic!("expected object command, got {:?}", other),
1646        }
1647    }
1648
1649    #[test]
1650    fn cli_accepts_object_find_and_tree_options() {
1651        let find_cli = Cli::try_parse_from([
1652            "rc",
1653            "object",
1654            "find",
1655            "local/my-bucket/logs/",
1656            "--name",
1657            "*.json",
1658            "--maxdepth",
1659            "2",
1660            "--count",
1661            "--print",
1662        ])
1663        .expect("parse object find options");
1664
1665        match find_cli.command {
1666            Commands::Object(args) => match args.command {
1667                object::ObjectCommands::Find(arg) => {
1668                    assert_eq!(arg.path, "local/my-bucket/logs/");
1669                    assert_eq!(arg.name.as_deref(), Some("*.json"));
1670                    assert_eq!(arg.maxdepth, 2);
1671                    assert!(arg.count);
1672                    assert!(arg.print);
1673                }
1674                other => panic!("expected object find command, got {:?}", other),
1675            },
1676            other => panic!("expected object command, got {:?}", other),
1677        }
1678
1679        let tree_cli = Cli::try_parse_from([
1680            "rc",
1681            "object",
1682            "tree",
1683            "local/my-bucket/logs/",
1684            "--level",
1685            "4",
1686            "--size",
1687            "--pattern",
1688            "*.json",
1689            "--full-path",
1690        ])
1691        .expect("parse object tree options");
1692
1693        match tree_cli.command {
1694            Commands::Object(args) => match args.command {
1695                object::ObjectCommands::Tree(arg) => {
1696                    assert_eq!(arg.path, "local/my-bucket/logs/");
1697                    assert_eq!(arg.level, 4);
1698                    assert!(arg.size);
1699                    assert_eq!(arg.pattern.as_deref(), Some("*.json"));
1700                    assert!(arg.full_path);
1701                }
1702                other => panic!("expected object tree command, got {:?}", other),
1703            },
1704            other => panic!("expected object command, got {:?}", other),
1705        }
1706    }
1707
1708    #[test]
1709    fn cli_accepts_portable_names_on_mirror_and_copy() {
1710        let mirror = Cli::try_parse_from([
1711            "rc",
1712            "mirror",
1713            "local/loki/",
1714            "./restore/",
1715            "--portable-names",
1716        ])
1717        .expect("parse mirror portable-names");
1718        match mirror.command {
1719            Commands::Mirror(args) => assert!(args.portable_names),
1720            other => panic!("expected mirror command, got {other:?}"),
1721        }
1722
1723        let copy = Cli::try_parse_from([
1724            "rc",
1725            "object",
1726            "copy",
1727            "local/loki/",
1728            "./restore/",
1729            "--recursive",
1730            "--portable-names",
1731        ])
1732        .expect("parse object copy portable-names");
1733        match copy.command {
1734            Commands::Object(args) => match args.command {
1735                object::ObjectCommands::Copy(args) => assert!(args.portable_names),
1736                other => panic!("expected object copy command, got {other:?}"),
1737            },
1738            other => panic!("expected object command, got {other:?}"),
1739        }
1740    }
1741
1742    #[test]
1743    fn version_selector_rejects_ambiguous_or_empty_values() {
1744        assert!(validate_version_selector(Some("v1"), None).is_ok());
1745        assert!(validate_version_selector(None, Some("1h")).is_ok());
1746        assert!(validate_version_selector(Some("v1"), Some("1h")).is_err());
1747        assert!(validate_version_selector(Some(""), None).is_err());
1748    }
1749
1750    #[test]
1751    fn versioning_errors_map_to_stable_exit_codes() {
1752        assert_eq!(
1753            exit_code_for_core_error(&rc_core::Error::VersionNotFound {
1754                path: "local/bucket/key".to_string(),
1755                version_id: "v1".to_string(),
1756            }),
1757            ExitCode::NotFound
1758        );
1759        assert_eq!(
1760            exit_code_for_core_error(&rc_core::Error::Auth("denied".to_string())),
1761            ExitCode::AuthError
1762        );
1763        assert_eq!(
1764            exit_code_for_core_error(&rc_core::Error::GovernanceDenied {
1765                path: "local/bucket/key".to_string(),
1766                version_id: Some("v1".to_string()),
1767            }),
1768            ExitCode::Conflict
1769        );
1770    }
1771}