rustfs_cli/commands/
cp.rs

1//! cp command - Copy objects
2//!
3//! Copies objects between local filesystem and S3, or between S3 locations.
4
5use 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/// Copy objects
15#[derive(Args, Debug)]
16pub struct CpArgs {
17    /// Source path (local path or alias/bucket/key)
18    pub source: String,
19
20    /// Destination path (local path or alias/bucket/key)
21    pub target: String,
22
23    /// Copy recursively
24    #[arg(short, long)]
25    pub recursive: bool,
26
27    /// Preserve file attributes
28    #[arg(short, long)]
29    pub preserve: bool,
30
31    /// Continue on errors
32    #[arg(long)]
33    pub continue_on_error: bool,
34
35    /// Overwrite destination if it exists
36    #[arg(long, default_value = "true")]
37    pub overwrite: bool,
38
39    /// Only show what would be copied (dry run)
40    #[arg(long)]
41    pub dry_run: bool,
42
43    /// Storage class for destination (S3 only)
44    #[arg(long)]
45    pub storage_class: Option<String>,
46
47    /// Content type for uploaded files
48    #[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
63/// Execute the cp command
64pub async fn execute(args: CpArgs, output_config: OutputConfig) -> ExitCode {
65    let formatter = Formatter::new(output_config);
66
67    // Parse source and target paths
68    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    // Determine copy direction
85    match (&source, &target) {
86        (ParsedPath::Local(src), ParsedPath::Remote(dst)) => {
87            // Local to S3
88            copy_local_to_s3(src, dst, &args, &formatter).await
89        }
90        (ParsedPath::Remote(src), ParsedPath::Local(dst)) => {
91            // S3 to Local
92            copy_s3_to_local(src, dst, &args, &formatter).await
93        }
94        (ParsedPath::Remote(src), ParsedPath::Remote(dst)) => {
95            // S3 to S3
96            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    // Check if source exists
112    if !src.exists() {
113        formatter.error(&format!("Source not found: {}", src.display()));
114        return ExitCode::NotFound;
115    }
116
117    // If source is a directory, require recursive flag
118    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    // Load alias and create client
124    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        // Single file upload
150        upload_file(&client, src, dst, args, formatter).await
151    } else {
152        // Directory upload
153        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    // Determine destination key
165    let dst_key = if dst.key.is_empty() || dst.key.ends_with('/') {
166        // If destination is a directory, use source filename
167        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    // Read file content
185    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    // Determine content type
196    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    // Upload
202    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    // Walk directory
241    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        // Build destination key
267        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    // Load alias and create client
309    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    // Check if source is a prefix (directory-like)
334    let is_prefix = src.key.is_empty() || src.key.ends_with('/');
335
336    if is_prefix || args.recursive {
337        // Download multiple objects
338        download_prefix(&client, src, dst, args, formatter).await
339    } else {
340        // Download single object
341        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    // Determine destination path
355    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    // Check if destination exists
372    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    // Create parent directories
380    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    // Download object
389    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                    // Calculate relative path from prefix
458                    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    // For S3-to-S3, we need to handle same or different aliases
511    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    // For now, only support same-alias copies (server-side copy)
520    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        // Use platform-appropriate absolute path
602        #[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}