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