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