1use 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#[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 #[arg(long, global = true, value_enum)]
94 pub format: Option<OutputFormat>,
95
96 #[arg(long, global = true, default_value = "false")]
98 pub json: bool,
99
100 #[arg(long, global = true, default_value = "false")]
102 pub no_color: bool,
103
104 #[arg(long, global = true, default_value = "false")]
106 pub no_progress: bool,
107
108 #[arg(short, long, global = true, default_value = "false")]
110 pub quiet: bool,
111
112 #[arg(long, global = true, default_value = "false")]
114 pub debug: bool,
115
116 #[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 #[command(subcommand)]
229 Alias(alias::AliasCommands),
230
231 #[command(subcommand)]
233 Admin(admin::AdminCommands),
234
235 Bucket(bucket::BucketArgs),
237
238 Object(object::ObjectArgs),
240
241 Ls(ls::LsArgs),
244
245 Mb(mb::MbArgs),
247
248 Rb(rb::RbArgs),
250
251 Cat(cat::CatArgs),
253
254 Head(head::HeadArgs),
256
257 Stat(stat::StatArgs),
259
260 Cp(Box<cp::CpArgs>),
263
264 Get(Box<cp::GetArgs>),
266
267 Put(Box<cp::PutArgs>),
269
270 Mv(mv::MvArgs),
272
273 Rm(rm::RmArgs),
275
276 Undo(undo::UndoArgs),
278
279 Pipe(pipe::PipeArgs),
281
282 Find(find::FindArgs),
285
286 Event(event::EventArgs),
288
289 #[command(subcommand)]
291 Cors(cors::CorsCommands),
292
293 Diff(diff::DiffArgs),
295
296 Mirror(mirror::MirrorArgs),
298
299 Tree(tree::TreeArgs),
301
302 Share(share::ShareArgs),
304
305 Sql(sql::SqlArgs),
307
308 #[command(subcommand)]
311 Version(version::VersionCommands),
312
313 #[command(subcommand)]
315 Tag(tag::TagCommands),
316
317 #[command(subcommand)]
319 Anonymous(anonymous::AnonymousCommands),
320
321 #[command(subcommand)]
323 Quota(quota::QuotaCommands),
324
325 Ilm(ilm::IlmArgs),
327
328 Replicate(replicate::ReplicateArgs),
330
331 Retention(retention::RetentionArgs),
333
334 Legalhold(legalhold::LegalHoldArgs),
336
337 Completions(completions::CompletionsArgs),
340
341 Du(du::DuArgs),
343
344 Ping(ping::PingArgs),
346
347 Ready(ready::ReadyArgs),
349
350 Watch(watch::WatchArgs),
352}
353
354pub 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}