1use clap::Args;
6use rc_core::{
7 AliasManager, ObjectEncryptionRequest, ObjectStore as _, ParsedPath, RemotePath, parse_path,
8};
9use rc_s3::S3Client;
10use serde::Serialize;
11use std::path::{Path, PathBuf};
12
13use crate::exit_code::ExitCode;
14use crate::output::{Formatter, OutputConfig, ProgressBar};
15
16const CP_AFTER_HELP: &str = "\
17Examples:
18 rc object copy ./report.json local/my-bucket/reports/
19 rc cp ./report.json local/my-bucket/reports/
20 rc object copy local/source-bucket/archive.tar.gz ./downloads/archive.tar.gz";
21
22const REMOTE_PATH_SUGGESTION: &str =
23 "Use a local filesystem path or a remote path in the form alias/bucket[/key].";
24
25#[derive(Args, Debug)]
27#[command(after_help = CP_AFTER_HELP)]
28pub struct CpArgs {
29 pub source: String,
31
32 pub target: String,
34
35 #[arg(short, long)]
37 pub recursive: bool,
38
39 #[arg(short, long)]
41 pub preserve: bool,
42
43 #[arg(long)]
45 pub continue_on_error: bool,
46
47 #[arg(long, default_value = "true")]
49 pub overwrite: bool,
50
51 #[arg(long)]
53 pub dry_run: bool,
54
55 #[arg(long)]
57 pub storage_class: Option<String>,
58
59 #[arg(long)]
61 pub content_type: Option<String>,
62
63 #[arg(long = "enc-s3")]
65 pub enc_s3: Vec<String>,
66
67 #[arg(long = "enc-kms")]
69 pub enc_kms: Vec<String>,
70}
71
72#[derive(Debug, Serialize)]
73struct CpOutput {
74 status: &'static str,
75 source: String,
76 target: String,
77 #[serde(skip_serializing_if = "Option::is_none")]
78 size_bytes: Option<i64>,
79 #[serde(skip_serializing_if = "Option::is_none")]
80 size_human: Option<String>,
81}
82
83pub async fn execute(args: CpArgs, output_config: OutputConfig) -> ExitCode {
85 let formatter = Formatter::new(output_config);
86 let alias_manager = AliasManager::new().ok();
87
88 let source = match parse_cp_path(&args.source, alias_manager.as_ref()) {
90 Ok(p) => p,
91 Err(e) => {
92 return formatter.fail_with_suggestion(
93 ExitCode::UsageError,
94 &format!("Invalid source path: {e}"),
95 REMOTE_PATH_SUGGESTION,
96 );
97 }
98 };
99
100 let target = match parse_cp_path(&args.target, alias_manager.as_ref()) {
101 Ok(p) => p,
102 Err(e) => {
103 return formatter.fail_with_suggestion(
104 ExitCode::UsageError,
105 &format!("Invalid target path: {e}"),
106 REMOTE_PATH_SUGGESTION,
107 );
108 }
109 };
110
111 match (&source, &target) {
113 (ParsedPath::Local(src), ParsedPath::Remote(dst)) => {
114 copy_local_to_s3(src, dst, &args, &formatter).await
116 }
117 (ParsedPath::Remote(src), ParsedPath::Local(dst)) => {
118 copy_s3_to_local(src, dst, &args, &formatter).await
120 }
121 (ParsedPath::Remote(src), ParsedPath::Remote(dst)) => {
122 copy_s3_to_s3(src, dst, &args, &formatter).await
124 }
125 (ParsedPath::Local(_), ParsedPath::Local(_)) => formatter.fail_with_suggestion(
126 ExitCode::UsageError,
127 "Cannot copy between two local paths. Use system cp command.",
128 "Use your local shell cp command when both paths are on the filesystem.",
129 ),
130 }
131}
132
133fn parse_cp_path(path: &str, alias_manager: Option<&AliasManager>) -> rc_core::Result<ParsedPath> {
134 let parsed = parse_path(path)?;
135
136 let ParsedPath::Remote(remote) = &parsed else {
137 return Ok(parsed);
138 };
139
140 if let Some(manager) = alias_manager
141 && matches!(manager.exists(&remote.alias), Ok(true))
142 {
143 return Ok(parsed);
144 }
145
146 if Path::new(path).exists() {
147 return Ok(ParsedPath::Local(PathBuf::from(path)));
148 }
149
150 Ok(parsed)
151}
152
153async fn copy_local_to_s3(
154 src: &Path,
155 dst: &RemotePath,
156 args: &CpArgs,
157 formatter: &Formatter,
158) -> ExitCode {
159 let target = ParsedPath::Remote(dst.clone());
160 let encryption = match parse_destination_encryption(&args.enc_s3, &args.enc_kms, &target) {
161 Ok(encryption) => encryption,
162 Err(error) => {
163 return formatter.fail(ExitCode::UsageError, &error);
164 }
165 };
166
167 if !src.exists() {
169 return formatter.fail_with_suggestion(
170 ExitCode::NotFound,
171 &format!("Source not found: {}", src.display()),
172 "Check the local source path and retry the copy command.",
173 );
174 }
175
176 if src.is_dir() && !args.recursive {
178 return formatter.fail_with_suggestion(
179 ExitCode::UsageError,
180 "Source is a directory. Use -r/--recursive to copy directories.",
181 "Retry with -r or --recursive to copy a directory tree.",
182 );
183 }
184
185 let alias_manager = match AliasManager::new() {
187 Ok(am) => am,
188 Err(e) => {
189 formatter.error(&format!("Failed to load aliases: {e}"));
190 return ExitCode::GeneralError;
191 }
192 };
193
194 let alias = match alias_manager.get(&dst.alias) {
195 Ok(a) => a,
196 Err(_) => {
197 return formatter.fail_with_suggestion(
198 ExitCode::NotFound,
199 &format!("Alias '{}' not found", dst.alias),
200 "Run `rc alias list` to inspect configured aliases or add one with `rc alias set ...`.",
201 );
202 }
203 };
204
205 let client = match S3Client::new(alias).await {
206 Ok(c) => c,
207 Err(e) => {
208 return formatter.fail(
209 ExitCode::NetworkError,
210 &format!("Failed to create S3 client: {e}"),
211 );
212 }
213 };
214
215 if src.is_file() {
216 upload_file(&client, src, dst, args, formatter, encryption.as_ref()).await
218 } else {
219 upload_directory(&client, src, dst, args, formatter, encryption.as_ref()).await
221 }
222}
223
224const MULTIPART_THRESHOLD: u64 = rc_s3::multipart::DEFAULT_PART_SIZE;
226const DOWNLOAD_PROGRESS_THRESHOLD: u64 = 4 * 1024 * 1024;
228
229fn update_download_progress(
230 progress: &mut Option<ProgressBar>,
231 output_config: &OutputConfig,
232 bytes_downloaded: u64,
233 total_size: Option<u64>,
234) {
235 let Some(total_size) = total_size else {
236 return;
237 };
238
239 if total_size < DOWNLOAD_PROGRESS_THRESHOLD {
240 return;
241 }
242
243 let progress_bar =
244 progress.get_or_insert_with(|| ProgressBar::new(output_config.clone(), total_size));
245 progress_bar.set_position(bytes_downloaded);
246}
247
248fn print_upload_success(
249 formatter: &Formatter,
250 info: &rc_core::ObjectInfo,
251 src_display: &str,
252 dst_display: &str,
253) {
254 if formatter.is_json() {
255 let output = CpOutput {
256 status: "success",
257 source: src_display.to_string(),
258 target: dst_display.to_string(),
259 size_bytes: info.size_bytes,
260 size_human: info.size_human.clone(),
261 };
262 formatter.json(&output);
263 } else {
264 let styled_src = formatter.style_file(src_display);
265 let styled_dst = formatter.style_file(dst_display);
266 let styled_size = formatter.style_size(&info.size_human.clone().unwrap_or_default());
267 formatter.println(&format!("{styled_src} -> {styled_dst} ({styled_size})"));
268 }
269}
270
271async fn upload_file(
272 client: &S3Client,
273 src: &Path,
274 dst: &RemotePath,
275 args: &CpArgs,
276 formatter: &Formatter,
277 encryption: Option<&ObjectEncryptionRequest>,
278) -> ExitCode {
279 let dst_key = if dst.key.is_empty() || dst.key.ends_with('/') {
281 let filename = src.file_name().unwrap_or_default().to_string_lossy();
283 format!("{}{}", dst.key, filename)
284 } else {
285 dst.key.clone()
286 };
287
288 let target = RemotePath::new(&dst.alias, &dst.bucket, &dst_key);
289 let src_display = src.display().to_string();
290 let dst_display = format!("{}/{}/{}", dst.alias, dst.bucket, dst_key);
291
292 if args.dry_run {
293 let styled_src = formatter.style_file(&src_display);
294 let styled_dst = formatter.style_file(&dst_display);
295 formatter.println(&format!("Would copy: {styled_src} -> {styled_dst}"));
296 return ExitCode::Success;
297 }
298
299 let file_size = match std::fs::metadata(src) {
301 Ok(m) => m.len(),
302 Err(e) => {
303 return formatter.fail(
304 ExitCode::GeneralError,
305 &format!("Failed to read {src_display}: {e}"),
306 );
307 }
308 };
309
310 let guessed_type: Option<String> = mime_guess::from_path(src)
312 .first()
313 .map(|m| m.essence_str().to_string());
314 let content_type = select_upload_content_type(
315 args.content_type.as_deref(),
316 guessed_type.as_deref(),
317 file_size,
318 );
319
320 let progress = if file_size > MULTIPART_THRESHOLD {
322 tracing::debug!(
323 file_size,
324 threshold = MULTIPART_THRESHOLD,
325 "Using multipart upload for large file"
326 );
327 Some(ProgressBar::new(formatter.output_config(), file_size))
328 } else {
329 tracing::debug!(file_size, "Using single put_object for small file");
330 None
331 };
332
333 match client
335 .put_object_from_path(&target, src, content_type, encryption, |bytes_sent| {
336 if let Some(ref pb) = progress {
337 pb.set_position(bytes_sent);
338 }
339 })
340 .await
341 {
342 Ok(info) => {
343 if let Some(ref pb) = progress {
344 pb.finish_and_clear();
345 }
346 print_upload_success(formatter, &info, &src_display, &dst_display);
347 ExitCode::Success
348 }
349 Err(e) => {
350 if let Some(ref pb) = progress {
351 pb.finish_and_clear();
352 }
353 formatter.fail(
354 ExitCode::NetworkError,
355 &format!("Failed to upload {src_display}: {e}"),
356 )
357 }
358 }
359}
360
361fn select_upload_content_type<'a>(
362 explicit_type: Option<&'a str>,
363 guessed_type: Option<&'a str>,
364 file_size: u64,
365) -> Option<&'a str> {
366 if file_size > MULTIPART_THRESHOLD {
367 explicit_type
368 } else {
369 explicit_type.or(guessed_type)
370 }
371}
372
373async fn upload_directory(
374 client: &S3Client,
375 src: &Path,
376 dst: &RemotePath,
377 args: &CpArgs,
378 formatter: &Formatter,
379 encryption: Option<&ObjectEncryptionRequest>,
380) -> ExitCode {
381 use std::fs;
382
383 let mut success_count = 0;
384 let mut error_count = 0;
385
386 fn walk_dir(dir: &Path, base: &Path) -> std::io::Result<Vec<(std::path::PathBuf, String)>> {
388 let mut files = Vec::new();
389 for entry in fs::read_dir(dir)? {
390 let entry = entry?;
391 let path = entry.path();
392 if path.is_file() {
393 let relative = path.strip_prefix(base).unwrap_or(&path);
394 let relative_str = relative.to_string_lossy().to_string();
395 files.push((path, relative_str));
396 } else if path.is_dir() {
397 files.extend(walk_dir(&path, base)?);
398 }
399 }
400 Ok(files)
401 }
402
403 let files = match walk_dir(src, src) {
404 Ok(f) => f,
405 Err(e) => {
406 return formatter.fail(
407 ExitCode::GeneralError,
408 &format!("Failed to read directory: {e}"),
409 );
410 }
411 };
412
413 for (file_path, relative_path) in files {
414 let dst_key = if dst.key.is_empty() {
416 relative_path.replace('\\', "/")
417 } else if dst.key.ends_with('/') {
418 format!("{}{}", dst.key, relative_path.replace('\\', "/"))
419 } else {
420 format!("{}/{}", dst.key, relative_path.replace('\\', "/"))
421 };
422
423 let target = RemotePath::new(&dst.alias, &dst.bucket, &dst_key);
424
425 let result = upload_file(client, &file_path, &target, args, formatter, encryption).await;
426
427 if result == ExitCode::Success {
428 success_count += 1;
429 } else {
430 error_count += 1;
431 if !args.continue_on_error {
432 return result;
433 }
434 }
435 }
436
437 if error_count > 0 {
438 formatter.warning(&format!(
439 "Completed with errors: {success_count} succeeded, {error_count} failed"
440 ));
441 ExitCode::GeneralError
442 } else {
443 if !formatter.is_json() {
444 formatter.success(&format!("Uploaded {success_count} file(s)."));
445 }
446 ExitCode::Success
447 }
448}
449
450async fn copy_s3_to_local(
451 src: &RemotePath,
452 dst: &Path,
453 args: &CpArgs,
454 formatter: &Formatter,
455) -> ExitCode {
456 let alias_manager = match AliasManager::new() {
458 Ok(am) => am,
459 Err(e) => {
460 formatter.error(&format!("Failed to load aliases: {e}"));
461 return ExitCode::GeneralError;
462 }
463 };
464
465 let alias = match alias_manager.get(&src.alias) {
466 Ok(a) => a,
467 Err(_) => {
468 return formatter.fail_with_suggestion(
469 ExitCode::NotFound,
470 &format!("Alias '{}' not found", src.alias),
471 "Run `rc alias list` to inspect configured aliases or add one with `rc alias set ...`.",
472 );
473 }
474 };
475
476 let client = match S3Client::new(alias).await {
477 Ok(c) => c,
478 Err(e) => {
479 return formatter.fail(
480 ExitCode::NetworkError,
481 &format!("Failed to create S3 client: {e}"),
482 );
483 }
484 };
485
486 let is_prefix = src.key.is_empty() || src.key.ends_with('/');
488
489 if is_prefix || args.recursive {
490 download_prefix(&client, src, dst, args, formatter).await
492 } else {
493 download_file(&client, src, dst, args, formatter).await
495 }
496}
497
498async fn download_file(
499 client: &S3Client,
500 src: &RemotePath,
501 dst: &Path,
502 args: &CpArgs,
503 formatter: &Formatter,
504) -> ExitCode {
505 let src_display = format!("{}/{}/{}", src.alias, src.bucket, src.key);
506
507 let dst_path = if dst.is_dir() || dst.to_string_lossy().ends_with('/') {
509 let filename = src.key.rsplit('/').next().unwrap_or(&src.key);
510 dst.join(filename)
511 } else {
512 dst.to_path_buf()
513 };
514
515 let dst_display = dst_path.display().to_string();
516
517 if args.dry_run {
518 let styled_src = formatter.style_file(&src_display);
519 let styled_dst = formatter.style_file(&dst_display);
520 formatter.println(&format!("Would copy: {styled_src} -> {styled_dst}"));
521 return ExitCode::Success;
522 }
523
524 if dst_path.exists() && !args.overwrite {
526 return formatter.fail_with_suggestion(
527 ExitCode::Conflict,
528 &format!("Destination exists: {dst_display}. Use --overwrite to replace."),
529 "Retry with --overwrite if replacing the destination file is intended.",
530 );
531 }
532
533 if let Some(parent) = dst_path.parent()
535 && !parent.exists()
536 && let Err(e) = std::fs::create_dir_all(parent)
537 {
538 return formatter.fail(
539 ExitCode::GeneralError,
540 &format!("Failed to create directory: {e}"),
541 );
542 }
543
544 let output_config = formatter.output_config();
545 let mut progress = None;
546
547 let result = client
549 .get_object_with_progress(src, |bytes_downloaded, total_size| {
550 update_download_progress(&mut progress, &output_config, bytes_downloaded, total_size);
551 })
552 .await;
553
554 if let Some(ref pb) = progress {
555 pb.finish_and_clear();
556 }
557
558 match result {
559 Ok(data) => {
560 let size = data.len() as i64;
561
562 if let Err(e) = std::fs::write(&dst_path, &data) {
563 return formatter.fail(
564 ExitCode::GeneralError,
565 &format!("Failed to write {dst_display}: {e}"),
566 );
567 }
568
569 if formatter.is_json() {
570 let output = CpOutput {
571 status: "success",
572 source: src_display,
573 target: dst_display,
574 size_bytes: Some(size),
575 size_human: Some(humansize::format_size(size as u64, humansize::BINARY)),
576 };
577 formatter.json(&output);
578 } else {
579 let styled_src = formatter.style_file(&src_display);
580 let styled_dst = formatter.style_file(&dst_display);
581 let styled_size =
582 formatter.style_size(&humansize::format_size(size as u64, humansize::BINARY));
583 formatter.println(&format!("{styled_src} -> {styled_dst} ({styled_size})"));
584 }
585 ExitCode::Success
586 }
587 Err(e) => {
588 let err_str = e.to_string();
589 if err_str.contains("NotFound") || err_str.contains("NoSuchKey") {
590 formatter.fail_with_suggestion(
591 ExitCode::NotFound,
592 &format!("Object not found: {src_display}"),
593 "Check the object key and bucket path, then retry the copy command.",
594 )
595 } else {
596 formatter.fail(
597 ExitCode::NetworkError,
598 &format!("Failed to download {src_display}: {e}"),
599 )
600 }
601 }
602 }
603}
604
605async fn download_prefix(
606 client: &S3Client,
607 src: &RemotePath,
608 dst: &Path,
609 args: &CpArgs,
610 formatter: &Formatter,
611) -> ExitCode {
612 use rc_core::ListOptions;
613
614 let mut success_count = 0;
615 let mut error_count = 0;
616 let mut continuation_token: Option<String> = None;
617
618 loop {
619 let options = ListOptions {
620 recursive: true,
621 max_keys: Some(1000),
622 continuation_token: continuation_token.clone(),
623 ..Default::default()
624 };
625
626 match client.list_objects(src, options).await {
627 Ok(result) => {
628 for item in result.items {
629 if item.is_dir {
630 continue;
631 }
632
633 let relative_key = item.key.strip_prefix(&src.key).unwrap_or(&item.key);
635 let dst_path =
636 dst.join(relative_key.replace('/', std::path::MAIN_SEPARATOR_STR));
637
638 let obj_src = RemotePath::new(&src.alias, &src.bucket, &item.key);
639 let result = download_file(client, &obj_src, &dst_path, args, formatter).await;
640
641 if result == ExitCode::Success {
642 success_count += 1;
643 } else {
644 error_count += 1;
645 if !args.continue_on_error {
646 return result;
647 }
648 }
649 }
650
651 if result.truncated {
652 continuation_token = result.continuation_token;
653 } else {
654 break;
655 }
656 }
657 Err(e) => {
658 return formatter.fail(
659 ExitCode::NetworkError,
660 &format!("Failed to list objects: {e}"),
661 );
662 }
663 }
664 }
665
666 if error_count > 0 {
667 formatter.warning(&format!(
668 "Completed with errors: {success_count} succeeded, {error_count} failed"
669 ));
670 ExitCode::GeneralError
671 } else if success_count == 0 {
672 formatter.warning("No objects found to download.");
673 ExitCode::Success
674 } else {
675 if !formatter.is_json() {
676 formatter.success(&format!("Downloaded {success_count} file(s)."));
677 }
678 ExitCode::Success
679 }
680}
681
682async fn copy_s3_to_s3(
683 src: &RemotePath,
684 dst: &RemotePath,
685 args: &CpArgs,
686 formatter: &Formatter,
687) -> ExitCode {
688 let target = ParsedPath::Remote(dst.clone());
689 let encryption = match parse_destination_encryption(&args.enc_s3, &args.enc_kms, &target) {
690 Ok(encryption) => encryption,
691 Err(error) => {
692 return formatter.fail(ExitCode::UsageError, &error);
693 }
694 };
695
696 let alias_manager = match AliasManager::new() {
698 Ok(am) => am,
699 Err(e) => {
700 formatter.error(&format!("Failed to load aliases: {e}"));
701 return ExitCode::GeneralError;
702 }
703 };
704
705 if src.alias != dst.alias {
707 return formatter.fail_with_suggestion(
708 ExitCode::UnsupportedFeature,
709 "Cross-alias S3-to-S3 copy not yet supported. Use download + upload.",
710 "Copy via a local path or split the operation into download and upload steps.",
711 );
712 }
713
714 let alias = match alias_manager.get(&src.alias) {
715 Ok(a) => a,
716 Err(_) => {
717 return formatter.fail_with_suggestion(
718 ExitCode::NotFound,
719 &format!("Alias '{}' not found", src.alias),
720 "Run `rc alias list` to inspect configured aliases or add one with `rc alias set ...`.",
721 );
722 }
723 };
724
725 let client = match S3Client::new(alias).await {
726 Ok(c) => c,
727 Err(e) => {
728 return formatter.fail(
729 ExitCode::NetworkError,
730 &format!("Failed to create S3 client: {e}"),
731 );
732 }
733 };
734
735 let src_display = format!("{}/{}/{}", src.alias, src.bucket, src.key);
736 let dst_display = format!("{}/{}/{}", dst.alias, dst.bucket, dst.key);
737
738 if args.dry_run {
739 let styled_src = formatter.style_file(&src_display);
740 let styled_dst = formatter.style_file(&dst_display);
741 formatter.println(&format!("Would copy: {styled_src} -> {styled_dst}"));
742 return ExitCode::Success;
743 }
744
745 match client.copy_object(src, dst, encryption.as_ref()).await {
746 Ok(info) => {
747 if formatter.is_json() {
748 let output = CpOutput {
749 status: "success",
750 source: src_display,
751 target: dst_display,
752 size_bytes: info.size_bytes,
753 size_human: info.size_human,
754 };
755 formatter.json(&output);
756 } else {
757 let styled_src = formatter.style_file(&src_display);
758 let styled_dst = formatter.style_file(&dst_display);
759 let styled_size = formatter.style_size(&info.size_human.unwrap_or_default());
760 formatter.println(&format!("{styled_src} -> {styled_dst} ({styled_size})"));
761 }
762 ExitCode::Success
763 }
764 Err(e) => {
765 let err_str = e.to_string();
766 if err_str.contains("NotFound") || err_str.contains("NoSuchKey") {
767 formatter.fail_with_suggestion(
768 ExitCode::NotFound,
769 &format!("Source not found: {src_display}"),
770 "Check the source bucket and object key, then retry the copy command.",
771 )
772 } else {
773 formatter.fail(ExitCode::NetworkError, &format!("Failed to copy: {e}"))
774 }
775 }
776 }
777}
778
779fn parse_kms_target(value: &str) -> Result<(String, String), String> {
780 let (target, key_id) = value
781 .split_once('=')
782 .ok_or_else(|| "Expected TARGET=KMS_KEY_ID for --enc-kms".to_string())?;
783
784 if target.is_empty() || key_id.is_empty() {
785 return Err("Expected TARGET=KMS_KEY_ID for --enc-kms".to_string());
786 }
787
788 Ok((target.to_string(), key_id.to_string()))
789}
790
791pub(crate) fn parse_destination_encryption(
792 enc_s3: &[String],
793 enc_kms: &[String],
794 target: &ParsedPath,
795) -> Result<Option<ObjectEncryptionRequest>, String> {
796 if enc_s3.is_empty() && enc_kms.is_empty() {
797 return Ok(None);
798 }
799
800 let remote = match target {
801 ParsedPath::Remote(remote) => remote,
802 ParsedPath::Local(_) => {
803 return Err("Destination encryption flags must reference a remote destination".into());
804 }
805 };
806
807 let target_display = remote.to_string();
808 let s3_matches = enc_s3.iter().any(|value| value == &target_display);
809 let kms_targets = enc_kms
810 .iter()
811 .map(|value| parse_kms_target(value))
812 .collect::<Result<Vec<_>, _>>()?;
813 let kms_match = kms_targets
814 .iter()
815 .find(|(candidate, _)| candidate == &target_display);
816
817 if !enc_s3.is_empty() && !s3_matches {
818 return Err(format!(
819 "--enc-s3 target must exactly match the remote destination: {target_display}"
820 ));
821 }
822
823 if !enc_kms.is_empty() && kms_match.is_none() {
824 return Err(format!(
825 "--enc-kms target must exactly match the remote destination: {target_display}"
826 ));
827 }
828
829 match (s3_matches, kms_match) {
830 (true, Some(_)) => Err(format!(
831 "--enc-s3 and --enc-kms cannot target the same destination: {target_display}"
832 )),
833 (true, None) => Ok(Some(ObjectEncryptionRequest::SseS3)),
834 (false, Some((_, key_id))) => Ok(Some(ObjectEncryptionRequest::SseKms {
835 key_id: key_id.clone(),
836 })),
837 (false, None) => Ok(None),
838 }
839}
840
841#[cfg(test)]
842mod tests {
843 use super::*;
844 use rc_core::{Alias, ConfigManager};
845 use tempfile::TempDir;
846
847 fn temp_alias_manager() -> (AliasManager, TempDir) {
848 let temp_dir = TempDir::new().expect("create temp dir");
849 let config_path = temp_dir.path().join("config.toml");
850 let config_manager = ConfigManager::with_path(config_path);
851 let alias_manager = AliasManager::with_config_manager(config_manager);
852 (alias_manager, temp_dir)
853 }
854
855 #[test]
856 fn test_parse_local_path() {
857 let result = parse_path("./file.txt").unwrap();
858 assert!(matches!(result, ParsedPath::Local(_)));
859 }
860
861 #[test]
862 fn test_parse_remote_path() {
863 let result = parse_path("myalias/bucket/file.txt").unwrap();
864 assert!(matches!(result, ParsedPath::Remote(_)));
865 }
866
867 #[test]
868 fn test_parse_local_absolute_path() {
869 #[cfg(unix)]
871 let path = "/home/user/file.txt";
872 #[cfg(windows)]
873 let path = "C:\\Users\\user\\file.txt";
874
875 let result = parse_path(path).unwrap();
876 assert!(matches!(result, ParsedPath::Local(_)));
877 if let ParsedPath::Local(p) = result {
878 assert!(p.is_absolute());
879 }
880 }
881
882 #[test]
883 fn test_parse_local_relative_path() {
884 let result = parse_path("../file.txt").unwrap();
885 assert!(matches!(result, ParsedPath::Local(_)));
886 }
887
888 #[test]
889 fn test_parse_remote_path_bucket_only() {
890 let result = parse_path("myalias/bucket/").unwrap();
891 assert!(matches!(result, ParsedPath::Remote(_)));
892 if let ParsedPath::Remote(r) = result {
893 assert_eq!(r.alias, "myalias");
894 assert_eq!(r.bucket, "bucket");
895 assert!(r.key.is_empty());
896 }
897 }
898
899 #[test]
900 fn test_parse_remote_path_with_deep_key() {
901 let result = parse_path("myalias/bucket/dir1/dir2/file.txt").unwrap();
902 assert!(matches!(result, ParsedPath::Remote(_)));
903 if let ParsedPath::Remote(r) = result {
904 assert_eq!(r.alias, "myalias");
905 assert_eq!(r.bucket, "bucket");
906 assert_eq!(r.key, "dir1/dir2/file.txt");
907 }
908 }
909
910 #[test]
911 fn test_download_progress_created_for_large_transfer() {
912 let output_config = OutputConfig::default();
913 let mut progress = None;
914
915 update_download_progress(
916 &mut progress,
917 &output_config,
918 1024,
919 Some(DOWNLOAD_PROGRESS_THRESHOLD),
920 );
921
922 let progress = progress.expect("large download should create progress bar");
923 assert!(progress.is_visible());
924 progress.finish_and_clear();
925 }
926
927 #[test]
928 fn test_download_progress_skips_small_transfer() {
929 let output_config = OutputConfig::default();
930 let mut progress = None;
931
932 update_download_progress(
933 &mut progress,
934 &output_config,
935 1024,
936 Some(DOWNLOAD_PROGRESS_THRESHOLD - 1),
937 );
938
939 assert!(progress.is_none());
940 }
941
942 #[test]
943 fn test_download_progress_skips_unknown_total_size() {
944 let output_config = OutputConfig::default();
945 let mut progress = None;
946
947 update_download_progress(&mut progress, &output_config, 1024, None);
948
949 assert!(progress.is_none());
950 }
951
952 #[test]
953 fn test_download_progress_respects_no_progress_config() {
954 let output_config = OutputConfig {
955 no_progress: true,
956 ..Default::default()
957 };
958 let mut progress = None;
959
960 update_download_progress(
961 &mut progress,
962 &output_config,
963 1024,
964 Some(DOWNLOAD_PROGRESS_THRESHOLD),
965 );
966
967 let progress = progress.expect("large download should create progress state");
968 assert!(!progress.is_visible());
969 }
970
971 #[test]
972 fn test_select_upload_content_type_uses_guess_for_small_files() {
973 let selected =
974 select_upload_content_type(None, Some("text/plain"), MULTIPART_THRESHOLD - 1);
975
976 assert_eq!(selected, Some("text/plain"));
977 }
978
979 #[test]
980 fn test_select_upload_content_type_skips_guess_for_multipart_files() {
981 let selected =
982 select_upload_content_type(None, Some("text/plain"), MULTIPART_THRESHOLD + 1);
983
984 assert_eq!(selected, None);
985 }
986
987 #[test]
988 fn test_select_upload_content_type_uses_guess_at_multipart_boundary() {
989 let selected = select_upload_content_type(None, Some("text/plain"), MULTIPART_THRESHOLD);
990
991 assert_eq!(selected, Some("text/plain"));
992 }
993
994 #[test]
995 fn test_select_upload_content_type_keeps_explicit_type_for_multipart_files() {
996 let selected = select_upload_content_type(
997 Some("application/octet-stream"),
998 Some("text/plain"),
999 MULTIPART_THRESHOLD + 1,
1000 );
1001
1002 assert_eq!(selected, Some("application/octet-stream"));
1003 }
1004
1005 #[test]
1006 fn test_parse_cp_path_prefers_existing_local_path_when_alias_missing() {
1007 let (alias_manager, temp_dir) = temp_alias_manager();
1008 let full = temp_dir.path().join("issue-2094-local").join("file.txt");
1009 let full_str = full.to_string_lossy().to_string();
1010
1011 if let Some(parent) = full.parent() {
1012 std::fs::create_dir_all(parent).expect("create parent dirs");
1013 }
1014 std::fs::write(&full, b"test").expect("write local file");
1015
1016 let parsed = parse_cp_path(&full_str, Some(&alias_manager)).expect("parse path");
1017 assert!(matches!(parsed, ParsedPath::Local(_)));
1018 }
1019
1020 #[test]
1021 fn test_parse_cp_path_keeps_remote_when_alias_exists() {
1022 let (alias_manager, _temp_dir) = temp_alias_manager();
1023 alias_manager
1024 .set(Alias::new("target", "http://localhost:9000", "a", "b"))
1025 .expect("set alias");
1026
1027 let parsed = parse_cp_path("target/bucket/file.txt", Some(&alias_manager))
1028 .expect("parse remote path");
1029 assert!(matches!(parsed, ParsedPath::Remote(_)));
1030 }
1031
1032 #[test]
1033 fn test_parse_cp_path_keeps_remote_when_local_missing() {
1034 let (alias_manager, _temp_dir) = temp_alias_manager();
1035 let parsed = parse_cp_path("missing/bucket/file.txt", Some(&alias_manager))
1036 .expect("parse remote path");
1037 assert!(matches!(parsed, ParsedPath::Remote(_)));
1038 }
1039
1040 #[test]
1041 fn test_cp_args_defaults() {
1042 let args = CpArgs {
1043 source: "src".to_string(),
1044 target: "dst".to_string(),
1045 recursive: false,
1046 preserve: false,
1047 continue_on_error: false,
1048 overwrite: true,
1049 dry_run: false,
1050 storage_class: None,
1051 content_type: None,
1052 enc_s3: Vec::new(),
1053 enc_kms: Vec::new(),
1054 };
1055 assert!(args.overwrite);
1056 assert!(!args.recursive);
1057 assert!(!args.dry_run);
1058 }
1059
1060 #[test]
1061 fn parse_enc_kms_target_requires_equals_separator() {
1062 let error = parse_kms_target("local/bucket/file.txt").expect_err("missing key separator");
1063 assert!(error.contains("Expected TARGET=KMS_KEY_ID"));
1064 }
1065
1066 #[test]
1067 fn destination_encryption_rejects_local_targets() {
1068 let error = parse_destination_encryption(
1069 &[String::from("./local.txt")],
1070 &[],
1071 &ParsedPath::Local(std::path::PathBuf::from("./local.txt")),
1072 )
1073 .expect_err("local target should be rejected");
1074
1075 assert!(error.contains("must reference a remote destination"));
1076 }
1077
1078 #[test]
1079 fn destination_encryption_detects_conflicting_flags_for_same_target() {
1080 let target = ParsedPath::Remote(RemotePath::new("local", "bucket", "file.txt"));
1081 let error = parse_destination_encryption(
1082 &[String::from("local/bucket/file.txt")],
1083 &[String::from("local/bucket/file.txt=kms-key")],
1084 &target,
1085 )
1086 .expect_err("same target conflict should fail");
1087
1088 assert!(error.contains("cannot target the same destination"));
1089 }
1090
1091 #[test]
1092 fn destination_encryption_rejects_unmatched_s3_target() {
1093 let target = ParsedPath::Remote(RemotePath::new("local", "bucket", "file.txt"));
1094 let error =
1095 parse_destination_encryption(&[String::from("local/bucket/typo.txt")], &[], &target)
1096 .expect_err("unmatched s3 target should fail");
1097
1098 assert!(error.contains("must exactly match the remote destination"));
1099 }
1100
1101 #[test]
1102 fn destination_encryption_rejects_unmatched_kms_target() {
1103 let target = ParsedPath::Remote(RemotePath::new("local", "bucket", "file.txt"));
1104 let error = parse_destination_encryption(
1105 &[],
1106 &[String::from("local/bucket/typo.txt=kms-key")],
1107 &target,
1108 )
1109 .expect_err("unmatched kms target should fail");
1110
1111 assert!(error.contains("must exactly match the remote destination"));
1112 }
1113
1114 #[test]
1115 fn test_cp_output_serialization() {
1116 let output = CpOutput {
1117 status: "success",
1118 source: "src/file.txt".to_string(),
1119 target: "dst/file.txt".to_string(),
1120 size_bytes: Some(1024),
1121 size_human: Some("1 KiB".to_string()),
1122 };
1123 let json = serde_json::to_string(&output).unwrap();
1124 assert!(json.contains("\"status\":\"success\""));
1125 assert!(json.contains("\"size_bytes\":1024"));
1126 }
1127
1128 #[test]
1129 fn test_cp_output_skips_none_fields() {
1130 let output = CpOutput {
1131 status: "success",
1132 source: "src".to_string(),
1133 target: "dst".to_string(),
1134 size_bytes: None,
1135 size_human: None,
1136 };
1137 let json = serde_json::to_string(&output).unwrap();
1138 assert!(!json.contains("size_bytes"));
1139 assert!(!json.contains("size_human"));
1140 }
1141}