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        formatter.println(&format!("Would copy: {src_display} -> {dst_display}"));
179        return ExitCode::Success;
180    }
181
182    // Read file content
183    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    // Determine content type
194    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    // Upload
200    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    // Walk directory
239    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        // Build destination key
265        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    // Load alias and create client
307    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    // Check if source is a prefix (directory-like)
332    let is_prefix = src.key.is_empty() || src.key.ends_with('/');
333
334    if is_prefix || args.recursive {
335        // Download multiple objects
336        download_prefix(&client, src, dst, args, formatter).await
337    } else {
338        // Download single object
339        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    // Determine destination path
353    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    // Check if destination exists
368    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    // Create parent directories
376    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    // Download object
385    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                    // Calculate relative path from prefix
453                    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    // For S3-to-S3, we need to handle same or different aliases
506    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    // For now, only support same-alias copies (server-side copy)
515    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        // Use platform-appropriate absolute path
595        #[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}