1use clap::Args;
6use rc_core::{AliasManager, ObjectStore as _, ParsedPath, RemotePath, parse_path};
7use rc_s3::S3Client;
8use serde::Serialize;
9use std::path::Path;
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
67 let source = match parse_path(&args.source) {
69 Ok(p) => p,
70 Err(e) => {
71 formatter.error(&format!("Invalid source path: {e}"));
72 return ExitCode::UsageError;
73 }
74 };
75
76 let target = match parse_path(&args.target) {
77 Ok(p) => p,
78 Err(e) => {
79 formatter.error(&format!("Invalid target path: {e}"));
80 return ExitCode::UsageError;
81 }
82 };
83
84 match (&source, &target) {
86 (ParsedPath::Local(src), ParsedPath::Remote(dst)) => {
87 copy_local_to_s3(src, dst, &args, &formatter).await
89 }
90 (ParsedPath::Remote(src), ParsedPath::Local(dst)) => {
91 copy_s3_to_local(src, dst, &args, &formatter).await
93 }
94 (ParsedPath::Remote(src), ParsedPath::Remote(dst)) => {
95 copy_s3_to_s3(src, dst, &args, &formatter).await
97 }
98 (ParsedPath::Local(_), ParsedPath::Local(_)) => {
99 formatter.error("Cannot copy between two local paths. Use system cp command.");
100 ExitCode::UsageError
101 }
102 }
103}
104
105async fn copy_local_to_s3(
106 src: &Path,
107 dst: &RemotePath,
108 args: &CpArgs,
109 formatter: &Formatter,
110) -> ExitCode {
111 if !src.exists() {
113 formatter.error(&format!("Source not found: {}", src.display()));
114 return ExitCode::NotFound;
115 }
116
117 if src.is_dir() && !args.recursive {
119 formatter.error("Source is a directory. Use -r/--recursive to copy directories.");
120 return ExitCode::UsageError;
121 }
122
123 let alias_manager = match AliasManager::new() {
125 Ok(am) => am,
126 Err(e) => {
127 formatter.error(&format!("Failed to load aliases: {e}"));
128 return ExitCode::GeneralError;
129 }
130 };
131
132 let alias = match alias_manager.get(&dst.alias) {
133 Ok(a) => a,
134 Err(_) => {
135 formatter.error(&format!("Alias '{}' not found", dst.alias));
136 return ExitCode::NotFound;
137 }
138 };
139
140 let client = match S3Client::new(alias).await {
141 Ok(c) => c,
142 Err(e) => {
143 formatter.error(&format!("Failed to create S3 client: {e}"));
144 return ExitCode::NetworkError;
145 }
146 };
147
148 if src.is_file() {
149 upload_file(&client, src, dst, args, formatter).await
151 } else {
152 upload_directory(&client, src, dst, args, formatter).await
154 }
155}
156
157async fn upload_file(
158 client: &S3Client,
159 src: &Path,
160 dst: &RemotePath,
161 args: &CpArgs,
162 formatter: &Formatter,
163) -> ExitCode {
164 let dst_key = if dst.key.is_empty() || dst.key.ends_with('/') {
166 let filename = src.file_name().unwrap_or_default().to_string_lossy();
168 format!("{}{}", dst.key, filename)
169 } else {
170 dst.key.clone()
171 };
172
173 let target = RemotePath::new(&dst.alias, &dst.bucket, &dst_key);
174 let src_display = src.display().to_string();
175 let dst_display = format!("{}/{}/{}", dst.alias, dst.bucket, dst_key);
176
177 if args.dry_run {
178 let styled_src = formatter.style_file(&src_display);
179 let styled_dst = formatter.style_file(&dst_display);
180 formatter.println(&format!("Would copy: {styled_src} -> {styled_dst}"));
181 return ExitCode::Success;
182 }
183
184 let data = match std::fs::read(src) {
186 Ok(d) => d,
187 Err(e) => {
188 formatter.error(&format!("Failed to read {src_display}: {e}"));
189 return ExitCode::GeneralError;
190 }
191 };
192
193 let size = data.len() as i64;
194
195 let guessed_type: Option<String> = mime_guess::from_path(src)
197 .first()
198 .map(|m| m.essence_str().to_string());
199 let content_type = args.content_type.as_deref().or(guessed_type.as_deref());
200
201 match client.put_object(&target, data, content_type).await {
203 Ok(info) => {
204 if formatter.is_json() {
205 let output = CpOutput {
206 status: "success",
207 source: src_display,
208 target: dst_display,
209 size_bytes: Some(size),
210 size_human: Some(humansize::format_size(size as u64, humansize::BINARY)),
211 };
212 formatter.json(&output);
213 } else {
214 let styled_src = formatter.style_file(&src_display);
215 let styled_dst = formatter.style_file(&dst_display);
216 let styled_size = formatter.style_size(&info.size_human.unwrap_or_default());
217 formatter.println(&format!("{styled_src} -> {styled_dst} ({styled_size})"));
218 }
219 ExitCode::Success
220 }
221 Err(e) => {
222 formatter.error(&format!("Failed to upload {src_display}: {e}"));
223 ExitCode::NetworkError
224 }
225 }
226}
227
228async fn upload_directory(
229 client: &S3Client,
230 src: &Path,
231 dst: &RemotePath,
232 args: &CpArgs,
233 formatter: &Formatter,
234) -> ExitCode {
235 use std::fs;
236
237 let mut success_count = 0;
238 let mut error_count = 0;
239
240 fn walk_dir(dir: &Path, base: &Path) -> std::io::Result<Vec<(std::path::PathBuf, String)>> {
242 let mut files = Vec::new();
243 for entry in fs::read_dir(dir)? {
244 let entry = entry?;
245 let path = entry.path();
246 if path.is_file() {
247 let relative = path.strip_prefix(base).unwrap_or(&path);
248 let relative_str = relative.to_string_lossy().to_string();
249 files.push((path, relative_str));
250 } else if path.is_dir() {
251 files.extend(walk_dir(&path, base)?);
252 }
253 }
254 Ok(files)
255 }
256
257 let files = match walk_dir(src, src) {
258 Ok(f) => f,
259 Err(e) => {
260 formatter.error(&format!("Failed to read directory: {e}"));
261 return ExitCode::GeneralError;
262 }
263 };
264
265 for (file_path, relative_path) in files {
266 let dst_key = if dst.key.is_empty() {
268 relative_path.replace('\\', "/")
269 } else if dst.key.ends_with('/') {
270 format!("{}{}", dst.key, relative_path.replace('\\', "/"))
271 } else {
272 format!("{}/{}", dst.key, relative_path.replace('\\', "/"))
273 };
274
275 let target = RemotePath::new(&dst.alias, &dst.bucket, &dst_key);
276
277 let result = upload_file(client, &file_path, &target, args, formatter).await;
278
279 if result == ExitCode::Success {
280 success_count += 1;
281 } else {
282 error_count += 1;
283 if !args.continue_on_error {
284 return result;
285 }
286 }
287 }
288
289 if error_count > 0 {
290 formatter.warning(&format!(
291 "Completed with errors: {success_count} succeeded, {error_count} failed"
292 ));
293 ExitCode::GeneralError
294 } else {
295 if !formatter.is_json() {
296 formatter.success(&format!("Uploaded {success_count} file(s)."));
297 }
298 ExitCode::Success
299 }
300}
301
302async fn copy_s3_to_local(
303 src: &RemotePath,
304 dst: &Path,
305 args: &CpArgs,
306 formatter: &Formatter,
307) -> ExitCode {
308 let alias_manager = match AliasManager::new() {
310 Ok(am) => am,
311 Err(e) => {
312 formatter.error(&format!("Failed to load aliases: {e}"));
313 return ExitCode::GeneralError;
314 }
315 };
316
317 let alias = match alias_manager.get(&src.alias) {
318 Ok(a) => a,
319 Err(_) => {
320 formatter.error(&format!("Alias '{}' not found", src.alias));
321 return ExitCode::NotFound;
322 }
323 };
324
325 let client = match S3Client::new(alias).await {
326 Ok(c) => c,
327 Err(e) => {
328 formatter.error(&format!("Failed to create S3 client: {e}"));
329 return ExitCode::NetworkError;
330 }
331 };
332
333 let is_prefix = src.key.is_empty() || src.key.ends_with('/');
335
336 if is_prefix || args.recursive {
337 download_prefix(&client, src, dst, args, formatter).await
339 } else {
340 download_file(&client, src, dst, args, formatter).await
342 }
343}
344
345async fn download_file(
346 client: &S3Client,
347 src: &RemotePath,
348 dst: &Path,
349 args: &CpArgs,
350 formatter: &Formatter,
351) -> ExitCode {
352 let src_display = format!("{}/{}/{}", src.alias, src.bucket, src.key);
353
354 let dst_path = if dst.is_dir() || dst.to_string_lossy().ends_with('/') {
356 let filename = src.key.rsplit('/').next().unwrap_or(&src.key);
357 dst.join(filename)
358 } else {
359 dst.to_path_buf()
360 };
361
362 let dst_display = dst_path.display().to_string();
363
364 if args.dry_run {
365 let styled_src = formatter.style_file(&src_display);
366 let styled_dst = formatter.style_file(&dst_display);
367 formatter.println(&format!("Would copy: {styled_src} -> {styled_dst}"));
368 return ExitCode::Success;
369 }
370
371 if dst_path.exists() && !args.overwrite {
373 formatter.error(&format!(
374 "Destination exists: {dst_display}. Use --overwrite to replace."
375 ));
376 return ExitCode::Conflict;
377 }
378
379 if let Some(parent) = dst_path.parent()
381 && !parent.exists()
382 && let Err(e) = std::fs::create_dir_all(parent)
383 {
384 formatter.error(&format!("Failed to create directory: {e}"));
385 return ExitCode::GeneralError;
386 }
387
388 match client.get_object(src).await {
390 Ok(data) => {
391 let size = data.len() as i64;
392
393 if let Err(e) = std::fs::write(&dst_path, &data) {
394 formatter.error(&format!("Failed to write {dst_display}: {e}"));
395 return ExitCode::GeneralError;
396 }
397
398 if formatter.is_json() {
399 let output = CpOutput {
400 status: "success",
401 source: src_display,
402 target: dst_display,
403 size_bytes: Some(size),
404 size_human: Some(humansize::format_size(size as u64, humansize::BINARY)),
405 };
406 formatter.json(&output);
407 } else {
408 let styled_src = formatter.style_file(&src_display);
409 let styled_dst = formatter.style_file(&dst_display);
410 let styled_size =
411 formatter.style_size(&humansize::format_size(size as u64, humansize::BINARY));
412 formatter.println(&format!("{styled_src} -> {styled_dst} ({styled_size})"));
413 }
414 ExitCode::Success
415 }
416 Err(e) => {
417 let err_str = e.to_string();
418 if err_str.contains("NotFound") || err_str.contains("NoSuchKey") {
419 formatter.error(&format!("Object not found: {src_display}"));
420 ExitCode::NotFound
421 } else {
422 formatter.error(&format!("Failed to download {src_display}: {e}"));
423 ExitCode::NetworkError
424 }
425 }
426 }
427}
428
429async fn download_prefix(
430 client: &S3Client,
431 src: &RemotePath,
432 dst: &Path,
433 args: &CpArgs,
434 formatter: &Formatter,
435) -> ExitCode {
436 use rc_core::ListOptions;
437
438 let mut success_count = 0;
439 let mut error_count = 0;
440 let mut continuation_token: Option<String> = None;
441
442 loop {
443 let options = ListOptions {
444 recursive: true,
445 max_keys: Some(1000),
446 continuation_token: continuation_token.clone(),
447 ..Default::default()
448 };
449
450 match client.list_objects(src, options).await {
451 Ok(result) => {
452 for item in result.items {
453 if item.is_dir {
454 continue;
455 }
456
457 let relative_key = item.key.strip_prefix(&src.key).unwrap_or(&item.key);
459 let dst_path =
460 dst.join(relative_key.replace('/', std::path::MAIN_SEPARATOR_STR));
461
462 let obj_src = RemotePath::new(&src.alias, &src.bucket, &item.key);
463 let result = download_file(client, &obj_src, &dst_path, args, formatter).await;
464
465 if result == ExitCode::Success {
466 success_count += 1;
467 } else {
468 error_count += 1;
469 if !args.continue_on_error {
470 return result;
471 }
472 }
473 }
474
475 if result.truncated {
476 continuation_token = result.continuation_token;
477 } else {
478 break;
479 }
480 }
481 Err(e) => {
482 formatter.error(&format!("Failed to list objects: {e}"));
483 return ExitCode::NetworkError;
484 }
485 }
486 }
487
488 if error_count > 0 {
489 formatter.warning(&format!(
490 "Completed with errors: {success_count} succeeded, {error_count} failed"
491 ));
492 ExitCode::GeneralError
493 } else if success_count == 0 {
494 formatter.warning("No objects found to download.");
495 ExitCode::Success
496 } else {
497 if !formatter.is_json() {
498 formatter.success(&format!("Downloaded {success_count} file(s)."));
499 }
500 ExitCode::Success
501 }
502}
503
504async fn copy_s3_to_s3(
505 src: &RemotePath,
506 dst: &RemotePath,
507 args: &CpArgs,
508 formatter: &Formatter,
509) -> ExitCode {
510 let alias_manager = match AliasManager::new() {
512 Ok(am) => am,
513 Err(e) => {
514 formatter.error(&format!("Failed to load aliases: {e}"));
515 return ExitCode::GeneralError;
516 }
517 };
518
519 if src.alias != dst.alias {
521 formatter.error("Cross-alias S3-to-S3 copy not yet supported. Use download + upload.");
522 return ExitCode::UnsupportedFeature;
523 }
524
525 let alias = match alias_manager.get(&src.alias) {
526 Ok(a) => a,
527 Err(_) => {
528 formatter.error(&format!("Alias '{}' not found", src.alias));
529 return ExitCode::NotFound;
530 }
531 };
532
533 let client = match S3Client::new(alias).await {
534 Ok(c) => c,
535 Err(e) => {
536 formatter.error(&format!("Failed to create S3 client: {e}"));
537 return ExitCode::NetworkError;
538 }
539 };
540
541 let src_display = format!("{}/{}/{}", src.alias, src.bucket, src.key);
542 let dst_display = format!("{}/{}/{}", dst.alias, dst.bucket, dst.key);
543
544 if args.dry_run {
545 let styled_src = formatter.style_file(&src_display);
546 let styled_dst = formatter.style_file(&dst_display);
547 formatter.println(&format!("Would copy: {styled_src} -> {styled_dst}"));
548 return ExitCode::Success;
549 }
550
551 match client.copy_object(src, dst).await {
552 Ok(info) => {
553 if formatter.is_json() {
554 let output = CpOutput {
555 status: "success",
556 source: src_display,
557 target: dst_display,
558 size_bytes: info.size_bytes,
559 size_human: info.size_human,
560 };
561 formatter.json(&output);
562 } else {
563 let styled_src = formatter.style_file(&src_display);
564 let styled_dst = formatter.style_file(&dst_display);
565 let styled_size = formatter.style_size(&info.size_human.unwrap_or_default());
566 formatter.println(&format!("{styled_src} -> {styled_dst} ({styled_size})"));
567 }
568 ExitCode::Success
569 }
570 Err(e) => {
571 let err_str = e.to_string();
572 if err_str.contains("NotFound") || err_str.contains("NoSuchKey") {
573 formatter.error(&format!("Source not found: {src_display}"));
574 ExitCode::NotFound
575 } else {
576 formatter.error(&format!("Failed to copy: {e}"));
577 ExitCode::NetworkError
578 }
579 }
580 }
581}
582
583#[cfg(test)]
584mod tests {
585 use super::*;
586
587 #[test]
588 fn test_parse_local_path() {
589 let result = parse_path("./file.txt").unwrap();
590 assert!(matches!(result, ParsedPath::Local(_)));
591 }
592
593 #[test]
594 fn test_parse_remote_path() {
595 let result = parse_path("myalias/bucket/file.txt").unwrap();
596 assert!(matches!(result, ParsedPath::Remote(_)));
597 }
598
599 #[test]
600 fn test_parse_local_absolute_path() {
601 #[cfg(unix)]
603 let path = "/home/user/file.txt";
604 #[cfg(windows)]
605 let path = "C:\\Users\\user\\file.txt";
606
607 let result = parse_path(path).unwrap();
608 assert!(matches!(result, ParsedPath::Local(_)));
609 if let ParsedPath::Local(p) = result {
610 assert!(p.is_absolute());
611 }
612 }
613
614 #[test]
615 fn test_parse_local_relative_path() {
616 let result = parse_path("../file.txt").unwrap();
617 assert!(matches!(result, ParsedPath::Local(_)));
618 }
619
620 #[test]
621 fn test_parse_remote_path_bucket_only() {
622 let result = parse_path("myalias/bucket/").unwrap();
623 assert!(matches!(result, ParsedPath::Remote(_)));
624 if let ParsedPath::Remote(r) = result {
625 assert_eq!(r.alias, "myalias");
626 assert_eq!(r.bucket, "bucket");
627 assert!(r.key.is_empty());
628 }
629 }
630
631 #[test]
632 fn test_parse_remote_path_with_deep_key() {
633 let result = parse_path("myalias/bucket/dir1/dir2/file.txt").unwrap();
634 assert!(matches!(result, ParsedPath::Remote(_)));
635 if let ParsedPath::Remote(r) = result {
636 assert_eq!(r.alias, "myalias");
637 assert_eq!(r.bucket, "bucket");
638 assert_eq!(r.key, "dir1/dir2/file.txt");
639 }
640 }
641
642 #[test]
643 fn test_cp_args_defaults() {
644 let args = CpArgs {
645 source: "src".to_string(),
646 target: "dst".to_string(),
647 recursive: false,
648 preserve: false,
649 continue_on_error: false,
650 overwrite: true,
651 dry_run: false,
652 storage_class: None,
653 content_type: None,
654 };
655 assert!(args.overwrite);
656 assert!(!args.recursive);
657 assert!(!args.dry_run);
658 }
659
660 #[test]
661 fn test_cp_output_serialization() {
662 let output = CpOutput {
663 status: "success",
664 source: "src/file.txt".to_string(),
665 target: "dst/file.txt".to_string(),
666 size_bytes: Some(1024),
667 size_human: Some("1 KiB".to_string()),
668 };
669 let json = serde_json::to_string(&output).unwrap();
670 assert!(json.contains("\"status\":\"success\""));
671 assert!(json.contains("\"size_bytes\":1024"));
672 }
673
674 #[test]
675 fn test_cp_output_skips_none_fields() {
676 let output = CpOutput {
677 status: "success",
678 source: "src".to_string(),
679 target: "dst".to_string(),
680 size_bytes: None,
681 size_human: None,
682 };
683 let json = serde_json::to_string(&output).unwrap();
684 assert!(!json.contains("size_bytes"));
685 assert!(!json.contains("size_human"));
686 }
687}