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