Skip to main content

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, PathBuf};
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    let alias_manager = AliasManager::new().ok();
67
68    // Parse source and target paths
69    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    // Determine copy direction
86    match (&source, &target) {
87        (ParsedPath::Local(src), ParsedPath::Remote(dst)) => {
88            // Local to S3
89            copy_local_to_s3(src, dst, &args, &formatter).await
90        }
91        (ParsedPath::Remote(src), ParsedPath::Local(dst)) => {
92            // S3 to Local
93            copy_s3_to_local(src, dst, &args, &formatter).await
94        }
95        (ParsedPath::Remote(src), ParsedPath::Remote(dst)) => {
96            // S3 to S3
97            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    // Check if source exists
133    if !src.exists() {
134        formatter.error(&format!("Source not found: {}", src.display()));
135        return ExitCode::NotFound;
136    }
137
138    // If source is a directory, require recursive flag
139    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    // Load alias and create client
145    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        // Single file upload
171        upload_file(&client, src, dst, args, formatter).await
172    } else {
173        // Directory upload
174        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    // Determine destination key
186    let dst_key = if dst.key.is_empty() || dst.key.ends_with('/') {
187        // If destination is a directory, use source filename
188        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    // Read file content
206    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    // Determine content type
217    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    // Upload
223    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    // Walk directory
262    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        // Build destination key
288        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    // Load alias and create client
330    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    // Check if source is a prefix (directory-like)
355    let is_prefix = src.key.is_empty() || src.key.ends_with('/');
356
357    if is_prefix || args.recursive {
358        // Download multiple objects
359        download_prefix(&client, src, dst, args, formatter).await
360    } else {
361        // Download single object
362        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    // Determine destination path
376    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    // Check if destination exists
393    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    // Create parent directories
401    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    // Download object
410    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                    // Calculate relative path from prefix
479                    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    // For S3-to-S3, we need to handle same or different aliases
532    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    // For now, only support same-alias copies (server-side copy)
541    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        // Use platform-appropriate absolute path
633        #[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}