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, ProgressBar};
13
14const CP_AFTER_HELP: &str = "\
15Examples:
16  rc object copy ./report.json local/my-bucket/reports/
17  rc cp ./report.json local/my-bucket/reports/
18  rc object copy local/source-bucket/archive.tar.gz ./downloads/archive.tar.gz";
19
20const REMOTE_PATH_SUGGESTION: &str =
21    "Use a local filesystem path or a remote path in the form alias/bucket[/key].";
22
23/// Copy objects
24#[derive(Args, Debug)]
25#[command(after_help = CP_AFTER_HELP)]
26pub struct CpArgs {
27    /// Source path (local path or alias/bucket/key)
28    pub source: String,
29
30    /// Destination path (local path or alias/bucket/key)
31    pub target: String,
32
33    /// Copy recursively
34    #[arg(short, long)]
35    pub recursive: bool,
36
37    /// Preserve file attributes
38    #[arg(short, long)]
39    pub preserve: bool,
40
41    /// Continue on errors
42    #[arg(long)]
43    pub continue_on_error: bool,
44
45    /// Overwrite destination if it exists
46    #[arg(long, default_value = "true")]
47    pub overwrite: bool,
48
49    /// Only show what would be copied (dry run)
50    #[arg(long)]
51    pub dry_run: bool,
52
53    /// Storage class for destination (S3 only)
54    #[arg(long)]
55    pub storage_class: Option<String>,
56
57    /// Content type for uploaded files
58    #[arg(long)]
59    pub content_type: Option<String>,
60}
61
62#[derive(Debug, Serialize)]
63struct CpOutput {
64    status: &'static str,
65    source: String,
66    target: String,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    size_bytes: Option<i64>,
69    #[serde(skip_serializing_if = "Option::is_none")]
70    size_human: Option<String>,
71}
72
73/// Execute the cp command
74pub async fn execute(args: CpArgs, output_config: OutputConfig) -> ExitCode {
75    let formatter = Formatter::new(output_config);
76    let alias_manager = AliasManager::new().ok();
77
78    // Parse source and target paths
79    let source = match parse_cp_path(&args.source, alias_manager.as_ref()) {
80        Ok(p) => p,
81        Err(e) => {
82            return formatter.fail_with_suggestion(
83                ExitCode::UsageError,
84                &format!("Invalid source path: {e}"),
85                REMOTE_PATH_SUGGESTION,
86            );
87        }
88    };
89
90    let target = match parse_cp_path(&args.target, alias_manager.as_ref()) {
91        Ok(p) => p,
92        Err(e) => {
93            return formatter.fail_with_suggestion(
94                ExitCode::UsageError,
95                &format!("Invalid target path: {e}"),
96                REMOTE_PATH_SUGGESTION,
97            );
98        }
99    };
100
101    // Determine copy direction
102    match (&source, &target) {
103        (ParsedPath::Local(src), ParsedPath::Remote(dst)) => {
104            // Local to S3
105            copy_local_to_s3(src, dst, &args, &formatter).await
106        }
107        (ParsedPath::Remote(src), ParsedPath::Local(dst)) => {
108            // S3 to Local
109            copy_s3_to_local(src, dst, &args, &formatter).await
110        }
111        (ParsedPath::Remote(src), ParsedPath::Remote(dst)) => {
112            // S3 to S3
113            copy_s3_to_s3(src, dst, &args, &formatter).await
114        }
115        (ParsedPath::Local(_), ParsedPath::Local(_)) => formatter.fail_with_suggestion(
116            ExitCode::UsageError,
117            "Cannot copy between two local paths. Use system cp command.",
118            "Use your local shell cp command when both paths are on the filesystem.",
119        ),
120    }
121}
122
123fn parse_cp_path(path: &str, alias_manager: Option<&AliasManager>) -> rc_core::Result<ParsedPath> {
124    let parsed = parse_path(path)?;
125
126    let ParsedPath::Remote(remote) = &parsed else {
127        return Ok(parsed);
128    };
129
130    if let Some(manager) = alias_manager
131        && matches!(manager.exists(&remote.alias), Ok(true))
132    {
133        return Ok(parsed);
134    }
135
136    if Path::new(path).exists() {
137        return Ok(ParsedPath::Local(PathBuf::from(path)));
138    }
139
140    Ok(parsed)
141}
142
143async fn copy_local_to_s3(
144    src: &Path,
145    dst: &RemotePath,
146    args: &CpArgs,
147    formatter: &Formatter,
148) -> ExitCode {
149    // Check if source exists
150    if !src.exists() {
151        return formatter.fail_with_suggestion(
152            ExitCode::NotFound,
153            &format!("Source not found: {}", src.display()),
154            "Check the local source path and retry the copy command.",
155        );
156    }
157
158    // If source is a directory, require recursive flag
159    if src.is_dir() && !args.recursive {
160        return formatter.fail_with_suggestion(
161            ExitCode::UsageError,
162            "Source is a directory. Use -r/--recursive to copy directories.",
163            "Retry with -r or --recursive to copy a directory tree.",
164        );
165    }
166
167    // Load alias and create client
168    let alias_manager = match AliasManager::new() {
169        Ok(am) => am,
170        Err(e) => {
171            formatter.error(&format!("Failed to load aliases: {e}"));
172            return ExitCode::GeneralError;
173        }
174    };
175
176    let alias = match alias_manager.get(&dst.alias) {
177        Ok(a) => a,
178        Err(_) => {
179            return formatter.fail_with_suggestion(
180                ExitCode::NotFound,
181                &format!("Alias '{}' not found", dst.alias),
182                "Run `rc alias list` to inspect configured aliases or add one with `rc alias set ...`.",
183            );
184        }
185    };
186
187    let client = match S3Client::new(alias).await {
188        Ok(c) => c,
189        Err(e) => {
190            return formatter.fail(
191                ExitCode::NetworkError,
192                &format!("Failed to create S3 client: {e}"),
193            );
194        }
195    };
196
197    if src.is_file() {
198        // Single file upload
199        upload_file(&client, src, dst, args, formatter).await
200    } else {
201        // Directory upload
202        upload_directory(&client, src, dst, args, formatter).await
203    }
204}
205
206/// Multipart upload threshold: files at least this size use multipart upload (64 MiB)
207const MULTIPART_THRESHOLD: u64 = 64 * 1024 * 1024;
208
209fn print_upload_success(
210    formatter: &Formatter,
211    info: &rc_core::ObjectInfo,
212    src_display: &str,
213    dst_display: &str,
214) {
215    if formatter.is_json() {
216        let output = CpOutput {
217            status: "success",
218            source: src_display.to_string(),
219            target: dst_display.to_string(),
220            size_bytes: info.size_bytes,
221            size_human: info.size_human.clone(),
222        };
223        formatter.json(&output);
224    } else {
225        let styled_src = formatter.style_file(src_display);
226        let styled_dst = formatter.style_file(dst_display);
227        let styled_size = formatter.style_size(&info.size_human.clone().unwrap_or_default());
228        formatter.println(&format!("{styled_src} -> {styled_dst} ({styled_size})"));
229    }
230}
231
232async fn upload_file(
233    client: &S3Client,
234    src: &Path,
235    dst: &RemotePath,
236    args: &CpArgs,
237    formatter: &Formatter,
238) -> ExitCode {
239    // Determine destination key
240    let dst_key = if dst.key.is_empty() || dst.key.ends_with('/') {
241        // If destination is a directory, use source filename
242        let filename = src.file_name().unwrap_or_default().to_string_lossy();
243        format!("{}{}", dst.key, filename)
244    } else {
245        dst.key.clone()
246    };
247
248    let target = RemotePath::new(&dst.alias, &dst.bucket, &dst_key);
249    let src_display = src.display().to_string();
250    let dst_display = format!("{}/{}/{}", dst.alias, dst.bucket, dst_key);
251
252    if args.dry_run {
253        let styled_src = formatter.style_file(&src_display);
254        let styled_dst = formatter.style_file(&dst_display);
255        formatter.println(&format!("Would copy: {styled_src} -> {styled_dst}"));
256        return ExitCode::Success;
257    }
258
259    // Determine content type
260    let guessed_type: Option<String> = mime_guess::from_path(src)
261        .first()
262        .map(|m| m.essence_str().to_string());
263    let content_type = args.content_type.as_deref().or(guessed_type.as_deref());
264
265    // Get file size for progress bar decision
266    let file_size = match std::fs::metadata(src) {
267        Ok(m) => m.len(),
268        Err(e) => {
269            return formatter.fail(
270                ExitCode::GeneralError,
271                &format!("Failed to read {src_display}: {e}"),
272            );
273        }
274    };
275
276    // Show progress bar for large files
277    let progress = if file_size >= MULTIPART_THRESHOLD {
278        tracing::debug!(
279            file_size,
280            threshold = MULTIPART_THRESHOLD,
281            "Using multipart upload for large file"
282        );
283        Some(ProgressBar::new(formatter.output_config(), file_size))
284    } else {
285        tracing::debug!(file_size, "Using single put_object for small file");
286        None
287    };
288
289    // Upload
290    match client
291        .put_object_from_path(&target, src, content_type, |bytes_sent| {
292            if let Some(ref pb) = progress {
293                pb.set_position(bytes_sent);
294            }
295        })
296        .await
297    {
298        Ok(info) => {
299            if let Some(ref pb) = progress {
300                pb.finish_and_clear();
301            }
302            print_upload_success(formatter, &info, &src_display, &dst_display);
303            ExitCode::Success
304        }
305        Err(e) => {
306            if let Some(ref pb) = progress {
307                pb.finish_and_clear();
308            }
309            formatter.fail(
310                ExitCode::NetworkError,
311                &format!("Failed to upload {src_display}: {e}"),
312            )
313        }
314    }
315}
316
317async fn upload_directory(
318    client: &S3Client,
319    src: &Path,
320    dst: &RemotePath,
321    args: &CpArgs,
322    formatter: &Formatter,
323) -> ExitCode {
324    use std::fs;
325
326    let mut success_count = 0;
327    let mut error_count = 0;
328
329    // Walk directory
330    fn walk_dir(dir: &Path, base: &Path) -> std::io::Result<Vec<(std::path::PathBuf, String)>> {
331        let mut files = Vec::new();
332        for entry in fs::read_dir(dir)? {
333            let entry = entry?;
334            let path = entry.path();
335            if path.is_file() {
336                let relative = path.strip_prefix(base).unwrap_or(&path);
337                let relative_str = relative.to_string_lossy().to_string();
338                files.push((path, relative_str));
339            } else if path.is_dir() {
340                files.extend(walk_dir(&path, base)?);
341            }
342        }
343        Ok(files)
344    }
345
346    let files = match walk_dir(src, src) {
347        Ok(f) => f,
348        Err(e) => {
349            return formatter.fail(
350                ExitCode::GeneralError,
351                &format!("Failed to read directory: {e}"),
352            );
353        }
354    };
355
356    for (file_path, relative_path) in files {
357        // Build destination key
358        let dst_key = if dst.key.is_empty() {
359            relative_path.replace('\\', "/")
360        } else if dst.key.ends_with('/') {
361            format!("{}{}", dst.key, relative_path.replace('\\', "/"))
362        } else {
363            format!("{}/{}", dst.key, relative_path.replace('\\', "/"))
364        };
365
366        let target = RemotePath::new(&dst.alias, &dst.bucket, &dst_key);
367
368        let result = upload_file(client, &file_path, &target, args, formatter).await;
369
370        if result == ExitCode::Success {
371            success_count += 1;
372        } else {
373            error_count += 1;
374            if !args.continue_on_error {
375                return result;
376            }
377        }
378    }
379
380    if error_count > 0 {
381        formatter.warning(&format!(
382            "Completed with errors: {success_count} succeeded, {error_count} failed"
383        ));
384        ExitCode::GeneralError
385    } else {
386        if !formatter.is_json() {
387            formatter.success(&format!("Uploaded {success_count} file(s)."));
388        }
389        ExitCode::Success
390    }
391}
392
393async fn copy_s3_to_local(
394    src: &RemotePath,
395    dst: &Path,
396    args: &CpArgs,
397    formatter: &Formatter,
398) -> ExitCode {
399    // Load alias and create client
400    let alias_manager = match AliasManager::new() {
401        Ok(am) => am,
402        Err(e) => {
403            formatter.error(&format!("Failed to load aliases: {e}"));
404            return ExitCode::GeneralError;
405        }
406    };
407
408    let alias = match alias_manager.get(&src.alias) {
409        Ok(a) => a,
410        Err(_) => {
411            return formatter.fail_with_suggestion(
412                ExitCode::NotFound,
413                &format!("Alias '{}' not found", src.alias),
414                "Run `rc alias list` to inspect configured aliases or add one with `rc alias set ...`.",
415            );
416        }
417    };
418
419    let client = match S3Client::new(alias).await {
420        Ok(c) => c,
421        Err(e) => {
422            return formatter.fail(
423                ExitCode::NetworkError,
424                &format!("Failed to create S3 client: {e}"),
425            );
426        }
427    };
428
429    // Check if source is a prefix (directory-like)
430    let is_prefix = src.key.is_empty() || src.key.ends_with('/');
431
432    if is_prefix || args.recursive {
433        // Download multiple objects
434        download_prefix(&client, src, dst, args, formatter).await
435    } else {
436        // Download single object
437        download_file(&client, src, dst, args, formatter).await
438    }
439}
440
441async fn download_file(
442    client: &S3Client,
443    src: &RemotePath,
444    dst: &Path,
445    args: &CpArgs,
446    formatter: &Formatter,
447) -> ExitCode {
448    let src_display = format!("{}/{}/{}", src.alias, src.bucket, src.key);
449
450    // Determine destination path
451    let dst_path = if dst.is_dir() || dst.to_string_lossy().ends_with('/') {
452        let filename = src.key.rsplit('/').next().unwrap_or(&src.key);
453        dst.join(filename)
454    } else {
455        dst.to_path_buf()
456    };
457
458    let dst_display = dst_path.display().to_string();
459
460    if args.dry_run {
461        let styled_src = formatter.style_file(&src_display);
462        let styled_dst = formatter.style_file(&dst_display);
463        formatter.println(&format!("Would copy: {styled_src} -> {styled_dst}"));
464        return ExitCode::Success;
465    }
466
467    // Check if destination exists
468    if dst_path.exists() && !args.overwrite {
469        return formatter.fail_with_suggestion(
470            ExitCode::Conflict,
471            &format!("Destination exists: {dst_display}. Use --overwrite to replace."),
472            "Retry with --overwrite if replacing the destination file is intended.",
473        );
474    }
475
476    // Create parent directories
477    if let Some(parent) = dst_path.parent()
478        && !parent.exists()
479        && let Err(e) = std::fs::create_dir_all(parent)
480    {
481        return formatter.fail(
482            ExitCode::GeneralError,
483            &format!("Failed to create directory: {e}"),
484        );
485    }
486
487    // Download object
488    match client.get_object(src).await {
489        Ok(data) => {
490            let size = data.len() as i64;
491
492            if let Err(e) = std::fs::write(&dst_path, &data) {
493                return formatter.fail(
494                    ExitCode::GeneralError,
495                    &format!("Failed to write {dst_display}: {e}"),
496                );
497            }
498
499            if formatter.is_json() {
500                let output = CpOutput {
501                    status: "success",
502                    source: src_display,
503                    target: dst_display,
504                    size_bytes: Some(size),
505                    size_human: Some(humansize::format_size(size as u64, humansize::BINARY)),
506                };
507                formatter.json(&output);
508            } else {
509                let styled_src = formatter.style_file(&src_display);
510                let styled_dst = formatter.style_file(&dst_display);
511                let styled_size =
512                    formatter.style_size(&humansize::format_size(size as u64, humansize::BINARY));
513                formatter.println(&format!("{styled_src} -> {styled_dst} ({styled_size})"));
514            }
515            ExitCode::Success
516        }
517        Err(e) => {
518            let err_str = e.to_string();
519            if err_str.contains("NotFound") || err_str.contains("NoSuchKey") {
520                formatter.fail_with_suggestion(
521                    ExitCode::NotFound,
522                    &format!("Object not found: {src_display}"),
523                    "Check the object key and bucket path, then retry the copy command.",
524                )
525            } else {
526                formatter.fail(
527                    ExitCode::NetworkError,
528                    &format!("Failed to download {src_display}: {e}"),
529                )
530            }
531        }
532    }
533}
534
535async fn download_prefix(
536    client: &S3Client,
537    src: &RemotePath,
538    dst: &Path,
539    args: &CpArgs,
540    formatter: &Formatter,
541) -> ExitCode {
542    use rc_core::ListOptions;
543
544    let mut success_count = 0;
545    let mut error_count = 0;
546    let mut continuation_token: Option<String> = None;
547
548    loop {
549        let options = ListOptions {
550            recursive: true,
551            max_keys: Some(1000),
552            continuation_token: continuation_token.clone(),
553            ..Default::default()
554        };
555
556        match client.list_objects(src, options).await {
557            Ok(result) => {
558                for item in result.items {
559                    if item.is_dir {
560                        continue;
561                    }
562
563                    // Calculate relative path from prefix
564                    let relative_key = item.key.strip_prefix(&src.key).unwrap_or(&item.key);
565                    let dst_path =
566                        dst.join(relative_key.replace('/', std::path::MAIN_SEPARATOR_STR));
567
568                    let obj_src = RemotePath::new(&src.alias, &src.bucket, &item.key);
569                    let result = download_file(client, &obj_src, &dst_path, args, formatter).await;
570
571                    if result == ExitCode::Success {
572                        success_count += 1;
573                    } else {
574                        error_count += 1;
575                        if !args.continue_on_error {
576                            return result;
577                        }
578                    }
579                }
580
581                if result.truncated {
582                    continuation_token = result.continuation_token;
583                } else {
584                    break;
585                }
586            }
587            Err(e) => {
588                return formatter.fail(
589                    ExitCode::NetworkError,
590                    &format!("Failed to list objects: {e}"),
591                );
592            }
593        }
594    }
595
596    if error_count > 0 {
597        formatter.warning(&format!(
598            "Completed with errors: {success_count} succeeded, {error_count} failed"
599        ));
600        ExitCode::GeneralError
601    } else if success_count == 0 {
602        formatter.warning("No objects found to download.");
603        ExitCode::Success
604    } else {
605        if !formatter.is_json() {
606            formatter.success(&format!("Downloaded {success_count} file(s)."));
607        }
608        ExitCode::Success
609    }
610}
611
612async fn copy_s3_to_s3(
613    src: &RemotePath,
614    dst: &RemotePath,
615    args: &CpArgs,
616    formatter: &Formatter,
617) -> ExitCode {
618    // For S3-to-S3, we need to handle same or different aliases
619    let alias_manager = match AliasManager::new() {
620        Ok(am) => am,
621        Err(e) => {
622            formatter.error(&format!("Failed to load aliases: {e}"));
623            return ExitCode::GeneralError;
624        }
625    };
626
627    // For now, only support same-alias copies (server-side copy)
628    if src.alias != dst.alias {
629        return formatter.fail_with_suggestion(
630            ExitCode::UnsupportedFeature,
631            "Cross-alias S3-to-S3 copy not yet supported. Use download + upload.",
632            "Copy via a local path or split the operation into download and upload steps.",
633        );
634    }
635
636    let alias = match alias_manager.get(&src.alias) {
637        Ok(a) => a,
638        Err(_) => {
639            return formatter.fail_with_suggestion(
640                ExitCode::NotFound,
641                &format!("Alias '{}' not found", src.alias),
642                "Run `rc alias list` to inspect configured aliases or add one with `rc alias set ...`.",
643            );
644        }
645    };
646
647    let client = match S3Client::new(alias).await {
648        Ok(c) => c,
649        Err(e) => {
650            return formatter.fail(
651                ExitCode::NetworkError,
652                &format!("Failed to create S3 client: {e}"),
653            );
654        }
655    };
656
657    let src_display = format!("{}/{}/{}", src.alias, src.bucket, src.key);
658    let dst_display = format!("{}/{}/{}", dst.alias, dst.bucket, dst.key);
659
660    if args.dry_run {
661        let styled_src = formatter.style_file(&src_display);
662        let styled_dst = formatter.style_file(&dst_display);
663        formatter.println(&format!("Would copy: {styled_src} -> {styled_dst}"));
664        return ExitCode::Success;
665    }
666
667    match client.copy_object(src, dst).await {
668        Ok(info) => {
669            if formatter.is_json() {
670                let output = CpOutput {
671                    status: "success",
672                    source: src_display,
673                    target: dst_display,
674                    size_bytes: info.size_bytes,
675                    size_human: info.size_human,
676                };
677                formatter.json(&output);
678            } else {
679                let styled_src = formatter.style_file(&src_display);
680                let styled_dst = formatter.style_file(&dst_display);
681                let styled_size = formatter.style_size(&info.size_human.unwrap_or_default());
682                formatter.println(&format!("{styled_src} -> {styled_dst} ({styled_size})"));
683            }
684            ExitCode::Success
685        }
686        Err(e) => {
687            let err_str = e.to_string();
688            if err_str.contains("NotFound") || err_str.contains("NoSuchKey") {
689                formatter.fail_with_suggestion(
690                    ExitCode::NotFound,
691                    &format!("Source not found: {src_display}"),
692                    "Check the source bucket and object key, then retry the copy command.",
693                )
694            } else {
695                formatter.fail(ExitCode::NetworkError, &format!("Failed to copy: {e}"))
696            }
697        }
698    }
699}
700
701#[cfg(test)]
702mod tests {
703    use super::*;
704    use rc_core::{Alias, ConfigManager};
705    use tempfile::TempDir;
706
707    fn temp_alias_manager() -> (AliasManager, TempDir) {
708        let temp_dir = TempDir::new().expect("create temp dir");
709        let config_path = temp_dir.path().join("config.toml");
710        let config_manager = ConfigManager::with_path(config_path);
711        let alias_manager = AliasManager::with_config_manager(config_manager);
712        (alias_manager, temp_dir)
713    }
714
715    #[test]
716    fn test_parse_local_path() {
717        let result = parse_path("./file.txt").unwrap();
718        assert!(matches!(result, ParsedPath::Local(_)));
719    }
720
721    #[test]
722    fn test_parse_remote_path() {
723        let result = parse_path("myalias/bucket/file.txt").unwrap();
724        assert!(matches!(result, ParsedPath::Remote(_)));
725    }
726
727    #[test]
728    fn test_parse_local_absolute_path() {
729        // Use platform-appropriate absolute path
730        #[cfg(unix)]
731        let path = "/home/user/file.txt";
732        #[cfg(windows)]
733        let path = "C:\\Users\\user\\file.txt";
734
735        let result = parse_path(path).unwrap();
736        assert!(matches!(result, ParsedPath::Local(_)));
737        if let ParsedPath::Local(p) = result {
738            assert!(p.is_absolute());
739        }
740    }
741
742    #[test]
743    fn test_parse_local_relative_path() {
744        let result = parse_path("../file.txt").unwrap();
745        assert!(matches!(result, ParsedPath::Local(_)));
746    }
747
748    #[test]
749    fn test_parse_remote_path_bucket_only() {
750        let result = parse_path("myalias/bucket/").unwrap();
751        assert!(matches!(result, ParsedPath::Remote(_)));
752        if let ParsedPath::Remote(r) = result {
753            assert_eq!(r.alias, "myalias");
754            assert_eq!(r.bucket, "bucket");
755            assert!(r.key.is_empty());
756        }
757    }
758
759    #[test]
760    fn test_parse_remote_path_with_deep_key() {
761        let result = parse_path("myalias/bucket/dir1/dir2/file.txt").unwrap();
762        assert!(matches!(result, ParsedPath::Remote(_)));
763        if let ParsedPath::Remote(r) = result {
764            assert_eq!(r.alias, "myalias");
765            assert_eq!(r.bucket, "bucket");
766            assert_eq!(r.key, "dir1/dir2/file.txt");
767        }
768    }
769
770    #[test]
771    fn test_parse_cp_path_prefers_existing_local_path_when_alias_missing() {
772        let (alias_manager, temp_dir) = temp_alias_manager();
773        let full = temp_dir.path().join("issue-2094-local").join("file.txt");
774        let full_str = full.to_string_lossy().to_string();
775
776        if let Some(parent) = full.parent() {
777            std::fs::create_dir_all(parent).expect("create parent dirs");
778        }
779        std::fs::write(&full, b"test").expect("write local file");
780
781        let parsed = parse_cp_path(&full_str, Some(&alias_manager)).expect("parse path");
782        assert!(matches!(parsed, ParsedPath::Local(_)));
783    }
784
785    #[test]
786    fn test_parse_cp_path_keeps_remote_when_alias_exists() {
787        let (alias_manager, _temp_dir) = temp_alias_manager();
788        alias_manager
789            .set(Alias::new("target", "http://localhost:9000", "a", "b"))
790            .expect("set alias");
791
792        let parsed = parse_cp_path("target/bucket/file.txt", Some(&alias_manager))
793            .expect("parse remote path");
794        assert!(matches!(parsed, ParsedPath::Remote(_)));
795    }
796
797    #[test]
798    fn test_parse_cp_path_keeps_remote_when_local_missing() {
799        let (alias_manager, _temp_dir) = temp_alias_manager();
800        let parsed = parse_cp_path("missing/bucket/file.txt", Some(&alias_manager))
801            .expect("parse remote path");
802        assert!(matches!(parsed, ParsedPath::Remote(_)));
803    }
804
805    #[test]
806    fn test_cp_args_defaults() {
807        let args = CpArgs {
808            source: "src".to_string(),
809            target: "dst".to_string(),
810            recursive: false,
811            preserve: false,
812            continue_on_error: false,
813            overwrite: true,
814            dry_run: false,
815            storage_class: None,
816            content_type: None,
817        };
818        assert!(args.overwrite);
819        assert!(!args.recursive);
820        assert!(!args.dry_run);
821    }
822
823    #[test]
824    fn test_cp_output_serialization() {
825        let output = CpOutput {
826            status: "success",
827            source: "src/file.txt".to_string(),
828            target: "dst/file.txt".to_string(),
829            size_bytes: Some(1024),
830            size_human: Some("1 KiB".to_string()),
831        };
832        let json = serde_json::to_string(&output).unwrap();
833        assert!(json.contains("\"status\":\"success\""));
834        assert!(json.contains("\"size_bytes\":1024"));
835    }
836
837    #[test]
838    fn test_cp_output_skips_none_fields() {
839        let output = CpOutput {
840            status: "success",
841            source: "src".to_string(),
842            target: "dst".to_string(),
843            size_bytes: None,
844            size_human: None,
845        };
846        let json = serde_json::to_string(&output).unwrap();
847        assert!(!json.contains("size_bytes"));
848        assert!(!json.contains("size_human"));
849    }
850}