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