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};
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
178async fn upload_file(
179 client: &S3Client,
180 src: &Path,
181 dst: &RemotePath,
182 args: &CpArgs,
183 formatter: &Formatter,
184) -> ExitCode {
185 let dst_key = if dst.key.is_empty() || dst.key.ends_with('/') {
187 let filename = src.file_name().unwrap_or_default().to_string_lossy();
189 format!("{}{}", dst.key, filename)
190 } else {
191 dst.key.clone()
192 };
193
194 let target = RemotePath::new(&dst.alias, &dst.bucket, &dst_key);
195 let src_display = src.display().to_string();
196 let dst_display = format!("{}/{}/{}", dst.alias, dst.bucket, dst_key);
197
198 if args.dry_run {
199 let styled_src = formatter.style_file(&src_display);
200 let styled_dst = formatter.style_file(&dst_display);
201 formatter.println(&format!("Would copy: {styled_src} -> {styled_dst}"));
202 return ExitCode::Success;
203 }
204
205 let data = match std::fs::read(src) {
207 Ok(d) => d,
208 Err(e) => {
209 formatter.error(&format!("Failed to read {src_display}: {e}"));
210 return ExitCode::GeneralError;
211 }
212 };
213
214 let size = data.len() as i64;
215
216 let guessed_type: Option<String> = mime_guess::from_path(src)
218 .first()
219 .map(|m| m.essence_str().to_string());
220 let content_type = args.content_type.as_deref().or(guessed_type.as_deref());
221
222 match client.put_object(&target, data, content_type).await {
224 Ok(info) => {
225 if formatter.is_json() {
226 let output = CpOutput {
227 status: "success",
228 source: src_display,
229 target: dst_display,
230 size_bytes: Some(size),
231 size_human: Some(humansize::format_size(size as u64, humansize::BINARY)),
232 };
233 formatter.json(&output);
234 } else {
235 let styled_src = formatter.style_file(&src_display);
236 let styled_dst = formatter.style_file(&dst_display);
237 let styled_size = formatter.style_size(&info.size_human.unwrap_or_default());
238 formatter.println(&format!("{styled_src} -> {styled_dst} ({styled_size})"));
239 }
240 ExitCode::Success
241 }
242 Err(e) => {
243 formatter.error(&format!("Failed to upload {src_display}: {e}"));
244 ExitCode::NetworkError
245 }
246 }
247}
248
249async fn upload_directory(
250 client: &S3Client,
251 src: &Path,
252 dst: &RemotePath,
253 args: &CpArgs,
254 formatter: &Formatter,
255) -> ExitCode {
256 use std::fs;
257
258 let mut success_count = 0;
259 let mut error_count = 0;
260
261 fn walk_dir(dir: &Path, base: &Path) -> std::io::Result<Vec<(std::path::PathBuf, String)>> {
263 let mut files = Vec::new();
264 for entry in fs::read_dir(dir)? {
265 let entry = entry?;
266 let path = entry.path();
267 if path.is_file() {
268 let relative = path.strip_prefix(base).unwrap_or(&path);
269 let relative_str = relative.to_string_lossy().to_string();
270 files.push((path, relative_str));
271 } else if path.is_dir() {
272 files.extend(walk_dir(&path, base)?);
273 }
274 }
275 Ok(files)
276 }
277
278 let files = match walk_dir(src, src) {
279 Ok(f) => f,
280 Err(e) => {
281 formatter.error(&format!("Failed to read directory: {e}"));
282 return ExitCode::GeneralError;
283 }
284 };
285
286 for (file_path, relative_path) in files {
287 let dst_key = if dst.key.is_empty() {
289 relative_path.replace('\\', "/")
290 } else if dst.key.ends_with('/') {
291 format!("{}{}", dst.key, relative_path.replace('\\', "/"))
292 } else {
293 format!("{}/{}", dst.key, relative_path.replace('\\', "/"))
294 };
295
296 let target = RemotePath::new(&dst.alias, &dst.bucket, &dst_key);
297
298 let result = upload_file(client, &file_path, &target, args, formatter).await;
299
300 if result == ExitCode::Success {
301 success_count += 1;
302 } else {
303 error_count += 1;
304 if !args.continue_on_error {
305 return result;
306 }
307 }
308 }
309
310 if error_count > 0 {
311 formatter.warning(&format!(
312 "Completed with errors: {success_count} succeeded, {error_count} failed"
313 ));
314 ExitCode::GeneralError
315 } else {
316 if !formatter.is_json() {
317 formatter.success(&format!("Uploaded {success_count} file(s)."));
318 }
319 ExitCode::Success
320 }
321}
322
323async fn copy_s3_to_local(
324 src: &RemotePath,
325 dst: &Path,
326 args: &CpArgs,
327 formatter: &Formatter,
328) -> ExitCode {
329 let alias_manager = match AliasManager::new() {
331 Ok(am) => am,
332 Err(e) => {
333 formatter.error(&format!("Failed to load aliases: {e}"));
334 return ExitCode::GeneralError;
335 }
336 };
337
338 let alias = match alias_manager.get(&src.alias) {
339 Ok(a) => a,
340 Err(_) => {
341 formatter.error(&format!("Alias '{}' not found", src.alias));
342 return ExitCode::NotFound;
343 }
344 };
345
346 let client = match S3Client::new(alias).await {
347 Ok(c) => c,
348 Err(e) => {
349 formatter.error(&format!("Failed to create S3 client: {e}"));
350 return ExitCode::NetworkError;
351 }
352 };
353
354 let is_prefix = src.key.is_empty() || src.key.ends_with('/');
356
357 if is_prefix || args.recursive {
358 download_prefix(&client, src, dst, args, formatter).await
360 } else {
361 download_file(&client, src, dst, args, formatter).await
363 }
364}
365
366async fn download_file(
367 client: &S3Client,
368 src: &RemotePath,
369 dst: &Path,
370 args: &CpArgs,
371 formatter: &Formatter,
372) -> ExitCode {
373 let src_display = format!("{}/{}/{}", src.alias, src.bucket, src.key);
374
375 let dst_path = if dst.is_dir() || dst.to_string_lossy().ends_with('/') {
377 let filename = src.key.rsplit('/').next().unwrap_or(&src.key);
378 dst.join(filename)
379 } else {
380 dst.to_path_buf()
381 };
382
383 let dst_display = dst_path.display().to_string();
384
385 if args.dry_run {
386 let styled_src = formatter.style_file(&src_display);
387 let styled_dst = formatter.style_file(&dst_display);
388 formatter.println(&format!("Would copy: {styled_src} -> {styled_dst}"));
389 return ExitCode::Success;
390 }
391
392 if dst_path.exists() && !args.overwrite {
394 formatter.error(&format!(
395 "Destination exists: {dst_display}. Use --overwrite to replace."
396 ));
397 return ExitCode::Conflict;
398 }
399
400 if let Some(parent) = dst_path.parent()
402 && !parent.exists()
403 && let Err(e) = std::fs::create_dir_all(parent)
404 {
405 formatter.error(&format!("Failed to create directory: {e}"));
406 return ExitCode::GeneralError;
407 }
408
409 match client.get_object(src).await {
411 Ok(data) => {
412 let size = data.len() as i64;
413
414 if let Err(e) = std::fs::write(&dst_path, &data) {
415 formatter.error(&format!("Failed to write {dst_display}: {e}"));
416 return ExitCode::GeneralError;
417 }
418
419 if formatter.is_json() {
420 let output = CpOutput {
421 status: "success",
422 source: src_display,
423 target: dst_display,
424 size_bytes: Some(size),
425 size_human: Some(humansize::format_size(size as u64, humansize::BINARY)),
426 };
427 formatter.json(&output);
428 } else {
429 let styled_src = formatter.style_file(&src_display);
430 let styled_dst = formatter.style_file(&dst_display);
431 let styled_size =
432 formatter.style_size(&humansize::format_size(size as u64, humansize::BINARY));
433 formatter.println(&format!("{styled_src} -> {styled_dst} ({styled_size})"));
434 }
435 ExitCode::Success
436 }
437 Err(e) => {
438 let err_str = e.to_string();
439 if err_str.contains("NotFound") || err_str.contains("NoSuchKey") {
440 formatter.error(&format!("Object not found: {src_display}"));
441 ExitCode::NotFound
442 } else {
443 formatter.error(&format!("Failed to download {src_display}: {e}"));
444 ExitCode::NetworkError
445 }
446 }
447 }
448}
449
450async fn download_prefix(
451 client: &S3Client,
452 src: &RemotePath,
453 dst: &Path,
454 args: &CpArgs,
455 formatter: &Formatter,
456) -> ExitCode {
457 use rc_core::ListOptions;
458
459 let mut success_count = 0;
460 let mut error_count = 0;
461 let mut continuation_token: Option<String> = None;
462
463 loop {
464 let options = ListOptions {
465 recursive: true,
466 max_keys: Some(1000),
467 continuation_token: continuation_token.clone(),
468 ..Default::default()
469 };
470
471 match client.list_objects(src, options).await {
472 Ok(result) => {
473 for item in result.items {
474 if item.is_dir {
475 continue;
476 }
477
478 let relative_key = item.key.strip_prefix(&src.key).unwrap_or(&item.key);
480 let dst_path =
481 dst.join(relative_key.replace('/', std::path::MAIN_SEPARATOR_STR));
482
483 let obj_src = RemotePath::new(&src.alias, &src.bucket, &item.key);
484 let result = download_file(client, &obj_src, &dst_path, args, formatter).await;
485
486 if result == ExitCode::Success {
487 success_count += 1;
488 } else {
489 error_count += 1;
490 if !args.continue_on_error {
491 return result;
492 }
493 }
494 }
495
496 if result.truncated {
497 continuation_token = result.continuation_token;
498 } else {
499 break;
500 }
501 }
502 Err(e) => {
503 formatter.error(&format!("Failed to list objects: {e}"));
504 return ExitCode::NetworkError;
505 }
506 }
507 }
508
509 if error_count > 0 {
510 formatter.warning(&format!(
511 "Completed with errors: {success_count} succeeded, {error_count} failed"
512 ));
513 ExitCode::GeneralError
514 } else if success_count == 0 {
515 formatter.warning("No objects found to download.");
516 ExitCode::Success
517 } else {
518 if !formatter.is_json() {
519 formatter.success(&format!("Downloaded {success_count} file(s)."));
520 }
521 ExitCode::Success
522 }
523}
524
525async fn copy_s3_to_s3(
526 src: &RemotePath,
527 dst: &RemotePath,
528 args: &CpArgs,
529 formatter: &Formatter,
530) -> ExitCode {
531 let alias_manager = match AliasManager::new() {
533 Ok(am) => am,
534 Err(e) => {
535 formatter.error(&format!("Failed to load aliases: {e}"));
536 return ExitCode::GeneralError;
537 }
538 };
539
540 if src.alias != dst.alias {
542 formatter.error("Cross-alias S3-to-S3 copy not yet supported. Use download + upload.");
543 return ExitCode::UnsupportedFeature;
544 }
545
546 let alias = match alias_manager.get(&src.alias) {
547 Ok(a) => a,
548 Err(_) => {
549 formatter.error(&format!("Alias '{}' not found", src.alias));
550 return ExitCode::NotFound;
551 }
552 };
553
554 let client = match S3Client::new(alias).await {
555 Ok(c) => c,
556 Err(e) => {
557 formatter.error(&format!("Failed to create S3 client: {e}"));
558 return ExitCode::NetworkError;
559 }
560 };
561
562 let src_display = format!("{}/{}/{}", src.alias, src.bucket, src.key);
563 let dst_display = format!("{}/{}/{}", dst.alias, dst.bucket, dst.key);
564
565 if args.dry_run {
566 let styled_src = formatter.style_file(&src_display);
567 let styled_dst = formatter.style_file(&dst_display);
568 formatter.println(&format!("Would copy: {styled_src} -> {styled_dst}"));
569 return ExitCode::Success;
570 }
571
572 match client.copy_object(src, dst).await {
573 Ok(info) => {
574 if formatter.is_json() {
575 let output = CpOutput {
576 status: "success",
577 source: src_display,
578 target: dst_display,
579 size_bytes: info.size_bytes,
580 size_human: info.size_human,
581 };
582 formatter.json(&output);
583 } else {
584 let styled_src = formatter.style_file(&src_display);
585 let styled_dst = formatter.style_file(&dst_display);
586 let styled_size = formatter.style_size(&info.size_human.unwrap_or_default());
587 formatter.println(&format!("{styled_src} -> {styled_dst} ({styled_size})"));
588 }
589 ExitCode::Success
590 }
591 Err(e) => {
592 let err_str = e.to_string();
593 if err_str.contains("NotFound") || err_str.contains("NoSuchKey") {
594 formatter.error(&format!("Source not found: {src_display}"));
595 ExitCode::NotFound
596 } else {
597 formatter.error(&format!("Failed to copy: {e}"));
598 ExitCode::NetworkError
599 }
600 }
601 }
602}
603
604#[cfg(test)]
605mod tests {
606 use super::*;
607 use rc_core::{Alias, ConfigManager};
608 use tempfile::TempDir;
609
610 fn temp_alias_manager() -> (AliasManager, TempDir) {
611 let temp_dir = TempDir::new().expect("create temp dir");
612 let config_path = temp_dir.path().join("config.toml");
613 let config_manager = ConfigManager::with_path(config_path);
614 let alias_manager = AliasManager::with_config_manager(config_manager);
615 (alias_manager, temp_dir)
616 }
617
618 #[test]
619 fn test_parse_local_path() {
620 let result = parse_path("./file.txt").unwrap();
621 assert!(matches!(result, ParsedPath::Local(_)));
622 }
623
624 #[test]
625 fn test_parse_remote_path() {
626 let result = parse_path("myalias/bucket/file.txt").unwrap();
627 assert!(matches!(result, ParsedPath::Remote(_)));
628 }
629
630 #[test]
631 fn test_parse_local_absolute_path() {
632 #[cfg(unix)]
634 let path = "/home/user/file.txt";
635 #[cfg(windows)]
636 let path = "C:\\Users\\user\\file.txt";
637
638 let result = parse_path(path).unwrap();
639 assert!(matches!(result, ParsedPath::Local(_)));
640 if let ParsedPath::Local(p) = result {
641 assert!(p.is_absolute());
642 }
643 }
644
645 #[test]
646 fn test_parse_local_relative_path() {
647 let result = parse_path("../file.txt").unwrap();
648 assert!(matches!(result, ParsedPath::Local(_)));
649 }
650
651 #[test]
652 fn test_parse_remote_path_bucket_only() {
653 let result = parse_path("myalias/bucket/").unwrap();
654 assert!(matches!(result, ParsedPath::Remote(_)));
655 if let ParsedPath::Remote(r) = result {
656 assert_eq!(r.alias, "myalias");
657 assert_eq!(r.bucket, "bucket");
658 assert!(r.key.is_empty());
659 }
660 }
661
662 #[test]
663 fn test_parse_remote_path_with_deep_key() {
664 let result = parse_path("myalias/bucket/dir1/dir2/file.txt").unwrap();
665 assert!(matches!(result, ParsedPath::Remote(_)));
666 if let ParsedPath::Remote(r) = result {
667 assert_eq!(r.alias, "myalias");
668 assert_eq!(r.bucket, "bucket");
669 assert_eq!(r.key, "dir1/dir2/file.txt");
670 }
671 }
672
673 #[test]
674 fn test_parse_cp_path_prefers_existing_local_path_when_alias_missing() {
675 let (alias_manager, temp_dir) = temp_alias_manager();
676 let full = temp_dir.path().join("issue-2094-local").join("file.txt");
677 let full_str = full.to_string_lossy().to_string();
678
679 if let Some(parent) = full.parent() {
680 std::fs::create_dir_all(parent).expect("create parent dirs");
681 }
682 std::fs::write(&full, b"test").expect("write local file");
683
684 let parsed = parse_cp_path(&full_str, Some(&alias_manager)).expect("parse path");
685 assert!(matches!(parsed, ParsedPath::Local(_)));
686 }
687
688 #[test]
689 fn test_parse_cp_path_keeps_remote_when_alias_exists() {
690 let (alias_manager, _temp_dir) = temp_alias_manager();
691 alias_manager
692 .set(Alias::new("target", "http://localhost:9000", "a", "b"))
693 .expect("set alias");
694
695 let parsed = parse_cp_path("target/bucket/file.txt", Some(&alias_manager))
696 .expect("parse remote path");
697 assert!(matches!(parsed, ParsedPath::Remote(_)));
698 }
699
700 #[test]
701 fn test_parse_cp_path_keeps_remote_when_local_missing() {
702 let (alias_manager, _temp_dir) = temp_alias_manager();
703 let parsed = parse_cp_path("missing/bucket/file.txt", Some(&alias_manager))
704 .expect("parse remote path");
705 assert!(matches!(parsed, ParsedPath::Remote(_)));
706 }
707
708 #[test]
709 fn test_cp_args_defaults() {
710 let args = CpArgs {
711 source: "src".to_string(),
712 target: "dst".to_string(),
713 recursive: false,
714 preserve: false,
715 continue_on_error: false,
716 overwrite: true,
717 dry_run: false,
718 storage_class: None,
719 content_type: None,
720 };
721 assert!(args.overwrite);
722 assert!(!args.recursive);
723 assert!(!args.dry_run);
724 }
725
726 #[test]
727 fn test_cp_output_serialization() {
728 let output = CpOutput {
729 status: "success",
730 source: "src/file.txt".to_string(),
731 target: "dst/file.txt".to_string(),
732 size_bytes: Some(1024),
733 size_human: Some("1 KiB".to_string()),
734 };
735 let json = serde_json::to_string(&output).unwrap();
736 assert!(json.contains("\"status\":\"success\""));
737 assert!(json.contains("\"size_bytes\":1024"));
738 }
739
740 #[test]
741 fn test_cp_output_skips_none_fields() {
742 let output = CpOutput {
743 status: "success",
744 source: "src".to_string(),
745 target: "dst".to_string(),
746 size_bytes: None,
747 size_human: None,
748 };
749 let json = serde_json::to_string(&output).unwrap();
750 assert!(!json.contains("size_bytes"));
751 assert!(!json.contains("size_human"));
752 }
753}