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