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
635 .key
636 .strip_prefix(&src.key)
637 .unwrap_or(&item.key)
638 .trim_start_matches('/');
639 let dst_path =
640 dst.join(relative_key.replace('/', std::path::MAIN_SEPARATOR_STR));
641
642 let obj_src = RemotePath::new(&src.alias, &src.bucket, &item.key);
643 let result = download_file(client, &obj_src, &dst_path, args, formatter).await;
644
645 if result == ExitCode::Success {
646 success_count += 1;
647 } else {
648 error_count += 1;
649 if !args.continue_on_error {
650 return result;
651 }
652 }
653 }
654
655 if result.truncated {
656 continuation_token = result.continuation_token;
657 } else {
658 break;
659 }
660 }
661 Err(e) => {
662 return formatter.fail(
663 ExitCode::NetworkError,
664 &format!("Failed to list objects: {e}"),
665 );
666 }
667 }
668 }
669
670 if error_count > 0 {
671 formatter.warning(&format!(
672 "Completed with errors: {success_count} succeeded, {error_count} failed"
673 ));
674 ExitCode::GeneralError
675 } else if success_count == 0 {
676 formatter.warning("No objects found to download.");
677 ExitCode::Success
678 } else {
679 if !formatter.is_json() {
680 formatter.success(&format!("Downloaded {success_count} file(s)."));
681 }
682 ExitCode::Success
683 }
684}
685
686async fn copy_s3_to_s3(
687 src: &RemotePath,
688 dst: &RemotePath,
689 args: &CpArgs,
690 formatter: &Formatter,
691) -> ExitCode {
692 let target = ParsedPath::Remote(dst.clone());
693 let encryption = match parse_destination_encryption(&args.enc_s3, &args.enc_kms, &target) {
694 Ok(encryption) => encryption,
695 Err(error) => {
696 return formatter.fail(ExitCode::UsageError, &error);
697 }
698 };
699
700 let alias_manager = match AliasManager::new() {
702 Ok(am) => am,
703 Err(e) => {
704 formatter.error(&format!("Failed to load aliases: {e}"));
705 return ExitCode::GeneralError;
706 }
707 };
708
709 if src.alias != dst.alias {
711 return formatter.fail_with_suggestion(
712 ExitCode::UnsupportedFeature,
713 "Cross-alias S3-to-S3 copy not yet supported. Use download + upload.",
714 "Copy via a local path or split the operation into download and upload steps.",
715 );
716 }
717
718 let alias = match alias_manager.get(&src.alias) {
719 Ok(a) => a,
720 Err(_) => {
721 return formatter.fail_with_suggestion(
722 ExitCode::NotFound,
723 &format!("Alias '{}' not found", src.alias),
724 "Run `rc alias list` to inspect configured aliases or add one with `rc alias set ...`.",
725 );
726 }
727 };
728
729 let client = match S3Client::new(alias).await {
730 Ok(c) => c,
731 Err(e) => {
732 return formatter.fail(
733 ExitCode::NetworkError,
734 &format!("Failed to create S3 client: {e}"),
735 );
736 }
737 };
738
739 let src_display = format!("{}/{}/{}", src.alias, src.bucket, src.key);
740 let dst_display = format!("{}/{}/{}", dst.alias, dst.bucket, dst.key);
741
742 if args.dry_run {
743 let styled_src = formatter.style_file(&src_display);
744 let styled_dst = formatter.style_file(&dst_display);
745 formatter.println(&format!("Would copy: {styled_src} -> {styled_dst}"));
746 return ExitCode::Success;
747 }
748
749 match client.copy_object(src, dst, encryption.as_ref()).await {
750 Ok(info) => {
751 if formatter.is_json() {
752 let output = CpOutput {
753 status: "success",
754 source: src_display,
755 target: dst_display,
756 size_bytes: info.size_bytes,
757 size_human: info.size_human,
758 };
759 formatter.json(&output);
760 } else {
761 let styled_src = formatter.style_file(&src_display);
762 let styled_dst = formatter.style_file(&dst_display);
763 let styled_size = formatter.style_size(&info.size_human.unwrap_or_default());
764 formatter.println(&format!("{styled_src} -> {styled_dst} ({styled_size})"));
765 }
766 ExitCode::Success
767 }
768 Err(e) => {
769 let err_str = e.to_string();
770 if err_str.contains("NotFound") || err_str.contains("NoSuchKey") {
771 formatter.fail_with_suggestion(
772 ExitCode::NotFound,
773 &format!("Source not found: {src_display}"),
774 "Check the source bucket and object key, then retry the copy command.",
775 )
776 } else {
777 formatter.fail(ExitCode::NetworkError, &format!("Failed to copy: {e}"))
778 }
779 }
780 }
781}
782
783fn parse_kms_target(value: &str) -> Result<(String, String), String> {
784 let (target, key_id) = value
785 .split_once('=')
786 .ok_or_else(|| "Expected TARGET=KMS_KEY_ID for --enc-kms".to_string())?;
787
788 if target.is_empty() || key_id.is_empty() {
789 return Err("Expected TARGET=KMS_KEY_ID for --enc-kms".to_string());
790 }
791
792 Ok((target.to_string(), key_id.to_string()))
793}
794
795pub(crate) fn parse_destination_encryption(
796 enc_s3: &[String],
797 enc_kms: &[String],
798 target: &ParsedPath,
799) -> Result<Option<ObjectEncryptionRequest>, String> {
800 if enc_s3.is_empty() && enc_kms.is_empty() {
801 return Ok(None);
802 }
803
804 let remote = match target {
805 ParsedPath::Remote(remote) => remote,
806 ParsedPath::Local(_) => {
807 return Err("Destination encryption flags must reference a remote destination".into());
808 }
809 };
810
811 let target_display = remote.to_string();
812 let s3_matches = enc_s3.iter().any(|value| value == &target_display);
813 let kms_targets = enc_kms
814 .iter()
815 .map(|value| parse_kms_target(value))
816 .collect::<Result<Vec<_>, _>>()?;
817 let kms_match = kms_targets
818 .iter()
819 .find(|(candidate, _)| candidate == &target_display);
820
821 if !enc_s3.is_empty() && !s3_matches {
822 return Err(format!(
823 "--enc-s3 target must exactly match the remote destination: {target_display}"
824 ));
825 }
826
827 if !enc_kms.is_empty() && kms_match.is_none() {
828 return Err(format!(
829 "--enc-kms target must exactly match the remote destination: {target_display}"
830 ));
831 }
832
833 match (s3_matches, kms_match) {
834 (true, Some(_)) => Err(format!(
835 "--enc-s3 and --enc-kms cannot target the same destination: {target_display}"
836 )),
837 (true, None) => Ok(Some(ObjectEncryptionRequest::SseS3)),
838 (false, Some((_, key_id))) => Ok(Some(ObjectEncryptionRequest::SseKms {
839 key_id: key_id.clone(),
840 })),
841 (false, None) => Ok(None),
842 }
843}
844
845#[cfg(test)]
846mod tests {
847 use super::*;
848 use rc_core::{Alias, ConfigManager};
849 use tempfile::TempDir;
850
851 fn temp_alias_manager() -> (AliasManager, TempDir) {
852 let temp_dir = TempDir::new().expect("create temp dir");
853 let config_path = temp_dir.path().join("config.toml");
854 let config_manager = ConfigManager::with_path(config_path);
855 let alias_manager = AliasManager::with_config_manager(config_manager);
856 (alias_manager, temp_dir)
857 }
858
859 #[test]
860 fn test_parse_local_path() {
861 let result = parse_path("./file.txt").unwrap();
862 assert!(matches!(result, ParsedPath::Local(_)));
863 }
864
865 #[test]
866 fn test_parse_remote_path() {
867 let result = parse_path("myalias/bucket/file.txt").unwrap();
868 assert!(matches!(result, ParsedPath::Remote(_)));
869 }
870
871 #[test]
872 fn test_parse_local_absolute_path() {
873 #[cfg(unix)]
875 let path = "/home/user/file.txt";
876 #[cfg(windows)]
877 let path = "C:\\Users\\user\\file.txt";
878
879 let result = parse_path(path).unwrap();
880 assert!(matches!(result, ParsedPath::Local(_)));
881 if let ParsedPath::Local(p) = result {
882 assert!(p.is_absolute());
883 }
884 }
885
886 #[test]
887 fn test_parse_local_relative_path() {
888 let result = parse_path("../file.txt").unwrap();
889 assert!(matches!(result, ParsedPath::Local(_)));
890 }
891
892 #[test]
893 fn test_parse_remote_path_bucket_only() {
894 let result = parse_path("myalias/bucket/").unwrap();
895 assert!(matches!(result, ParsedPath::Remote(_)));
896 if let ParsedPath::Remote(r) = result {
897 assert_eq!(r.alias, "myalias");
898 assert_eq!(r.bucket, "bucket");
899 assert!(r.key.is_empty());
900 }
901 }
902
903 #[test]
904 fn test_parse_remote_path_with_deep_key() {
905 let result = parse_path("myalias/bucket/dir1/dir2/file.txt").unwrap();
906 assert!(matches!(result, ParsedPath::Remote(_)));
907 if let ParsedPath::Remote(r) = result {
908 assert_eq!(r.alias, "myalias");
909 assert_eq!(r.bucket, "bucket");
910 assert_eq!(r.key, "dir1/dir2/file.txt");
911 }
912 }
913
914 #[test]
915 fn test_download_progress_created_for_large_transfer() {
916 let output_config = OutputConfig::default();
917 let mut progress = None;
918
919 update_download_progress(
920 &mut progress,
921 &output_config,
922 1024,
923 Some(DOWNLOAD_PROGRESS_THRESHOLD),
924 );
925
926 let progress = progress.expect("large download should create progress bar");
927 assert!(progress.is_visible());
928 progress.finish_and_clear();
929 }
930
931 #[test]
932 fn test_download_progress_skips_small_transfer() {
933 let output_config = OutputConfig::default();
934 let mut progress = None;
935
936 update_download_progress(
937 &mut progress,
938 &output_config,
939 1024,
940 Some(DOWNLOAD_PROGRESS_THRESHOLD - 1),
941 );
942
943 assert!(progress.is_none());
944 }
945
946 #[test]
947 fn test_download_progress_skips_unknown_total_size() {
948 let output_config = OutputConfig::default();
949 let mut progress = None;
950
951 update_download_progress(&mut progress, &output_config, 1024, None);
952
953 assert!(progress.is_none());
954 }
955
956 #[test]
957 fn test_download_progress_respects_no_progress_config() {
958 let output_config = OutputConfig {
959 no_progress: true,
960 ..Default::default()
961 };
962 let mut progress = None;
963
964 update_download_progress(
965 &mut progress,
966 &output_config,
967 1024,
968 Some(DOWNLOAD_PROGRESS_THRESHOLD),
969 );
970
971 let progress = progress.expect("large download should create progress state");
972 assert!(!progress.is_visible());
973 }
974
975 #[test]
976 fn test_select_upload_content_type_uses_guess_for_small_files() {
977 let selected =
978 select_upload_content_type(None, Some("text/plain"), MULTIPART_THRESHOLD - 1);
979
980 assert_eq!(selected, Some("text/plain"));
981 }
982
983 #[test]
984 fn test_select_upload_content_type_skips_guess_for_multipart_files() {
985 let selected =
986 select_upload_content_type(None, Some("text/plain"), MULTIPART_THRESHOLD + 1);
987
988 assert_eq!(selected, None);
989 }
990
991 #[test]
992 fn test_select_upload_content_type_uses_guess_at_multipart_boundary() {
993 let selected = select_upload_content_type(None, Some("text/plain"), MULTIPART_THRESHOLD);
994
995 assert_eq!(selected, Some("text/plain"));
996 }
997
998 #[test]
999 fn test_select_upload_content_type_keeps_explicit_type_for_multipart_files() {
1000 let selected = select_upload_content_type(
1001 Some("application/octet-stream"),
1002 Some("text/plain"),
1003 MULTIPART_THRESHOLD + 1,
1004 );
1005
1006 assert_eq!(selected, Some("application/octet-stream"));
1007 }
1008
1009 #[test]
1010 fn test_parse_cp_path_prefers_existing_local_path_when_alias_missing() {
1011 let (alias_manager, temp_dir) = temp_alias_manager();
1012 let full = temp_dir.path().join("issue-2094-local").join("file.txt");
1013 let full_str = full.to_string_lossy().to_string();
1014
1015 if let Some(parent) = full.parent() {
1016 std::fs::create_dir_all(parent).expect("create parent dirs");
1017 }
1018 std::fs::write(&full, b"test").expect("write local file");
1019
1020 let parsed = parse_cp_path(&full_str, Some(&alias_manager)).expect("parse path");
1021 assert!(matches!(parsed, ParsedPath::Local(_)));
1022 }
1023
1024 #[test]
1025 fn test_parse_cp_path_keeps_remote_when_alias_exists() {
1026 let (alias_manager, _temp_dir) = temp_alias_manager();
1027 alias_manager
1028 .set(Alias::new("target", "http://localhost:9000", "a", "b"))
1029 .expect("set alias");
1030
1031 let parsed = parse_cp_path("target/bucket/file.txt", Some(&alias_manager))
1032 .expect("parse remote path");
1033 assert!(matches!(parsed, ParsedPath::Remote(_)));
1034 }
1035
1036 #[test]
1037 fn test_parse_cp_path_keeps_remote_when_local_missing() {
1038 let (alias_manager, _temp_dir) = temp_alias_manager();
1039 let parsed = parse_cp_path("missing/bucket/file.txt", Some(&alias_manager))
1040 .expect("parse remote path");
1041 assert!(matches!(parsed, ParsedPath::Remote(_)));
1042 }
1043
1044 #[test]
1045 fn test_cp_args_defaults() {
1046 let args = CpArgs {
1047 source: "src".to_string(),
1048 target: "dst".to_string(),
1049 recursive: false,
1050 preserve: false,
1051 continue_on_error: false,
1052 overwrite: true,
1053 dry_run: false,
1054 storage_class: None,
1055 content_type: None,
1056 enc_s3: Vec::new(),
1057 enc_kms: Vec::new(),
1058 };
1059 assert!(args.overwrite);
1060 assert!(!args.recursive);
1061 assert!(!args.dry_run);
1062 }
1063
1064 #[test]
1065 fn parse_enc_kms_target_requires_equals_separator() {
1066 let error = parse_kms_target("local/bucket/file.txt").expect_err("missing key separator");
1067 assert!(error.contains("Expected TARGET=KMS_KEY_ID"));
1068 }
1069
1070 #[test]
1071 fn destination_encryption_rejects_local_targets() {
1072 let error = parse_destination_encryption(
1073 &[String::from("./local.txt")],
1074 &[],
1075 &ParsedPath::Local(std::path::PathBuf::from("./local.txt")),
1076 )
1077 .expect_err("local target should be rejected");
1078
1079 assert!(error.contains("must reference a remote destination"));
1080 }
1081
1082 #[test]
1083 fn destination_encryption_detects_conflicting_flags_for_same_target() {
1084 let target = ParsedPath::Remote(RemotePath::new("local", "bucket", "file.txt"));
1085 let error = parse_destination_encryption(
1086 &[String::from("local/bucket/file.txt")],
1087 &[String::from("local/bucket/file.txt=kms-key")],
1088 &target,
1089 )
1090 .expect_err("same target conflict should fail");
1091
1092 assert!(error.contains("cannot target the same destination"));
1093 }
1094
1095 #[test]
1096 fn destination_encryption_rejects_unmatched_s3_target() {
1097 let target = ParsedPath::Remote(RemotePath::new("local", "bucket", "file.txt"));
1098 let error =
1099 parse_destination_encryption(&[String::from("local/bucket/typo.txt")], &[], &target)
1100 .expect_err("unmatched s3 target should fail");
1101
1102 assert!(error.contains("must exactly match the remote destination"));
1103 }
1104
1105 #[test]
1106 fn destination_encryption_rejects_unmatched_kms_target() {
1107 let target = ParsedPath::Remote(RemotePath::new("local", "bucket", "file.txt"));
1108 let error = parse_destination_encryption(
1109 &[],
1110 &[String::from("local/bucket/typo.txt=kms-key")],
1111 &target,
1112 )
1113 .expect_err("unmatched kms target should fail");
1114
1115 assert!(error.contains("must exactly match the remote destination"));
1116 }
1117
1118 #[test]
1119 fn test_cp_output_serialization() {
1120 let output = CpOutput {
1121 status: "success",
1122 source: "src/file.txt".to_string(),
1123 target: "dst/file.txt".to_string(),
1124 size_bytes: Some(1024),
1125 size_human: Some("1 KiB".to_string()),
1126 };
1127 let json = serde_json::to_string(&output).unwrap();
1128 assert!(json.contains("\"status\":\"success\""));
1129 assert!(json.contains("\"size_bytes\":1024"));
1130 }
1131
1132 #[test]
1133 fn test_cp_output_skips_none_fields() {
1134 let output = CpOutput {
1135 status: "success",
1136 source: "src".to_string(),
1137 target: "dst".to_string(),
1138 size_bytes: None,
1139 size_human: None,
1140 };
1141 let json = serde_json::to_string(&output).unwrap();
1142 assert!(!json.contains("size_bytes"));
1143 assert!(!json.contains("size_human"));
1144 }
1145}