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_csv_input_record_delimiter() {
884        let cli = Cli::try_parse_from([
885            "rc",
886            "sql",
887            "local/reports/data.csv",
888            "--query",
889            "SELECT * FROM S3Object",
890            "--csv-input-record-delimiter",
891            "\r\n",
892        ])
893        .expect("parse CSV input record delimiter");
894
895        match cli.command {
896            Commands::Sql(arg) => {
897                assert!(matches!(arg.input_format, sql::InputFormatArg::Csv));
898                assert_eq!(arg.csv_input_record_delimiter.as_deref(), Some("\r\n"));
899            }
900            other => panic!("expected sql command, got {:?}", other),
901        }
902    }
903
904    #[test]
905    fn cli_accepts_sql_defaults() {
906        let cli = Cli::try_parse_from([
907            "rc",
908            "sql",
909            "local/reports/data.csv",
910            "--query",
911            "SELECT s._1 FROM S3Object s",
912        ])
913        .expect("parse sql command defaults");
914
915        match cli.command {
916            Commands::Sql(arg) => {
917                assert_eq!(arg.path, "local/reports/data.csv");
918                assert_eq!(arg.query, "SELECT s._1 FROM S3Object s");
919                assert!(matches!(arg.input_format, sql::InputFormatArg::Csv));
920                assert!(matches!(arg.output_format, sql::OutputFormatArg::Csv));
921                assert!(matches!(arg.compression, sql::CompressionArg::None));
922            }
923            other => panic!("expected sql command, got {:?}", other),
924        }
925    }
926
927    #[test]
928    fn cli_accepts_object_list_alias() {
929        let cli = Cli::try_parse_from(["rc", "object", "ls", "local/my-bucket/logs/"])
930            .expect("parse object ls alias");
931
932        match cli.command {
933            Commands::Object(args) => match args.command {
934                object::ObjectCommands::List(arg) => {
935                    assert_eq!(arg.path, "local/my-bucket/logs/");
936                }
937                other => panic!("expected object list alias, got {:?}", other),
938            },
939            other => panic!("expected object command, got {:?}", other),
940        }
941    }
942
943    #[test]
944    fn cli_accepts_top_level_event_remove_subcommand() {
945        let cli = Cli::try_parse_from([
946            "rc",
947            "event",
948            "remove",
949            "local/my-bucket",
950            "arn:aws:sns:us-east-1:123456789012:alerts",
951            "--force",
952        ])
953        .expect("parse top-level event remove");
954
955        match cli.command {
956            Commands::Event(event::EventArgs {
957                command: event::EventCommands::Remove(arg),
958            }) => {
959                assert_eq!(arg.path, "local/my-bucket");
960                assert_eq!(arg.arn, "arn:aws:sns:us-east-1:123456789012:alerts");
961                assert!(arg.force);
962            }
963            other => panic!("expected top-level event remove command, got {:?}", other),
964        }
965    }
966
967    #[test]
968    fn cli_accepts_bucket_cors_get_alias() {
969        let cli = Cli::try_parse_from(["rc", "bucket", "cors", "get", "local/my-bucket"])
970            .expect("parse bucket cors get");
971
972        match cli.command {
973            Commands::Bucket(args) => match args.command {
974                bucket::BucketCommands::Cors(cors::CorsCommands::List(arg)) => {
975                    assert_eq!(arg.path, "local/my-bucket");
976                }
977                other => panic!("expected bucket cors get alias, got {:?}", other),
978            },
979            other => panic!("expected bucket command, got {:?}", other),
980        }
981    }
982
983    #[test]
984    fn cli_accepts_bucket_cors_set_with_positional_source() {
985        let cli =
986            Cli::try_parse_from(["rc", "bucket", "cors", "set", "local/my-bucket", "cors.xml"])
987                .expect("parse bucket cors set with positional source");
988
989        match cli.command {
990            Commands::Bucket(args) => match args.command {
991                bucket::BucketCommands::Cors(cors::CorsCommands::Set(arg)) => {
992                    assert_eq!(arg.path, "local/my-bucket");
993                    assert_eq!(arg.source.as_deref(), Some("cors.xml"));
994                }
995                other => panic!("expected bucket cors set command, got {:?}", other),
996            },
997            other => panic!("expected bucket command, got {:?}", other),
998        }
999    }
1000
1001    #[test]
1002    fn cli_accepts_top_level_cors_set_with_positional_source() {
1003        let cli = Cli::try_parse_from(["rc", "cors", "set", "local/my-bucket", "cors.xml"])
1004            .expect("parse top-level cors set with positional source");
1005
1006        match cli.command {
1007            Commands::Cors(cors::CorsCommands::Set(arg)) => {
1008                assert_eq!(arg.path, "local/my-bucket");
1009                assert_eq!(arg.source.as_deref(), Some("cors.xml"));
1010                assert_eq!(arg.file, None);
1011                assert!(!arg.force);
1012            }
1013            other => panic!("expected top-level cors set command, got {:?}", other),
1014        }
1015    }
1016
1017    #[test]
1018    fn cli_accepts_top_level_cors_set_with_legacy_file_flag() {
1019        let cli = Cli::try_parse_from([
1020            "rc",
1021            "cors",
1022            "set",
1023            "local/my-bucket",
1024            "--file",
1025            "cors.json",
1026            "--force",
1027        ])
1028        .expect("parse top-level cors set with --file");
1029
1030        match cli.command {
1031            Commands::Cors(cors::CorsCommands::Set(arg)) => {
1032                assert_eq!(arg.path, "local/my-bucket");
1033                assert_eq!(arg.source, None);
1034                assert_eq!(arg.file.as_deref(), Some("cors.json"));
1035                assert!(arg.force);
1036            }
1037            other => panic!("expected top-level cors set command, got {:?}", other),
1038        }
1039    }
1040
1041    #[test]
1042    fn cli_accepts_bucket_cors_list_force_flag() {
1043        let cli =
1044            Cli::try_parse_from(["rc", "bucket", "cors", "list", "local/my-bucket", "--force"])
1045                .expect("parse bucket cors list with force");
1046
1047        match cli.command {
1048            Commands::Bucket(args) => match args.command {
1049                bucket::BucketCommands::Cors(cors::CorsCommands::List(arg)) => {
1050                    assert_eq!(arg.path, "local/my-bucket");
1051                    assert!(arg.force);
1052                }
1053                other => panic!("expected bucket cors list command, got {:?}", other),
1054            },
1055            other => panic!("expected bucket command, got {:?}", other),
1056        }
1057    }
1058
1059    #[test]
1060    fn cli_accepts_bucket_lifecycle_subcommand() {
1061        let cli = Cli::try_parse_from([
1062            "rc",
1063            "bucket",
1064            "lifecycle",
1065            "rule",
1066            "list",
1067            "local/my-bucket",
1068        ])
1069        .expect("parse bucket lifecycle rule list");
1070
1071        match cli.command {
1072            Commands::Bucket(args) => match args.command {
1073                bucket::BucketCommands::Lifecycle(ilm::IlmArgs {
1074                    command: ilm::IlmCommands::Rule(ilm::rule::RuleCommands::List(arg)),
1075                }) => {
1076                    assert_eq!(arg.path, "local/my-bucket");
1077                    assert!(!arg.force);
1078                }
1079                other => panic!(
1080                    "expected bucket lifecycle rule list 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_subcommand() {
1090        let cli = Cli::try_parse_from(["rc", "bucket", "replication", "status", "local/my-bucket"])
1091            .expect("parse bucket replication status");
1092
1093        match cli.command {
1094            Commands::Bucket(args) => match args.command {
1095                bucket::BucketCommands::Replication(replicate::ReplicateArgs {
1096                    command: replicate::ReplicateCommands::Status(arg),
1097                }) => {
1098                    assert_eq!(arg.path, "local/my-bucket");
1099                    assert!(!arg.force);
1100                }
1101                other => panic!(
1102                    "expected bucket replication status command, got {:?}",
1103                    other
1104                ),
1105            },
1106            other => panic!("expected bucket command, got {:?}", other),
1107        }
1108    }
1109
1110    #[test]
1111    fn cli_accepts_bucket_replication_diff_prefix() {
1112        let cli = Cli::try_parse_from([
1113            "rc",
1114            "bucket",
1115            "replication",
1116            "diff",
1117            "local/my-bucket",
1118            "--prefix",
1119            "reports/2026/",
1120        ])
1121        .expect("parse bucket replication diff");
1122
1123        match cli.command {
1124            Commands::Bucket(args) => match args.command {
1125                bucket::BucketCommands::Replication(replicate::ReplicateArgs {
1126                    command: replicate::ReplicateCommands::Diff(arg),
1127                }) => {
1128                    assert_eq!(arg.path, "local/my-bucket");
1129                    assert_eq!(arg.prefix.as_deref(), Some("reports/2026/"));
1130                }
1131                other => panic!("expected bucket replication diff command, got {:?}", other),
1132            },
1133            other => panic!("expected bucket command, got {:?}", other),
1134        }
1135    }
1136
1137    #[test]
1138    fn cli_accepts_bucket_replication_add_tls_flags() {
1139        let cli = Cli::try_parse_from([
1140            "rc",
1141            "bucket",
1142            "replication",
1143            "add",
1144            "local/my-bucket",
1145            "--remote-bucket",
1146            "backup/archive",
1147            "--insecure",
1148        ])
1149        .expect("parse bucket replication add with insecure");
1150
1151        match cli.command {
1152            Commands::Bucket(args) => match args.command {
1153                bucket::BucketCommands::Replication(replicate::ReplicateArgs {
1154                    command: replicate::ReplicateCommands::Add(arg),
1155                }) => {
1156                    assert_eq!(arg.path, "local/my-bucket");
1157                    assert_eq!(arg.remote_bucket, "backup/archive");
1158                    assert!(arg.insecure);
1159                }
1160                other => panic!("expected bucket replication add command, got {:?}", other),
1161            },
1162            other => panic!("expected bucket command, got {:?}", other),
1163        }
1164    }
1165
1166    #[test]
1167    fn cli_accepts_bucket_replication_check_confirmation() {
1168        let cli = Cli::try_parse_from([
1169            "rc",
1170            "bucket",
1171            "replication",
1172            "check",
1173            "local/my-bucket",
1174            "--yes",
1175            "--force",
1176        ])
1177        .expect("parse bucket replication check");
1178
1179        match cli.command {
1180            Commands::Bucket(args) => match args.command {
1181                bucket::BucketCommands::Replication(replicate::ReplicateArgs {
1182                    command: replicate::ReplicateCommands::Check(arg),
1183                }) => {
1184                    assert_eq!(arg.path, "local/my-bucket");
1185                    assert!(arg.yes);
1186                    assert!(arg.force);
1187                }
1188                other => panic!("expected bucket replication check command, got {other:?}"),
1189            },
1190            other => panic!("expected bucket command, got {other:?}"),
1191        }
1192    }
1193
1194    #[test]
1195    fn cli_accepts_bucket_replication_resync_lifecycle() {
1196        let start = Cli::try_parse_from([
1197            "rc",
1198            "bucket",
1199            "replication",
1200            "resync",
1201            "start",
1202            "local/my-bucket",
1203            "--target-arn",
1204            "arn:rustfs:replication::id:backup",
1205            "--older-than",
1206            "7d",
1207            "--reset-id",
1208            "caller-id",
1209            "--yes",
1210        ])
1211        .expect("parse bucket replication resync start");
1212
1213        match start.command {
1214            Commands::Bucket(args) => match args.command {
1215                bucket::BucketCommands::Replication(replicate::ReplicateArgs {
1216                    command:
1217                        replicate::ReplicateCommands::Resync(replicate::ResyncCommands::Start(arg)),
1218                }) => {
1219                    assert_eq!(arg.path, "local/my-bucket");
1220                    assert_eq!(
1221                        arg.target_arn.as_deref(),
1222                        Some("arn:rustfs:replication::id:backup")
1223                    );
1224                    assert_eq!(arg.older_than.as_deref(), Some("7d"));
1225                    assert_eq!(arg.reset_id.as_deref(), Some("caller-id"));
1226                    assert!(arg.yes);
1227                }
1228                other => panic!("expected bucket replication resync start, got {other:?}"),
1229            },
1230            other => panic!("expected bucket command, got {other:?}"),
1231        }
1232
1233        let status = Cli::try_parse_from([
1234            "rc",
1235            "bucket",
1236            "replication",
1237            "resync",
1238            "status",
1239            "local/my-bucket",
1240            "--target-arn",
1241            "arn:rustfs:replication::id:backup",
1242        ])
1243        .expect("parse bucket replication resync status");
1244
1245        match status.command {
1246            Commands::Bucket(args) => match args.command {
1247                bucket::BucketCommands::Replication(replicate::ReplicateArgs {
1248                    command:
1249                        replicate::ReplicateCommands::Resync(replicate::ResyncCommands::Status(arg)),
1250                }) => {
1251                    assert_eq!(arg.path, "local/my-bucket");
1252                    assert_eq!(
1253                        arg.target_arn.as_deref(),
1254                        Some("arn:rustfs:replication::id:backup")
1255                    );
1256                }
1257                other => panic!("expected bucket replication resync status, got {other:?}"),
1258            },
1259            other => panic!("expected bucket command, got {other:?}"),
1260        }
1261    }
1262
1263    #[test]
1264    fn cli_accepts_bucket_remove_subcommand() {
1265        let cli = Cli::try_parse_from(["rc", "bucket", "remove", "local/my-bucket"])
1266            .expect("parse bucket remove");
1267
1268        match cli.command {
1269            Commands::Bucket(args) => match args.command {
1270                bucket::BucketCommands::Remove(arg) => {
1271                    assert_eq!(arg.target, "local/my-bucket");
1272                    assert!(!arg.force);
1273                    assert!(!arg.dangerous);
1274                    assert!(!arg.yes);
1275                }
1276                other => panic!("expected bucket remove command, got {:?}", other),
1277            },
1278            other => panic!("expected bucket command, got {:?}", other),
1279        }
1280    }
1281
1282    #[test]
1283    fn cli_requires_complete_dangerous_bucket_remove_guards() {
1284        let cli = Cli::try_parse_from([
1285            "rc",
1286            "bucket",
1287            "remove",
1288            "local/my-bucket",
1289            "--force",
1290            "--dangerous",
1291            "--yes",
1292        ])
1293        .expect("parse guarded bucket remove");
1294        match cli.command {
1295            Commands::Bucket(args) => match args.command {
1296                bucket::BucketCommands::Remove(arg) => {
1297                    assert!(arg.force);
1298                    assert!(arg.dangerous);
1299                    assert!(arg.yes);
1300                }
1301                other => panic!("expected bucket remove command, got {other:?}"),
1302            },
1303            other => panic!("expected bucket command, got {other:?}"),
1304        }
1305
1306        assert!(
1307            Cli::try_parse_from([
1308                "rc",
1309                "bucket",
1310                "remove",
1311                "local/my-bucket",
1312                "--dangerous",
1313                "--yes",
1314            ])
1315            .is_err()
1316        );
1317        assert!(
1318            Cli::try_parse_from([
1319                "rc",
1320                "bucket",
1321                "remove",
1322                "local/my-bucket",
1323                "--force",
1324                "--dangerous",
1325            ])
1326            .is_err()
1327        );
1328    }
1329
1330    #[test]
1331    fn cli_accepts_object_remove_subcommand() {
1332        let cli = Cli::try_parse_from([
1333            "rc",
1334            "object",
1335            "remove",
1336            "local/my-bucket/report.csv",
1337            "--dry-run",
1338        ])
1339        .expect("parse object remove");
1340
1341        match cli.command {
1342            Commands::Object(args) => match args.command {
1343                object::ObjectCommands::Remove(arg) => {
1344                    assert_eq!(arg.paths, vec!["local/my-bucket/report.csv".to_string()]);
1345                    assert!(arg.dry_run);
1346                }
1347                other => panic!("expected object remove command, got {:?}", other),
1348            },
1349            other => panic!("expected object command, got {:?}", other),
1350        }
1351    }
1352
1353    #[test]
1354    fn cli_accepts_bucket_event_remove_subcommand() {
1355        let cli = Cli::try_parse_from([
1356            "rc",
1357            "bucket",
1358            "event",
1359            "remove",
1360            "local/my-bucket",
1361            "arn:aws:sns:us-east-1:123456789012:alerts",
1362        ])
1363        .expect("parse bucket event remove");
1364
1365        match cli.command {
1366            Commands::Bucket(args) => match args.command {
1367                bucket::BucketCommands::Event(event::EventCommands::Remove(arg)) => {
1368                    assert_eq!(arg.path, "local/my-bucket");
1369                    assert_eq!(arg.arn, "arn:aws:sns:us-east-1:123456789012:alerts");
1370                }
1371                other => panic!("expected bucket event remove command, got {:?}", other),
1372            },
1373            other => panic!("expected bucket command, got {:?}", other),
1374        }
1375    }
1376
1377    #[test]
1378    fn cli_accepts_rm_purge_flag() {
1379        let cli = Cli::try_parse_from(["rc", "rm", "local/my-bucket/object.txt", "--purge"])
1380            .expect("parse rm purge");
1381
1382        match cli.command {
1383            Commands::Rm(arg) => {
1384                assert_eq!(arg.paths, vec!["local/my-bucket/object.txt".to_string()]);
1385                assert!(arg.purge);
1386            }
1387            other => panic!("expected rm command, got {:?}", other),
1388        }
1389    }
1390
1391    #[test]
1392    fn cli_accepts_object_remove_purge_flag() {
1393        let cli = Cli::try_parse_from([
1394            "rc",
1395            "object",
1396            "remove",
1397            "local/my-bucket/object.txt",
1398            "--purge",
1399        ])
1400        .expect("parse object remove purge");
1401
1402        match cli.command {
1403            Commands::Object(args) => match args.command {
1404                object::ObjectCommands::Remove(arg) => {
1405                    assert_eq!(arg.paths, vec!["local/my-bucket/object.txt".to_string()]);
1406                    assert!(arg.purge);
1407                }
1408                other => panic!("expected object remove command, got {:?}", other),
1409            },
1410            other => panic!("expected object command, got {:?}", other),
1411        }
1412    }
1413
1414    #[test]
1415    fn cli_accepts_object_stat_subcommand() {
1416        let cli = Cli::try_parse_from(["rc", "object", "stat", "local/my-bucket/report.json"])
1417            .expect("parse object stat");
1418
1419        match cli.command {
1420            Commands::Object(args) => match args.command {
1421                object::ObjectCommands::Stat(arg) => {
1422                    assert_eq!(arg.path, "local/my-bucket/report.json");
1423                }
1424                other => panic!("expected object stat command, got {:?}", other),
1425            },
1426            other => panic!("expected object command, got {:?}", other),
1427        }
1428    }
1429
1430    #[test]
1431    fn cli_accepts_object_copy_with_transfer_options() {
1432        let cli = Cli::try_parse_from([
1433            "rc",
1434            "object",
1435            "copy",
1436            "./report.json",
1437            "local/my-bucket/reports/",
1438            "--content-type",
1439            "application/json",
1440            "--storage-class",
1441            "STANDARD",
1442            "--metadata-directive",
1443            "replace",
1444            "--tagging-directive",
1445            "replace",
1446            "--cache-control",
1447            "max-age=3600",
1448            "--metadata",
1449            "owner=analytics",
1450            "--tags",
1451            "env=prod",
1452            "--checksum",
1453            "sha256",
1454            "--retention-mode",
1455            "governance",
1456            "--retain-until",
1457            "2031-01-02T03:04:05Z",
1458            "--legal-hold",
1459            "on",
1460            "--dry-run",
1461        ])
1462        .expect("parse object copy with transfer options");
1463
1464        match cli.command {
1465            Commands::Object(args) => match args.command {
1466                object::ObjectCommands::Copy(arg) => {
1467                    assert_eq!(arg.sources, ["./report.json"]);
1468                    assert_eq!(arg.target, "local/my-bucket/reports/");
1469                    assert_eq!(arg.content_type.as_deref(), Some("application/json"));
1470                    assert_eq!(arg.storage_class.as_deref(), Some("STANDARD"));
1471                    assert_eq!(
1472                        arg.metadata_directive,
1473                        Some(transfer_fidelity::MetadataDirectiveArg::Replace)
1474                    );
1475                    assert_eq!(
1476                        arg.tagging_directive,
1477                        Some(transfer_fidelity::TaggingDirectiveArg::Replace)
1478                    );
1479                    assert_eq!(arg.fidelity.cache_control.as_deref(), Some("max-age=3600"));
1480                    assert_eq!(arg.fidelity.metadata, ["owner=analytics"]);
1481                    assert_eq!(arg.fidelity.tags, ["env=prod"]);
1482                    assert_eq!(arg.fidelity.checksum.as_deref(), Some("sha256"));
1483                    assert_eq!(arg.fidelity.retention_mode.as_deref(), Some("governance"));
1484                    assert_eq!(arg.fidelity.legal_hold.as_deref(), Some("on"));
1485                    assert!(arg.dry_run);
1486                }
1487                other => panic!("expected object copy command, got {:?}", other),
1488            },
1489            other => panic!("expected object command, got {:?}", other),
1490        }
1491    }
1492
1493    #[test]
1494    fn cli_accepts_multiple_copy_sources_and_global_transfer_controls() {
1495        let cli = Cli::try_parse_from([
1496            "rc",
1497            "cp",
1498            "./a.csv",
1499            "./b.csv",
1500            "local/reports/",
1501            "--include",
1502            "*.csv",
1503            "--exclude",
1504            "private-*",
1505            "--newer-than",
1506            "1h",
1507            "--concurrency",
1508            "8",
1509            "--rate-limit",
1510            "10MiB/s",
1511            "--retry-attempts",
1512            "5",
1513            "--continue-on-error",
1514            "--summary",
1515        ])
1516        .expect("parse multi-source transfer controls");
1517
1518        match cli.command {
1519            Commands::Cp(args) => {
1520                assert_eq!(args.sources, ["./a.csv", "./b.csv"]);
1521                assert_eq!(args.target, "local/reports/");
1522                assert_eq!(args.include, ["*.csv"]);
1523                assert_eq!(args.exclude, ["private-*"]);
1524                assert_eq!(args.newer_than.as_deref(), Some("1h"));
1525                assert_eq!(args.concurrency, Some(8));
1526                assert_eq!(args.rate_limit.as_deref(), Some("10MiB/s"));
1527                assert_eq!(args.retry_attempts, Some(5));
1528                assert!(args.continue_on_error);
1529                assert!(args.summary);
1530            }
1531            other => panic!("expected cp command, got {other:?}"),
1532        }
1533    }
1534
1535    #[test]
1536    fn cli_accepts_get_with_copy_options() {
1537        let cli = Cli::try_parse_from([
1538            "rc",
1539            "get",
1540            "local/reports/report.json",
1541            "./report.json",
1542            "--enc-c-source-key-env",
1543            "RC_SSE_C_KEY",
1544            "--retry-attempts",
1545            "5",
1546        ])
1547        .expect("parse get compatibility command");
1548
1549        match cli.command {
1550            Commands::Get(args) => {
1551                assert_eq!(args.transfer.sources, ["local/reports/report.json"]);
1552                assert_eq!(args.transfer.target, "./report.json");
1553                assert_eq!(
1554                    args.transfer.enc_c_source_key_env.as_deref(),
1555                    Some("RC_SSE_C_KEY")
1556                );
1557                assert_eq!(args.transfer.retry_attempts, Some(5));
1558            }
1559            other => panic!("expected get command, got {other:?}"),
1560        }
1561    }
1562
1563    #[test]
1564    fn cli_accepts_put_with_multiple_sources_and_copy_options() {
1565        let cli = Cli::try_parse_from([
1566            "rc",
1567            "put",
1568            "./january.csv",
1569            "./february.csv",
1570            "local/reports/",
1571            "--storage-class",
1572            "STANDARD",
1573            "--concurrency",
1574            "8",
1575            "--summary",
1576        ])
1577        .expect("parse put compatibility command");
1578
1579        match cli.command {
1580            Commands::Put(args) => {
1581                assert_eq!(args.transfer.sources, ["./january.csv", "./february.csv"]);
1582                assert_eq!(args.transfer.target, "local/reports/");
1583                assert_eq!(args.transfer.storage_class.as_deref(), Some("STANDARD"));
1584                assert_eq!(args.transfer.concurrency, Some(8));
1585                assert!(args.transfer.summary);
1586            }
1587            other => panic!("expected put command, got {other:?}"),
1588        }
1589    }
1590
1591    #[test]
1592    fn cli_accepts_object_move_with_recursive_dry_run() {
1593        let cli = Cli::try_parse_from([
1594            "rc",
1595            "object",
1596            "move",
1597            "local/source-bucket/logs/",
1598            "local/archive-bucket/logs/",
1599            "--recursive",
1600            "--dry-run",
1601            "--continue-on-error",
1602        ])
1603        .expect("parse object move with recursive dry-run");
1604
1605        match cli.command {
1606            Commands::Object(args) => match args.command {
1607                object::ObjectCommands::Move(arg) => {
1608                    assert_eq!(arg.source, "local/source-bucket/logs/");
1609                    assert_eq!(arg.target, "local/archive-bucket/logs/");
1610                    assert!(arg.recursive);
1611                    assert!(arg.dry_run);
1612                    assert!(arg.continue_on_error);
1613                }
1614                other => panic!("expected object move command, got {:?}", other),
1615            },
1616            other => panic!("expected object command, got {:?}", other),
1617        }
1618    }
1619
1620    #[test]
1621    fn cli_accepts_object_show_and_head_options() {
1622        let show_cli = Cli::try_parse_from([
1623            "rc",
1624            "object",
1625            "show",
1626            "local/my-bucket/report.json",
1627            "--version-id",
1628            "v1",
1629            "--rewind",
1630            "1h",
1631        ])
1632        .expect("parse object show options");
1633
1634        match show_cli.command {
1635            Commands::Object(args) => match args.command {
1636                object::ObjectCommands::Show(arg) => {
1637                    assert_eq!(arg.path, "local/my-bucket/report.json");
1638                    assert_eq!(arg.version_id.as_deref(), Some("v1"));
1639                    assert_eq!(arg.rewind.as_deref(), Some("1h"));
1640                }
1641                other => panic!("expected object show command, got {:?}", other),
1642            },
1643            other => panic!("expected object command, got {:?}", other),
1644        }
1645
1646        let head_cli = Cli::try_parse_from([
1647            "rc",
1648            "object",
1649            "head",
1650            "local/my-bucket/report.json",
1651            "--bytes",
1652            "128",
1653            "--version-id",
1654            "v2",
1655        ])
1656        .expect("parse object head options");
1657
1658        match head_cli.command {
1659            Commands::Object(args) => match args.command {
1660                object::ObjectCommands::Head(arg) => {
1661                    assert_eq!(arg.path, "local/my-bucket/report.json");
1662                    assert_eq!(arg.bytes, Some(128));
1663                    assert_eq!(arg.version_id.as_deref(), Some("v2"));
1664                }
1665                other => panic!("expected object head command, got {:?}", other),
1666            },
1667            other => panic!("expected object command, got {:?}", other),
1668        }
1669    }
1670
1671    #[test]
1672    fn cli_accepts_object_find_and_tree_options() {
1673        let find_cli = Cli::try_parse_from([
1674            "rc",
1675            "object",
1676            "find",
1677            "local/my-bucket/logs/",
1678            "--name",
1679            "*.json",
1680            "--maxdepth",
1681            "2",
1682            "--count",
1683            "--print",
1684        ])
1685        .expect("parse object find options");
1686
1687        match find_cli.command {
1688            Commands::Object(args) => match args.command {
1689                object::ObjectCommands::Find(arg) => {
1690                    assert_eq!(arg.path, "local/my-bucket/logs/");
1691                    assert_eq!(arg.name.as_deref(), Some("*.json"));
1692                    assert_eq!(arg.maxdepth, 2);
1693                    assert!(arg.count);
1694                    assert!(arg.print);
1695                }
1696                other => panic!("expected object find command, got {:?}", other),
1697            },
1698            other => panic!("expected object command, got {:?}", other),
1699        }
1700
1701        let tree_cli = Cli::try_parse_from([
1702            "rc",
1703            "object",
1704            "tree",
1705            "local/my-bucket/logs/",
1706            "--level",
1707            "4",
1708            "--size",
1709            "--pattern",
1710            "*.json",
1711            "--full-path",
1712        ])
1713        .expect("parse object tree options");
1714
1715        match tree_cli.command {
1716            Commands::Object(args) => match args.command {
1717                object::ObjectCommands::Tree(arg) => {
1718                    assert_eq!(arg.path, "local/my-bucket/logs/");
1719                    assert_eq!(arg.level, 4);
1720                    assert!(arg.size);
1721                    assert_eq!(arg.pattern.as_deref(), Some("*.json"));
1722                    assert!(arg.full_path);
1723                }
1724                other => panic!("expected object tree command, got {:?}", other),
1725            },
1726            other => panic!("expected object command, got {:?}", other),
1727        }
1728    }
1729
1730    #[test]
1731    fn cli_accepts_portable_names_on_mirror_and_copy() {
1732        let mirror = Cli::try_parse_from([
1733            "rc",
1734            "mirror",
1735            "local/loki/",
1736            "./restore/",
1737            "--portable-names",
1738        ])
1739        .expect("parse mirror portable-names");
1740        match mirror.command {
1741            Commands::Mirror(args) => assert!(args.portable_names),
1742            other => panic!("expected mirror command, got {other:?}"),
1743        }
1744
1745        let copy = Cli::try_parse_from([
1746            "rc",
1747            "object",
1748            "copy",
1749            "local/loki/",
1750            "./restore/",
1751            "--recursive",
1752            "--portable-names",
1753        ])
1754        .expect("parse object copy portable-names");
1755        match copy.command {
1756            Commands::Object(args) => match args.command {
1757                object::ObjectCommands::Copy(args) => assert!(args.portable_names),
1758                other => panic!("expected object copy command, got {other:?}"),
1759            },
1760            other => panic!("expected object command, got {other:?}"),
1761        }
1762    }
1763
1764    #[test]
1765    fn version_selector_rejects_ambiguous_or_empty_values() {
1766        assert!(validate_version_selector(Some("v1"), None).is_ok());
1767        assert!(validate_version_selector(None, Some("1h")).is_ok());
1768        assert!(validate_version_selector(Some("v1"), Some("1h")).is_err());
1769        assert!(validate_version_selector(Some(""), None).is_err());
1770    }
1771
1772    #[test]
1773    fn versioning_errors_map_to_stable_exit_codes() {
1774        assert_eq!(
1775            exit_code_for_core_error(&rc_core::Error::VersionNotFound {
1776                path: "local/bucket/key".to_string(),
1777                version_id: "v1".to_string(),
1778            }),
1779            ExitCode::NotFound
1780        );
1781        assert_eq!(
1782            exit_code_for_core_error(&rc_core::Error::Auth("denied".to_string())),
1783            ExitCode::AuthError
1784        );
1785        assert_eq!(
1786            exit_code_for_core_error(&rc_core::Error::GovernanceDenied {
1787                path: "local/bucket/key".to_string(),
1788                version_id: Some("v1".to_string()),
1789            }),
1790            ExitCode::Conflict
1791        );
1792    }
1793}