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