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
14#[derive(Args, Debug)]
16pub struct CpArgs {
17 pub source: String,
19
20 pub target: String,
22
23 #[arg(short, long)]
25 pub recursive: bool,
26
27 #[arg(short, long)]
29 pub preserve: bool,
30
31 #[arg(long)]
33 pub continue_on_error: bool,
34
35 #[arg(long, default_value = "true")]
37 pub overwrite: bool,
38
39 #[arg(long)]
41 pub dry_run: bool,
42
43 #[arg(long)]
45 pub storage_class: Option<String>,
46
47 #[arg(long)]
49 pub content_type: Option<String>,
50}
51
52#[derive(Debug, Serialize)]
53struct CpOutput {
54 status: &'static str,
55 source: String,
56 target: String,
57 #[serde(skip_serializing_if = "Option::is_none")]
58 size_bytes: Option<i64>,
59 #[serde(skip_serializing_if = "Option::is_none")]
60 size_human: Option<String>,
61}
62
63pub async fn execute(args: CpArgs, output_config: OutputConfig) -> ExitCode {
65 let formatter = Formatter::new(output_config);
66 let alias_manager = AliasManager::new().ok();
67
68 let source = match parse_cp_path(&args.source, alias_manager.as_ref()) {
70 Ok(p) => p,
71 Err(e) => {
72 formatter.error(&format!("Invalid source path: {e}"));
73 return ExitCode::UsageError;
74 }
75 };
76
77 let target = match parse_cp_path(&args.target, alias_manager.as_ref()) {
78 Ok(p) => p,
79 Err(e) => {
80 formatter.error(&format!("Invalid target path: {e}"));
81 return ExitCode::UsageError;
82 }
83 };
84
85 match (&source, &target) {
87 (ParsedPath::Local(src), ParsedPath::Remote(dst)) => {
88 copy_local_to_s3(src, dst, &args, &formatter).await
90 }
91 (ParsedPath::Remote(src), ParsedPath::Local(dst)) => {
92 copy_s3_to_local(src, dst, &args, &formatter).await
94 }
95 (ParsedPath::Remote(src), ParsedPath::Remote(dst)) => {
96 copy_s3_to_s3(src, dst, &args, &formatter).await
98 }
99 (ParsedPath::Local(_), ParsedPath::Local(_)) => {
100 formatter.error("Cannot copy between two local paths. Use system cp command.");
101 ExitCode::UsageError
102 }
103 }
104}
105
106fn parse_cp_path(path: &str, alias_manager: Option<&AliasManager>) -> rc_core::Result<ParsedPath> {
107 let parsed = parse_path(path)?;
108
109 let ParsedPath::Remote(remote) = &parsed else {
110 return Ok(parsed);
111 };
112
113 if let Some(manager) = alias_manager
114 && matches!(manager.exists(&remote.alias), Ok(true))
115 {
116 return Ok(parsed);
117 }
118
119 if Path::new(path).exists() {
120 return Ok(ParsedPath::Local(PathBuf::from(path)));
121 }
122
123 Ok(parsed)
124}
125
126async fn copy_local_to_s3(
127 src: &Path,
128 dst: &RemotePath,
129 args: &CpArgs,
130 formatter: &Formatter,
131) -> ExitCode {
132 if !src.exists() {
134 formatter.error(&format!("Source not found: {}", src.display()));
135 return ExitCode::NotFound;
136 }
137
138 if src.is_dir() && !args.recursive {
140 formatter.error("Source is a directory. Use -r/--recursive to copy directories.");
141 return ExitCode::UsageError;
142 }
143
144 let alias_manager = match AliasManager::new() {
146 Ok(am) => am,
147 Err(e) => {
148 formatter.error(&format!("Failed to load aliases: {e}"));
149 return ExitCode::GeneralError;
150 }
151 };
152
153 let alias = match alias_manager.get(&dst.alias) {
154 Ok(a) => a,
155 Err(_) => {
156 formatter.error(&format!("Alias '{}' not found", dst.alias));
157 return ExitCode::NotFound;
158 }
159 };
160
161 let client = match S3Client::new(alias).await {
162 Ok(c) => c,
163 Err(e) => {
164 formatter.error(&format!("Failed to create S3 client: {e}"));
165 return ExitCode::NetworkError;
166 }
167 };
168
169 if src.is_file() {
170 upload_file(&client, src, dst, args, formatter).await
172 } else {
173 upload_directory(&client, src, dst, args, formatter).await
175 }
176}
177
178const MULTIPART_THRESHOLD: u64 = 64 * 1024 * 1024;
180
181fn print_upload_success(
182 formatter: &Formatter,
183 info: &rc_core::ObjectInfo,
184 src_display: &str,
185 dst_display: &str,
186) {
187 if formatter.is_json() {
188 let output = CpOutput {
189 status: "success",
190 source: src_display.to_string(),
191 target: dst_display.to_string(),
192 size_bytes: info.size_bytes,
193 size_human: info.size_human.clone(),
194 };
195 formatter.json(&output);
196 } else {
197 let styled_src = formatter.style_file(src_display);
198 let styled_dst = formatter.style_file(dst_display);
199 let styled_size = formatter.style_size(&info.size_human.clone().unwrap_or_default());
200 formatter.println(&format!("{styled_src} -> {styled_dst} ({styled_size})"));
201 }
202}
203
204async fn upload_file(
205 client: &S3Client,
206 src: &Path,
207 dst: &RemotePath,
208 args: &CpArgs,
209 formatter: &Formatter,
210) -> ExitCode {
211 let dst_key = if dst.key.is_empty() || dst.key.ends_with('/') {
213 let filename = src.file_name().unwrap_or_default().to_string_lossy();
215 format!("{}{}", dst.key, filename)
216 } else {
217 dst.key.clone()
218 };
219
220 let target = RemotePath::new(&dst.alias, &dst.bucket, &dst_key);
221 let src_display = src.display().to_string();
222 let dst_display = format!("{}/{}/{}", dst.alias, dst.bucket, dst_key);
223
224 if args.dry_run {
225 let styled_src = formatter.style_file(&src_display);
226 let styled_dst = formatter.style_file(&dst_display);
227 formatter.println(&format!("Would copy: {styled_src} -> {styled_dst}"));
228 return ExitCode::Success;
229 }
230
231 let guessed_type: Option<String> = mime_guess::from_path(src)
233 .first()
234 .map(|m| m.essence_str().to_string());
235 let content_type = args.content_type.as_deref().or(guessed_type.as_deref());
236
237 let file_size = match std::fs::metadata(src) {
239 Ok(m) => m.len(),
240 Err(e) => {
241 formatter.error(&format!("Failed to read {src_display}: {e}"));
242 return ExitCode::GeneralError;
243 }
244 };
245
246 let progress = if file_size >= MULTIPART_THRESHOLD {
248 tracing::debug!(
249 file_size,
250 threshold = MULTIPART_THRESHOLD,
251 "Using multipart upload for large file"
252 );
253 Some(ProgressBar::new(formatter.output_config(), file_size))
254 } else {
255 tracing::debug!(file_size, "Using single put_object for small file");
256 None
257 };
258
259 match client
261 .put_object_from_path(&target, src, content_type, |bytes_sent| {
262 if let Some(ref pb) = progress {
263 pb.set_position(bytes_sent);
264 }
265 })
266 .await
267 {
268 Ok(info) => {
269 if let Some(ref pb) = progress {
270 pb.finish_and_clear();
271 }
272 print_upload_success(formatter, &info, &src_display, &dst_display);
273 ExitCode::Success
274 }
275 Err(e) => {
276 if let Some(ref pb) = progress {
277 pb.finish_and_clear();
278 }
279 formatter.error(&format!("Failed to upload {src_display}: {e}"));
280 ExitCode::NetworkError
281 }
282 }
283}
284
285async fn upload_directory(
286 client: &S3Client,
287 src: &Path,
288 dst: &RemotePath,
289 args: &CpArgs,
290 formatter: &Formatter,
291) -> ExitCode {
292 use std::fs;
293
294 let mut success_count = 0;
295 let mut error_count = 0;
296
297 fn walk_dir(dir: &Path, base: &Path) -> std::io::Result<Vec<(std::path::PathBuf, String)>> {
299 let mut files = Vec::new();
300 for entry in fs::read_dir(dir)? {
301 let entry = entry?;
302 let path = entry.path();
303 if path.is_file() {
304 let relative = path.strip_prefix(base).unwrap_or(&path);
305 let relative_str = relative.to_string_lossy().to_string();
306 files.push((path, relative_str));
307 } else if path.is_dir() {
308 files.extend(walk_dir(&path, base)?);
309 }
310 }
311 Ok(files)
312 }
313
314 let files = match walk_dir(src, src) {
315 Ok(f) => f,
316 Err(e) => {
317 formatter.error(&format!("Failed to read directory: {e}"));
318 return ExitCode::GeneralError;
319 }
320 };
321
322 for (file_path, relative_path) in files {
323 let dst_key = if dst.key.is_empty() {
325 relative_path.replace('\\', "/")
326 } else if dst.key.ends_with('/') {
327 format!("{}{}", dst.key, relative_path.replace('\\', "/"))
328 } else {
329 format!("{}/{}", dst.key, relative_path.replace('\\', "/"))
330 };
331
332 let target = RemotePath::new(&dst.alias, &dst.bucket, &dst_key);
333
334 let result = upload_file(client, &file_path, &target, args, formatter).await;
335
336 if result == ExitCode::Success {
337 success_count += 1;
338 } else {
339 error_count += 1;
340 if !args.continue_on_error {
341 return result;
342 }
343 }
344 }
345
346 if error_count > 0 {
347 formatter.warning(&format!(
348 "Completed with errors: {success_count} succeeded, {error_count} failed"
349 ));
350 ExitCode::GeneralError
351 } else {
352 if !formatter.is_json() {
353 formatter.success(&format!("Uploaded {success_count} file(s)."));
354 }
355 ExitCode::Success
356 }
357}
358
359async fn copy_s3_to_local(
360 src: &RemotePath,
361 dst: &Path,
362 args: &CpArgs,
363 formatter: &Formatter,
364) -> ExitCode {
365 let alias_manager = match AliasManager::new() {
367 Ok(am) => am,
368 Err(e) => {
369 formatter.error(&format!("Failed to load aliases: {e}"));
370 return ExitCode::GeneralError;
371 }
372 };
373
374 let alias = match alias_manager.get(&src.alias) {
375 Ok(a) => a,
376 Err(_) => {
377 formatter.error(&format!("Alias '{}' not found", src.alias));
378 return ExitCode::NotFound;
379 }
380 };
381
382 let client = match S3Client::new(alias).await {
383 Ok(c) => c,
384 Err(e) => {
385 formatter.error(&format!("Failed to create S3 client: {e}"));
386 return ExitCode::NetworkError;
387 }
388 };
389
390 let is_prefix = src.key.is_empty() || src.key.ends_with('/');
392
393 if is_prefix || args.recursive {
394 download_prefix(&client, src, dst, args, formatter).await
396 } else {
397 download_file(&client, src, dst, args, formatter).await
399 }
400}
401
402async fn download_file(
403 client: &S3Client,
404 src: &RemotePath,
405 dst: &Path,
406 args: &CpArgs,
407 formatter: &Formatter,
408) -> ExitCode {
409 let src_display = format!("{}/{}/{}", src.alias, src.bucket, src.key);
410
411 let dst_path = if dst.is_dir() || dst.to_string_lossy().ends_with('/') {
413 let filename = src.key.rsplit('/').next().unwrap_or(&src.key);
414 dst.join(filename)
415 } else {
416 dst.to_path_buf()
417 };
418
419 let dst_display = dst_path.display().to_string();
420
421 if args.dry_run {
422 let styled_src = formatter.style_file(&src_display);
423 let styled_dst = formatter.style_file(&dst_display);
424 formatter.println(&format!("Would copy: {styled_src} -> {styled_dst}"));
425 return ExitCode::Success;
426 }
427
428 if dst_path.exists() && !args.overwrite {
430 formatter.error(&format!(
431 "Destination exists: {dst_display}. Use --overwrite to replace."
432 ));
433 return ExitCode::Conflict;
434 }
435
436 if let Some(parent) = dst_path.parent()
438 && !parent.exists()
439 && let Err(e) = std::fs::create_dir_all(parent)
440 {
441 formatter.error(&format!("Failed to create directory: {e}"));
442 return ExitCode::GeneralError;
443 }
444
445 match client.get_object(src).await {
447 Ok(data) => {
448 let size = data.len() as i64;
449
450 if let Err(e) = std::fs::write(&dst_path, &data) {
451 formatter.error(&format!("Failed to write {dst_display}: {e}"));
452 return ExitCode::GeneralError;
453 }
454
455 if formatter.is_json() {
456 let output = CpOutput {
457 status: "success",
458 source: src_display,
459 target: dst_display,
460 size_bytes: Some(size),
461 size_human: Some(humansize::format_size(size as u64, humansize::BINARY)),
462 };
463 formatter.json(&output);
464 } else {
465 let styled_src = formatter.style_file(&src_display);
466 let styled_dst = formatter.style_file(&dst_display);
467 let styled_size =
468 formatter.style_size(&humansize::format_size(size as u64, humansize::BINARY));
469 formatter.println(&format!("{styled_src} -> {styled_dst} ({styled_size})"));
470 }
471 ExitCode::Success
472 }
473 Err(e) => {
474 let err_str = e.to_string();
475 if err_str.contains("NotFound") || err_str.contains("NoSuchKey") {
476 formatter.error(&format!("Object not found: {src_display}"));
477 ExitCode::NotFound
478 } else {
479 formatter.error(&format!("Failed to download {src_display}: {e}"));
480 ExitCode::NetworkError
481 }
482 }
483 }
484}
485
486async fn download_prefix(
487 client: &S3Client,
488 src: &RemotePath,
489 dst: &Path,
490 args: &CpArgs,
491 formatter: &Formatter,
492) -> ExitCode {
493 use rc_core::ListOptions;
494
495 let mut success_count = 0;
496 let mut error_count = 0;
497 let mut continuation_token: Option<String> = None;
498
499 loop {
500 let options = ListOptions {
501 recursive: true,
502 max_keys: Some(1000),
503 continuation_token: continuation_token.clone(),
504 ..Default::default()
505 };
506
507 match client.list_objects(src, options).await {
508 Ok(result) => {
509 for item in result.items {
510 if item.is_dir {
511 continue;
512 }
513
514 let relative_key = item.key.strip_prefix(&src.key).unwrap_or(&item.key);
516 let dst_path =
517 dst.join(relative_key.replace('/', std::path::MAIN_SEPARATOR_STR));
518
519 let obj_src = RemotePath::new(&src.alias, &src.bucket, &item.key);
520 let result = download_file(client, &obj_src, &dst_path, args, formatter).await;
521
522 if result == ExitCode::Success {
523 success_count += 1;
524 } else {
525 error_count += 1;
526 if !args.continue_on_error {
527 return result;
528 }
529 }
530 }
531
532 if result.truncated {
533 continuation_token = result.continuation_token;
534 } else {
535 break;
536 }
537 }
538 Err(e) => {
539 formatter.error(&format!("Failed to list objects: {e}"));
540 return ExitCode::NetworkError;
541 }
542 }
543 }
544
545 if error_count > 0 {
546 formatter.warning(&format!(
547 "Completed with errors: {success_count} succeeded, {error_count} failed"
548 ));
549 ExitCode::GeneralError
550 } else if success_count == 0 {
551 formatter.warning("No objects found to download.");
552 ExitCode::Success
553 } else {
554 if !formatter.is_json() {
555 formatter.success(&format!("Downloaded {success_count} file(s)."));
556 }
557 ExitCode::Success
558 }
559}
560
561async fn copy_s3_to_s3(
562 src: &RemotePath,
563 dst: &RemotePath,
564 args: &CpArgs,
565 formatter: &Formatter,
566) -> ExitCode {
567 let alias_manager = match AliasManager::new() {
569 Ok(am) => am,
570 Err(e) => {
571 formatter.error(&format!("Failed to load aliases: {e}"));
572 return ExitCode::GeneralError;
573 }
574 };
575
576 if src.alias != dst.alias {
578 formatter.error("Cross-alias S3-to-S3 copy not yet supported. Use download + upload.");
579 return ExitCode::UnsupportedFeature;
580 }
581
582 let alias = match alias_manager.get(&src.alias) {
583 Ok(a) => a,
584 Err(_) => {
585 formatter.error(&format!("Alias '{}' not found", src.alias));
586 return ExitCode::NotFound;
587 }
588 };
589
590 let client = match S3Client::new(alias).await {
591 Ok(c) => c,
592 Err(e) => {
593 formatter.error(&format!("Failed to create S3 client: {e}"));
594 return ExitCode::NetworkError;
595 }
596 };
597
598 let src_display = format!("{}/{}/{}", src.alias, src.bucket, src.key);
599 let dst_display = format!("{}/{}/{}", dst.alias, dst.bucket, dst.key);
600
601 if args.dry_run {
602 let styled_src = formatter.style_file(&src_display);
603 let styled_dst = formatter.style_file(&dst_display);
604 formatter.println(&format!("Would copy: {styled_src} -> {styled_dst}"));
605 return ExitCode::Success;
606 }
607
608 match client.copy_object(src, dst).await {
609 Ok(info) => {
610 if formatter.is_json() {
611 let output = CpOutput {
612 status: "success",
613 source: src_display,
614 target: dst_display,
615 size_bytes: info.size_bytes,
616 size_human: info.size_human,
617 };
618 formatter.json(&output);
619 } else {
620 let styled_src = formatter.style_file(&src_display);
621 let styled_dst = formatter.style_file(&dst_display);
622 let styled_size = formatter.style_size(&info.size_human.unwrap_or_default());
623 formatter.println(&format!("{styled_src} -> {styled_dst} ({styled_size})"));
624 }
625 ExitCode::Success
626 }
627 Err(e) => {
628 let err_str = e.to_string();
629 if err_str.contains("NotFound") || err_str.contains("NoSuchKey") {
630 formatter.error(&format!("Source not found: {src_display}"));
631 ExitCode::NotFound
632 } else {
633 formatter.error(&format!("Failed to copy: {e}"));
634 ExitCode::NetworkError
635 }
636 }
637 }
638}
639
640#[cfg(test)]
641mod tests {
642 use super::*;
643 use rc_core::{Alias, ConfigManager};
644 use tempfile::TempDir;
645
646 fn temp_alias_manager() -> (AliasManager, TempDir) {
647 let temp_dir = TempDir::new().expect("create temp dir");
648 let config_path = temp_dir.path().join("config.toml");
649 let config_manager = ConfigManager::with_path(config_path);
650 let alias_manager = AliasManager::with_config_manager(config_manager);
651 (alias_manager, temp_dir)
652 }
653
654 #[test]
655 fn test_parse_local_path() {
656 let result = parse_path("./file.txt").unwrap();
657 assert!(matches!(result, ParsedPath::Local(_)));
658 }
659
660 #[test]
661 fn test_parse_remote_path() {
662 let result = parse_path("myalias/bucket/file.txt").unwrap();
663 assert!(matches!(result, ParsedPath::Remote(_)));
664 }
665
666 #[test]
667 fn test_parse_local_absolute_path() {
668 #[cfg(unix)]
670 let path = "/home/user/file.txt";
671 #[cfg(windows)]
672 let path = "C:\\Users\\user\\file.txt";
673
674 let result = parse_path(path).unwrap();
675 assert!(matches!(result, ParsedPath::Local(_)));
676 if let ParsedPath::Local(p) = result {
677 assert!(p.is_absolute());
678 }
679 }
680
681 #[test]
682 fn test_parse_local_relative_path() {
683 let result = parse_path("../file.txt").unwrap();
684 assert!(matches!(result, ParsedPath::Local(_)));
685 }
686
687 #[test]
688 fn test_parse_remote_path_bucket_only() {
689 let result = parse_path("myalias/bucket/").unwrap();
690 assert!(matches!(result, ParsedPath::Remote(_)));
691 if let ParsedPath::Remote(r) = result {
692 assert_eq!(r.alias, "myalias");
693 assert_eq!(r.bucket, "bucket");
694 assert!(r.key.is_empty());
695 }
696 }
697
698 #[test]
699 fn test_parse_remote_path_with_deep_key() {
700 let result = parse_path("myalias/bucket/dir1/dir2/file.txt").unwrap();
701 assert!(matches!(result, ParsedPath::Remote(_)));
702 if let ParsedPath::Remote(r) = result {
703 assert_eq!(r.alias, "myalias");
704 assert_eq!(r.bucket, "bucket");
705 assert_eq!(r.key, "dir1/dir2/file.txt");
706 }
707 }
708
709 #[test]
710 fn test_parse_cp_path_prefers_existing_local_path_when_alias_missing() {
711 let (alias_manager, temp_dir) = temp_alias_manager();
712 let full = temp_dir.path().join("issue-2094-local").join("file.txt");
713 let full_str = full.to_string_lossy().to_string();
714
715 if let Some(parent) = full.parent() {
716 std::fs::create_dir_all(parent).expect("create parent dirs");
717 }
718 std::fs::write(&full, b"test").expect("write local file");
719
720 let parsed = parse_cp_path(&full_str, Some(&alias_manager)).expect("parse path");
721 assert!(matches!(parsed, ParsedPath::Local(_)));
722 }
723
724 #[test]
725 fn test_parse_cp_path_keeps_remote_when_alias_exists() {
726 let (alias_manager, _temp_dir) = temp_alias_manager();
727 alias_manager
728 .set(Alias::new("target", "http://localhost:9000", "a", "b"))
729 .expect("set alias");
730
731 let parsed = parse_cp_path("target/bucket/file.txt", Some(&alias_manager))
732 .expect("parse remote path");
733 assert!(matches!(parsed, ParsedPath::Remote(_)));
734 }
735
736 #[test]
737 fn test_parse_cp_path_keeps_remote_when_local_missing() {
738 let (alias_manager, _temp_dir) = temp_alias_manager();
739 let parsed = parse_cp_path("missing/bucket/file.txt", Some(&alias_manager))
740 .expect("parse remote path");
741 assert!(matches!(parsed, ParsedPath::Remote(_)));
742 }
743
744 #[test]
745 fn test_cp_args_defaults() {
746 let args = CpArgs {
747 source: "src".to_string(),
748 target: "dst".to_string(),
749 recursive: false,
750 preserve: false,
751 continue_on_error: false,
752 overwrite: true,
753 dry_run: false,
754 storage_class: None,
755 content_type: None,
756 };
757 assert!(args.overwrite);
758 assert!(!args.recursive);
759 assert!(!args.dry_run);
760 }
761
762 #[test]
763 fn test_cp_output_serialization() {
764 let output = CpOutput {
765 status: "success",
766 source: "src/file.txt".to_string(),
767 target: "dst/file.txt".to_string(),
768 size_bytes: Some(1024),
769 size_human: Some("1 KiB".to_string()),
770 };
771 let json = serde_json::to_string(&output).unwrap();
772 assert!(json.contains("\"status\":\"success\""));
773 assert!(json.contains("\"size_bytes\":1024"));
774 }
775
776 #[test]
777 fn test_cp_output_skips_none_fields() {
778 let output = CpOutput {
779 status: "success",
780 source: "src".to_string(),
781 target: "dst".to_string(),
782 size_bytes: None,
783 size_human: None,
784 };
785 let json = serde_json::to_string(&output).unwrap();
786 assert!(!json.contains("size_bytes"));
787 assert!(!json.contains("size_human"));
788 }
789}