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