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 = 64 * 1024 * 1024;
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 guessed_type: Option<String> = mime_guess::from_path(src)
282 .first()
283 .map(|m| m.essence_str().to_string());
284 let content_type = args.content_type.as_deref().or(guessed_type.as_deref());
285
286 let file_size = match std::fs::metadata(src) {
288 Ok(m) => m.len(),
289 Err(e) => {
290 return formatter.fail(
291 ExitCode::GeneralError,
292 &format!("Failed to read {src_display}: {e}"),
293 );
294 }
295 };
296
297 let progress = if file_size >= MULTIPART_THRESHOLD {
299 tracing::debug!(
300 file_size,
301 threshold = MULTIPART_THRESHOLD,
302 "Using multipart upload for large file"
303 );
304 Some(ProgressBar::new(formatter.output_config(), file_size))
305 } else {
306 tracing::debug!(file_size, "Using single put_object for small file");
307 None
308 };
309
310 match client
312 .put_object_from_path(&target, src, content_type, |bytes_sent| {
313 if let Some(ref pb) = progress {
314 pb.set_position(bytes_sent);
315 }
316 })
317 .await
318 {
319 Ok(info) => {
320 if let Some(ref pb) = progress {
321 pb.finish_and_clear();
322 }
323 print_upload_success(formatter, &info, &src_display, &dst_display);
324 ExitCode::Success
325 }
326 Err(e) => {
327 if let Some(ref pb) = progress {
328 pb.finish_and_clear();
329 }
330 formatter.fail(
331 ExitCode::NetworkError,
332 &format!("Failed to upload {src_display}: {e}"),
333 )
334 }
335 }
336}
337
338async fn upload_directory(
339 client: &S3Client,
340 src: &Path,
341 dst: &RemotePath,
342 args: &CpArgs,
343 formatter: &Formatter,
344) -> ExitCode {
345 use std::fs;
346
347 let mut success_count = 0;
348 let mut error_count = 0;
349
350 fn walk_dir(dir: &Path, base: &Path) -> std::io::Result<Vec<(std::path::PathBuf, String)>> {
352 let mut files = Vec::new();
353 for entry in fs::read_dir(dir)? {
354 let entry = entry?;
355 let path = entry.path();
356 if path.is_file() {
357 let relative = path.strip_prefix(base).unwrap_or(&path);
358 let relative_str = relative.to_string_lossy().to_string();
359 files.push((path, relative_str));
360 } else if path.is_dir() {
361 files.extend(walk_dir(&path, base)?);
362 }
363 }
364 Ok(files)
365 }
366
367 let files = match walk_dir(src, src) {
368 Ok(f) => f,
369 Err(e) => {
370 return formatter.fail(
371 ExitCode::GeneralError,
372 &format!("Failed to read directory: {e}"),
373 );
374 }
375 };
376
377 for (file_path, relative_path) in files {
378 let dst_key = if dst.key.is_empty() {
380 relative_path.replace('\\', "/")
381 } else if dst.key.ends_with('/') {
382 format!("{}{}", dst.key, relative_path.replace('\\', "/"))
383 } else {
384 format!("{}/{}", dst.key, relative_path.replace('\\', "/"))
385 };
386
387 let target = RemotePath::new(&dst.alias, &dst.bucket, &dst_key);
388
389 let result = upload_file(client, &file_path, &target, args, formatter).await;
390
391 if result == ExitCode::Success {
392 success_count += 1;
393 } else {
394 error_count += 1;
395 if !args.continue_on_error {
396 return result;
397 }
398 }
399 }
400
401 if error_count > 0 {
402 formatter.warning(&format!(
403 "Completed with errors: {success_count} succeeded, {error_count} failed"
404 ));
405 ExitCode::GeneralError
406 } else {
407 if !formatter.is_json() {
408 formatter.success(&format!("Uploaded {success_count} file(s)."));
409 }
410 ExitCode::Success
411 }
412}
413
414async fn copy_s3_to_local(
415 src: &RemotePath,
416 dst: &Path,
417 args: &CpArgs,
418 formatter: &Formatter,
419) -> ExitCode {
420 let alias_manager = match AliasManager::new() {
422 Ok(am) => am,
423 Err(e) => {
424 formatter.error(&format!("Failed to load aliases: {e}"));
425 return ExitCode::GeneralError;
426 }
427 };
428
429 let alias = match alias_manager.get(&src.alias) {
430 Ok(a) => a,
431 Err(_) => {
432 return formatter.fail_with_suggestion(
433 ExitCode::NotFound,
434 &format!("Alias '{}' not found", src.alias),
435 "Run `rc alias list` to inspect configured aliases or add one with `rc alias set ...`.",
436 );
437 }
438 };
439
440 let client = match S3Client::new(alias).await {
441 Ok(c) => c,
442 Err(e) => {
443 return formatter.fail(
444 ExitCode::NetworkError,
445 &format!("Failed to create S3 client: {e}"),
446 );
447 }
448 };
449
450 let is_prefix = src.key.is_empty() || src.key.ends_with('/');
452
453 if is_prefix || args.recursive {
454 download_prefix(&client, src, dst, args, formatter).await
456 } else {
457 download_file(&client, src, dst, args, formatter).await
459 }
460}
461
462async fn download_file(
463 client: &S3Client,
464 src: &RemotePath,
465 dst: &Path,
466 args: &CpArgs,
467 formatter: &Formatter,
468) -> ExitCode {
469 let src_display = format!("{}/{}/{}", src.alias, src.bucket, src.key);
470
471 let dst_path = if dst.is_dir() || dst.to_string_lossy().ends_with('/') {
473 let filename = src.key.rsplit('/').next().unwrap_or(&src.key);
474 dst.join(filename)
475 } else {
476 dst.to_path_buf()
477 };
478
479 let dst_display = dst_path.display().to_string();
480
481 if args.dry_run {
482 let styled_src = formatter.style_file(&src_display);
483 let styled_dst = formatter.style_file(&dst_display);
484 formatter.println(&format!("Would copy: {styled_src} -> {styled_dst}"));
485 return ExitCode::Success;
486 }
487
488 if dst_path.exists() && !args.overwrite {
490 return formatter.fail_with_suggestion(
491 ExitCode::Conflict,
492 &format!("Destination exists: {dst_display}. Use --overwrite to replace."),
493 "Retry with --overwrite if replacing the destination file is intended.",
494 );
495 }
496
497 if let Some(parent) = dst_path.parent()
499 && !parent.exists()
500 && let Err(e) = std::fs::create_dir_all(parent)
501 {
502 return formatter.fail(
503 ExitCode::GeneralError,
504 &format!("Failed to create directory: {e}"),
505 );
506 }
507
508 let output_config = formatter.output_config();
509 let mut progress = None;
510
511 let result = client
513 .get_object_with_progress(src, |bytes_downloaded, total_size| {
514 update_download_progress(&mut progress, &output_config, bytes_downloaded, total_size);
515 })
516 .await;
517
518 if let Some(ref pb) = progress {
519 pb.finish_and_clear();
520 }
521
522 match result {
523 Ok(data) => {
524 let size = data.len() as i64;
525
526 if let Err(e) = std::fs::write(&dst_path, &data) {
527 return formatter.fail(
528 ExitCode::GeneralError,
529 &format!("Failed to write {dst_display}: {e}"),
530 );
531 }
532
533 if formatter.is_json() {
534 let output = CpOutput {
535 status: "success",
536 source: src_display,
537 target: dst_display,
538 size_bytes: Some(size),
539 size_human: Some(humansize::format_size(size as u64, humansize::BINARY)),
540 };
541 formatter.json(&output);
542 } else {
543 let styled_src = formatter.style_file(&src_display);
544 let styled_dst = formatter.style_file(&dst_display);
545 let styled_size =
546 formatter.style_size(&humansize::format_size(size as u64, humansize::BINARY));
547 formatter.println(&format!("{styled_src} -> {styled_dst} ({styled_size})"));
548 }
549 ExitCode::Success
550 }
551 Err(e) => {
552 let err_str = e.to_string();
553 if err_str.contains("NotFound") || err_str.contains("NoSuchKey") {
554 formatter.fail_with_suggestion(
555 ExitCode::NotFound,
556 &format!("Object not found: {src_display}"),
557 "Check the object key and bucket path, then retry the copy command.",
558 )
559 } else {
560 formatter.fail(
561 ExitCode::NetworkError,
562 &format!("Failed to download {src_display}: {e}"),
563 )
564 }
565 }
566 }
567}
568
569async fn download_prefix(
570 client: &S3Client,
571 src: &RemotePath,
572 dst: &Path,
573 args: &CpArgs,
574 formatter: &Formatter,
575) -> ExitCode {
576 use rc_core::ListOptions;
577
578 let mut success_count = 0;
579 let mut error_count = 0;
580 let mut continuation_token: Option<String> = None;
581
582 loop {
583 let options = ListOptions {
584 recursive: true,
585 max_keys: Some(1000),
586 continuation_token: continuation_token.clone(),
587 ..Default::default()
588 };
589
590 match client.list_objects(src, options).await {
591 Ok(result) => {
592 for item in result.items {
593 if item.is_dir {
594 continue;
595 }
596
597 let relative_key = item.key.strip_prefix(&src.key).unwrap_or(&item.key);
599 let dst_path =
600 dst.join(relative_key.replace('/', std::path::MAIN_SEPARATOR_STR));
601
602 let obj_src = RemotePath::new(&src.alias, &src.bucket, &item.key);
603 let result = download_file(client, &obj_src, &dst_path, args, formatter).await;
604
605 if result == ExitCode::Success {
606 success_count += 1;
607 } else {
608 error_count += 1;
609 if !args.continue_on_error {
610 return result;
611 }
612 }
613 }
614
615 if result.truncated {
616 continuation_token = result.continuation_token;
617 } else {
618 break;
619 }
620 }
621 Err(e) => {
622 return formatter.fail(
623 ExitCode::NetworkError,
624 &format!("Failed to list objects: {e}"),
625 );
626 }
627 }
628 }
629
630 if error_count > 0 {
631 formatter.warning(&format!(
632 "Completed with errors: {success_count} succeeded, {error_count} failed"
633 ));
634 ExitCode::GeneralError
635 } else if success_count == 0 {
636 formatter.warning("No objects found to download.");
637 ExitCode::Success
638 } else {
639 if !formatter.is_json() {
640 formatter.success(&format!("Downloaded {success_count} file(s)."));
641 }
642 ExitCode::Success
643 }
644}
645
646async fn copy_s3_to_s3(
647 src: &RemotePath,
648 dst: &RemotePath,
649 args: &CpArgs,
650 formatter: &Formatter,
651) -> ExitCode {
652 let alias_manager = match AliasManager::new() {
654 Ok(am) => am,
655 Err(e) => {
656 formatter.error(&format!("Failed to load aliases: {e}"));
657 return ExitCode::GeneralError;
658 }
659 };
660
661 if src.alias != dst.alias {
663 return formatter.fail_with_suggestion(
664 ExitCode::UnsupportedFeature,
665 "Cross-alias S3-to-S3 copy not yet supported. Use download + upload.",
666 "Copy via a local path or split the operation into download and upload steps.",
667 );
668 }
669
670 let alias = match alias_manager.get(&src.alias) {
671 Ok(a) => a,
672 Err(_) => {
673 return formatter.fail_with_suggestion(
674 ExitCode::NotFound,
675 &format!("Alias '{}' not found", src.alias),
676 "Run `rc alias list` to inspect configured aliases or add one with `rc alias set ...`.",
677 );
678 }
679 };
680
681 let client = match S3Client::new(alias).await {
682 Ok(c) => c,
683 Err(e) => {
684 return formatter.fail(
685 ExitCode::NetworkError,
686 &format!("Failed to create S3 client: {e}"),
687 );
688 }
689 };
690
691 let src_display = format!("{}/{}/{}", src.alias, src.bucket, src.key);
692 let dst_display = format!("{}/{}/{}", dst.alias, dst.bucket, dst.key);
693
694 if args.dry_run {
695 let styled_src = formatter.style_file(&src_display);
696 let styled_dst = formatter.style_file(&dst_display);
697 formatter.println(&format!("Would copy: {styled_src} -> {styled_dst}"));
698 return ExitCode::Success;
699 }
700
701 match client.copy_object(src, dst).await {
702 Ok(info) => {
703 if formatter.is_json() {
704 let output = CpOutput {
705 status: "success",
706 source: src_display,
707 target: dst_display,
708 size_bytes: info.size_bytes,
709 size_human: info.size_human,
710 };
711 formatter.json(&output);
712 } else {
713 let styled_src = formatter.style_file(&src_display);
714 let styled_dst = formatter.style_file(&dst_display);
715 let styled_size = formatter.style_size(&info.size_human.unwrap_or_default());
716 formatter.println(&format!("{styled_src} -> {styled_dst} ({styled_size})"));
717 }
718 ExitCode::Success
719 }
720 Err(e) => {
721 let err_str = e.to_string();
722 if err_str.contains("NotFound") || err_str.contains("NoSuchKey") {
723 formatter.fail_with_suggestion(
724 ExitCode::NotFound,
725 &format!("Source not found: {src_display}"),
726 "Check the source bucket and object key, then retry the copy command.",
727 )
728 } else {
729 formatter.fail(ExitCode::NetworkError, &format!("Failed to copy: {e}"))
730 }
731 }
732 }
733}
734
735#[cfg(test)]
736mod tests {
737 use super::*;
738 use rc_core::{Alias, ConfigManager};
739 use tempfile::TempDir;
740
741 fn temp_alias_manager() -> (AliasManager, TempDir) {
742 let temp_dir = TempDir::new().expect("create temp dir");
743 let config_path = temp_dir.path().join("config.toml");
744 let config_manager = ConfigManager::with_path(config_path);
745 let alias_manager = AliasManager::with_config_manager(config_manager);
746 (alias_manager, temp_dir)
747 }
748
749 #[test]
750 fn test_parse_local_path() {
751 let result = parse_path("./file.txt").unwrap();
752 assert!(matches!(result, ParsedPath::Local(_)));
753 }
754
755 #[test]
756 fn test_parse_remote_path() {
757 let result = parse_path("myalias/bucket/file.txt").unwrap();
758 assert!(matches!(result, ParsedPath::Remote(_)));
759 }
760
761 #[test]
762 fn test_parse_local_absolute_path() {
763 #[cfg(unix)]
765 let path = "/home/user/file.txt";
766 #[cfg(windows)]
767 let path = "C:\\Users\\user\\file.txt";
768
769 let result = parse_path(path).unwrap();
770 assert!(matches!(result, ParsedPath::Local(_)));
771 if let ParsedPath::Local(p) = result {
772 assert!(p.is_absolute());
773 }
774 }
775
776 #[test]
777 fn test_parse_local_relative_path() {
778 let result = parse_path("../file.txt").unwrap();
779 assert!(matches!(result, ParsedPath::Local(_)));
780 }
781
782 #[test]
783 fn test_parse_remote_path_bucket_only() {
784 let result = parse_path("myalias/bucket/").unwrap();
785 assert!(matches!(result, ParsedPath::Remote(_)));
786 if let ParsedPath::Remote(r) = result {
787 assert_eq!(r.alias, "myalias");
788 assert_eq!(r.bucket, "bucket");
789 assert!(r.key.is_empty());
790 }
791 }
792
793 #[test]
794 fn test_parse_remote_path_with_deep_key() {
795 let result = parse_path("myalias/bucket/dir1/dir2/file.txt").unwrap();
796 assert!(matches!(result, ParsedPath::Remote(_)));
797 if let ParsedPath::Remote(r) = result {
798 assert_eq!(r.alias, "myalias");
799 assert_eq!(r.bucket, "bucket");
800 assert_eq!(r.key, "dir1/dir2/file.txt");
801 }
802 }
803
804 #[test]
805 fn test_download_progress_created_for_large_transfer() {
806 let output_config = OutputConfig::default();
807 let mut progress = None;
808
809 update_download_progress(
810 &mut progress,
811 &output_config,
812 1024,
813 Some(DOWNLOAD_PROGRESS_THRESHOLD),
814 );
815
816 let progress = progress.expect("large download should create progress bar");
817 assert!(progress.is_visible());
818 progress.finish_and_clear();
819 }
820
821 #[test]
822 fn test_download_progress_skips_small_transfer() {
823 let output_config = OutputConfig::default();
824 let mut progress = None;
825
826 update_download_progress(
827 &mut progress,
828 &output_config,
829 1024,
830 Some(DOWNLOAD_PROGRESS_THRESHOLD - 1),
831 );
832
833 assert!(progress.is_none());
834 }
835
836 #[test]
837 fn test_download_progress_skips_unknown_total_size() {
838 let output_config = OutputConfig::default();
839 let mut progress = None;
840
841 update_download_progress(&mut progress, &output_config, 1024, None);
842
843 assert!(progress.is_none());
844 }
845
846 #[test]
847 fn test_download_progress_respects_no_progress_config() {
848 let output_config = OutputConfig {
849 no_progress: true,
850 ..Default::default()
851 };
852 let mut progress = None;
853
854 update_download_progress(
855 &mut progress,
856 &output_config,
857 1024,
858 Some(DOWNLOAD_PROGRESS_THRESHOLD),
859 );
860
861 let progress = progress.expect("large download should create progress state");
862 assert!(!progress.is_visible());
863 }
864
865 #[test]
866 fn test_parse_cp_path_prefers_existing_local_path_when_alias_missing() {
867 let (alias_manager, temp_dir) = temp_alias_manager();
868 let full = temp_dir.path().join("issue-2094-local").join("file.txt");
869 let full_str = full.to_string_lossy().to_string();
870
871 if let Some(parent) = full.parent() {
872 std::fs::create_dir_all(parent).expect("create parent dirs");
873 }
874 std::fs::write(&full, b"test").expect("write local file");
875
876 let parsed = parse_cp_path(&full_str, Some(&alias_manager)).expect("parse path");
877 assert!(matches!(parsed, ParsedPath::Local(_)));
878 }
879
880 #[test]
881 fn test_parse_cp_path_keeps_remote_when_alias_exists() {
882 let (alias_manager, _temp_dir) = temp_alias_manager();
883 alias_manager
884 .set(Alias::new("target", "http://localhost:9000", "a", "b"))
885 .expect("set alias");
886
887 let parsed = parse_cp_path("target/bucket/file.txt", Some(&alias_manager))
888 .expect("parse remote path");
889 assert!(matches!(parsed, ParsedPath::Remote(_)));
890 }
891
892 #[test]
893 fn test_parse_cp_path_keeps_remote_when_local_missing() {
894 let (alias_manager, _temp_dir) = temp_alias_manager();
895 let parsed = parse_cp_path("missing/bucket/file.txt", Some(&alias_manager))
896 .expect("parse remote path");
897 assert!(matches!(parsed, ParsedPath::Remote(_)));
898 }
899
900 #[test]
901 fn test_cp_args_defaults() {
902 let args = CpArgs {
903 source: "src".to_string(),
904 target: "dst".to_string(),
905 recursive: false,
906 preserve: false,
907 continue_on_error: false,
908 overwrite: true,
909 dry_run: false,
910 storage_class: None,
911 content_type: None,
912 };
913 assert!(args.overwrite);
914 assert!(!args.recursive);
915 assert!(!args.dry_run);
916 }
917
918 #[test]
919 fn test_cp_output_serialization() {
920 let output = CpOutput {
921 status: "success",
922 source: "src/file.txt".to_string(),
923 target: "dst/file.txt".to_string(),
924 size_bytes: Some(1024),
925 size_human: Some("1 KiB".to_string()),
926 };
927 let json = serde_json::to_string(&output).unwrap();
928 assert!(json.contains("\"status\":\"success\""));
929 assert!(json.contains("\"size_bytes\":1024"));
930 }
931
932 #[test]
933 fn test_cp_output_skips_none_fields() {
934 let output = CpOutput {
935 status: "success",
936 source: "src".to_string(),
937 target: "dst".to_string(),
938 size_bytes: None,
939 size_human: None,
940 };
941 let json = serde_json::to_string(&output).unwrap();
942 assert!(!json.contains("size_bytes"));
943 assert!(!json.contains("size_human"));
944 }
945}