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::{
7    AliasManager, ObjectEncryptionRequest, ObjectStore as _, ParsedPath, RemotePath, parse_path,
8};
9use rc_s3::S3Client;
10use serde::Serialize;
11use std::path::{Path, PathBuf};
12
13use crate::exit_code::ExitCode;
14use crate::output::{Formatter, OutputConfig, ProgressBar};
15
16const CP_AFTER_HELP: &str = "\
17Examples:
18  rc object copy ./report.json local/my-bucket/reports/
19  rc cp ./report.json local/my-bucket/reports/
20  rc object copy local/source-bucket/archive.tar.gz ./downloads/archive.tar.gz";
21
22const REMOTE_PATH_SUGGESTION: &str =
23    "Use a local filesystem path or a remote path in the form alias/bucket[/key].";
24
25/// Copy objects
26#[derive(Args, Debug)]
27#[command(after_help = CP_AFTER_HELP)]
28pub struct CpArgs {
29    /// Source path (local path or alias/bucket/key)
30    pub source: String,
31
32    /// Destination path (local path or alias/bucket/key)
33    pub target: String,
34
35    /// Copy recursively
36    #[arg(short, long)]
37    pub recursive: bool,
38
39    /// Preserve file attributes
40    #[arg(short, long)]
41    pub preserve: bool,
42
43    /// Continue on errors
44    #[arg(long)]
45    pub continue_on_error: bool,
46
47    /// Overwrite destination if it exists
48    #[arg(long, default_value = "true")]
49    pub overwrite: bool,
50
51    /// Only show what would be copied (dry run)
52    #[arg(long)]
53    pub dry_run: bool,
54
55    /// Storage class for destination (S3 only)
56    #[arg(long)]
57    pub storage_class: Option<String>,
58
59    /// Content type for uploaded files
60    #[arg(long)]
61    pub content_type: Option<String>,
62
63    /// Apply SSE-S3 to the remote destination path
64    #[arg(long = "enc-s3")]
65    pub enc_s3: Vec<String>,
66
67    /// Apply SSE-KMS to the remote destination path as TARGET=KMS_KEY_ID
68    #[arg(long = "enc-kms")]
69    pub enc_kms: Vec<String>,
70}
71
72#[derive(Debug, Serialize)]
73struct CpOutput {
74    status: &'static str,
75    source: String,
76    target: String,
77    #[serde(skip_serializing_if = "Option::is_none")]
78    size_bytes: Option<i64>,
79    #[serde(skip_serializing_if = "Option::is_none")]
80    size_human: Option<String>,
81}
82
83/// Execute the cp command
84pub async fn execute(args: CpArgs, output_config: OutputConfig) -> ExitCode {
85    let formatter = Formatter::new(output_config);
86    let alias_manager = AliasManager::new().ok();
87
88    // Parse source and target paths
89    let source = match parse_cp_path(&args.source, alias_manager.as_ref()) {
90        Ok(p) => p,
91        Err(e) => {
92            return formatter.fail_with_suggestion(
93                ExitCode::UsageError,
94                &format!("Invalid source path: {e}"),
95                REMOTE_PATH_SUGGESTION,
96            );
97        }
98    };
99
100    let target = match parse_cp_path(&args.target, alias_manager.as_ref()) {
101        Ok(p) => p,
102        Err(e) => {
103            return formatter.fail_with_suggestion(
104                ExitCode::UsageError,
105                &format!("Invalid target path: {e}"),
106                REMOTE_PATH_SUGGESTION,
107            );
108        }
109    };
110
111    // Determine copy direction
112    match (&source, &target) {
113        (ParsedPath::Local(src), ParsedPath::Remote(dst)) => {
114            // Local to S3
115            copy_local_to_s3(src, dst, &args, &formatter).await
116        }
117        (ParsedPath::Remote(src), ParsedPath::Local(dst)) => {
118            // S3 to Local
119            copy_s3_to_local(src, dst, &args, &formatter).await
120        }
121        (ParsedPath::Remote(src), ParsedPath::Remote(dst)) => {
122            // S3 to S3
123            copy_s3_to_s3(src, dst, &args, &formatter).await
124        }
125        (ParsedPath::Local(_), ParsedPath::Local(_)) => formatter.fail_with_suggestion(
126            ExitCode::UsageError,
127            "Cannot copy between two local paths. Use system cp command.",
128            "Use your local shell cp command when both paths are on the filesystem.",
129        ),
130    }
131}
132
133fn parse_cp_path(path: &str, alias_manager: Option<&AliasManager>) -> rc_core::Result<ParsedPath> {
134    let parsed = parse_path(path)?;
135
136    let ParsedPath::Remote(remote) = &parsed else {
137        return Ok(parsed);
138    };
139
140    if let Some(manager) = alias_manager
141        && matches!(manager.exists(&remote.alias), Ok(true))
142    {
143        return Ok(parsed);
144    }
145
146    if Path::new(path).exists() {
147        return Ok(ParsedPath::Local(PathBuf::from(path)));
148    }
149
150    Ok(parsed)
151}
152
153async fn copy_local_to_s3(
154    src: &Path,
155    dst: &RemotePath,
156    args: &CpArgs,
157    formatter: &Formatter,
158) -> ExitCode {
159    let target = ParsedPath::Remote(dst.clone());
160    let encryption = match parse_destination_encryption(&args.enc_s3, &args.enc_kms, &target) {
161        Ok(encryption) => encryption,
162        Err(error) => {
163            return formatter.fail(ExitCode::UsageError, &error);
164        }
165    };
166
167    // Check if source exists
168    if !src.exists() {
169        return formatter.fail_with_suggestion(
170            ExitCode::NotFound,
171            &format!("Source not found: {}", src.display()),
172            "Check the local source path and retry the copy command.",
173        );
174    }
175
176    // If source is a directory, require recursive flag
177    if src.is_dir() && !args.recursive {
178        return formatter.fail_with_suggestion(
179            ExitCode::UsageError,
180            "Source is a directory. Use -r/--recursive to copy directories.",
181            "Retry with -r or --recursive to copy a directory tree.",
182        );
183    }
184
185    // Load alias and create client
186    let alias_manager = match AliasManager::new() {
187        Ok(am) => am,
188        Err(e) => {
189            formatter.error(&format!("Failed to load aliases: {e}"));
190            return ExitCode::GeneralError;
191        }
192    };
193
194    let alias = match alias_manager.get(&dst.alias) {
195        Ok(a) => a,
196        Err(_) => {
197            return formatter.fail_with_suggestion(
198                ExitCode::NotFound,
199                &format!("Alias '{}' not found", dst.alias),
200                "Run `rc alias list` to inspect configured aliases or add one with `rc alias set ...`.",
201            );
202        }
203    };
204
205    let client = match S3Client::new(alias).await {
206        Ok(c) => c,
207        Err(e) => {
208            return formatter.fail(
209                ExitCode::NetworkError,
210                &format!("Failed to create S3 client: {e}"),
211            );
212        }
213    };
214
215    if src.is_file() {
216        // Single file upload
217        upload_file(&client, src, dst, args, formatter, encryption.as_ref()).await
218    } else {
219        // Directory upload
220        upload_directory(&client, src, dst, args, formatter, encryption.as_ref()).await
221    }
222}
223
224/// Multipart upload threshold: files larger than this size use multipart upload.
225const MULTIPART_THRESHOLD: u64 = rc_s3::multipart::DEFAULT_PART_SIZE;
226/// Download progress threshold: avoid flicker for tiny downloads while surfacing meaningful waits.
227const DOWNLOAD_PROGRESS_THRESHOLD: u64 = 4 * 1024 * 1024;
228
229fn update_download_progress(
230    progress: &mut Option<ProgressBar>,
231    output_config: &OutputConfig,
232    bytes_downloaded: u64,
233    total_size: Option<u64>,
234) {
235    let Some(total_size) = total_size else {
236        return;
237    };
238
239    if total_size < DOWNLOAD_PROGRESS_THRESHOLD {
240        return;
241    }
242
243    let progress_bar =
244        progress.get_or_insert_with(|| ProgressBar::new(output_config.clone(), total_size));
245    progress_bar.set_position(bytes_downloaded);
246}
247
248fn print_upload_success(
249    formatter: &Formatter,
250    info: &rc_core::ObjectInfo,
251    src_display: &str,
252    dst_display: &str,
253) {
254    if formatter.is_json() {
255        let output = CpOutput {
256            status: "success",
257            source: src_display.to_string(),
258            target: dst_display.to_string(),
259            size_bytes: info.size_bytes,
260            size_human: info.size_human.clone(),
261        };
262        formatter.json(&output);
263    } else {
264        let styled_src = formatter.style_file(src_display);
265        let styled_dst = formatter.style_file(dst_display);
266        let styled_size = formatter.style_size(&info.size_human.clone().unwrap_or_default());
267        formatter.println(&format!("{styled_src} -> {styled_dst} ({styled_size})"));
268    }
269}
270
271async fn upload_file(
272    client: &S3Client,
273    src: &Path,
274    dst: &RemotePath,
275    args: &CpArgs,
276    formatter: &Formatter,
277    encryption: Option<&ObjectEncryptionRequest>,
278) -> ExitCode {
279    // Determine destination key
280    let dst_key = if dst.key.is_empty() || dst.key.ends_with('/') {
281        // If destination is a directory, use source filename
282        let filename = src.file_name().unwrap_or_default().to_string_lossy();
283        format!("{}{}", dst.key, filename)
284    } else {
285        dst.key.clone()
286    };
287
288    let target = RemotePath::new(&dst.alias, &dst.bucket, &dst_key);
289    let src_display = src.display().to_string();
290    let dst_display = format!("{}/{}/{}", dst.alias, dst.bucket, dst_key);
291
292    if args.dry_run {
293        let styled_src = formatter.style_file(&src_display);
294        let styled_dst = formatter.style_file(&dst_display);
295        formatter.println(&format!("Would copy: {styled_src} -> {styled_dst}"));
296        return ExitCode::Success;
297    }
298
299    // Get file size for progress bar decision
300    let file_size = match std::fs::metadata(src) {
301        Ok(m) => m.len(),
302        Err(e) => {
303            return formatter.fail(
304                ExitCode::GeneralError,
305                &format!("Failed to read {src_display}: {e}"),
306            );
307        }
308    };
309
310    // Determine content type
311    let guessed_type: Option<String> = mime_guess::from_path(src)
312        .first()
313        .map(|m| m.essence_str().to_string());
314    let content_type = select_upload_content_type(
315        args.content_type.as_deref(),
316        guessed_type.as_deref(),
317        file_size,
318    );
319
320    // Show progress bar for large files
321    let progress = if file_size > MULTIPART_THRESHOLD {
322        tracing::debug!(
323            file_size,
324            threshold = MULTIPART_THRESHOLD,
325            "Using multipart upload for large file"
326        );
327        Some(ProgressBar::new(formatter.output_config(), file_size))
328    } else {
329        tracing::debug!(file_size, "Using single put_object for small file");
330        None
331    };
332
333    // Upload
334    match client
335        .put_object_from_path(&target, src, content_type, encryption, |bytes_sent| {
336            if let Some(ref pb) = progress {
337                pb.set_position(bytes_sent);
338            }
339        })
340        .await
341    {
342        Ok(info) => {
343            if let Some(ref pb) = progress {
344                pb.finish_and_clear();
345            }
346            print_upload_success(formatter, &info, &src_display, &dst_display);
347            ExitCode::Success
348        }
349        Err(e) => {
350            if let Some(ref pb) = progress {
351                pb.finish_and_clear();
352            }
353            formatter.fail(
354                ExitCode::NetworkError,
355                &format!("Failed to upload {src_display}: {e}"),
356            )
357        }
358    }
359}
360
361fn select_upload_content_type<'a>(
362    explicit_type: Option<&'a str>,
363    guessed_type: Option<&'a str>,
364    file_size: u64,
365) -> Option<&'a str> {
366    if file_size > MULTIPART_THRESHOLD {
367        explicit_type
368    } else {
369        explicit_type.or(guessed_type)
370    }
371}
372
373async fn upload_directory(
374    client: &S3Client,
375    src: &Path,
376    dst: &RemotePath,
377    args: &CpArgs,
378    formatter: &Formatter,
379    encryption: Option<&ObjectEncryptionRequest>,
380) -> ExitCode {
381    use std::fs;
382
383    let mut success_count = 0;
384    let mut error_count = 0;
385
386    // Walk directory
387    fn walk_dir(dir: &Path, base: &Path) -> std::io::Result<Vec<(std::path::PathBuf, String)>> {
388        let mut files = Vec::new();
389        for entry in fs::read_dir(dir)? {
390            let entry = entry?;
391            let path = entry.path();
392            if path.is_file() {
393                let relative = path.strip_prefix(base).unwrap_or(&path);
394                let relative_str = relative.to_string_lossy().to_string();
395                files.push((path, relative_str));
396            } else if path.is_dir() {
397                files.extend(walk_dir(&path, base)?);
398            }
399        }
400        Ok(files)
401    }
402
403    let files = match walk_dir(src, src) {
404        Ok(f) => f,
405        Err(e) => {
406            return formatter.fail(
407                ExitCode::GeneralError,
408                &format!("Failed to read directory: {e}"),
409            );
410        }
411    };
412
413    for (file_path, relative_path) in files {
414        // Build destination key
415        let dst_key = if dst.key.is_empty() {
416            relative_path.replace('\\', "/")
417        } else if dst.key.ends_with('/') {
418            format!("{}{}", dst.key, relative_path.replace('\\', "/"))
419        } else {
420            format!("{}/{}", dst.key, relative_path.replace('\\', "/"))
421        };
422
423        let target = RemotePath::new(&dst.alias, &dst.bucket, &dst_key);
424
425        let result = upload_file(client, &file_path, &target, args, formatter, encryption).await;
426
427        if result == ExitCode::Success {
428            success_count += 1;
429        } else {
430            error_count += 1;
431            if !args.continue_on_error {
432                return result;
433            }
434        }
435    }
436
437    if error_count > 0 {
438        formatter.warning(&format!(
439            "Completed with errors: {success_count} succeeded, {error_count} failed"
440        ));
441        ExitCode::GeneralError
442    } else {
443        if !formatter.is_json() {
444            formatter.success(&format!("Uploaded {success_count} file(s)."));
445        }
446        ExitCode::Success
447    }
448}
449
450async fn copy_s3_to_local(
451    src: &RemotePath,
452    dst: &Path,
453    args: &CpArgs,
454    formatter: &Formatter,
455) -> ExitCode {
456    // Load alias and create client
457    let alias_manager = match AliasManager::new() {
458        Ok(am) => am,
459        Err(e) => {
460            formatter.error(&format!("Failed to load aliases: {e}"));
461            return ExitCode::GeneralError;
462        }
463    };
464
465    let alias = match alias_manager.get(&src.alias) {
466        Ok(a) => a,
467        Err(_) => {
468            return formatter.fail_with_suggestion(
469                ExitCode::NotFound,
470                &format!("Alias '{}' not found", src.alias),
471                "Run `rc alias list` to inspect configured aliases or add one with `rc alias set ...`.",
472            );
473        }
474    };
475
476    let client = match S3Client::new(alias).await {
477        Ok(c) => c,
478        Err(e) => {
479            return formatter.fail(
480                ExitCode::NetworkError,
481                &format!("Failed to create S3 client: {e}"),
482            );
483        }
484    };
485
486    // Check if source is a prefix (directory-like)
487    let is_prefix = src.key.is_empty() || src.key.ends_with('/');
488
489    if is_prefix || args.recursive {
490        // Download multiple objects
491        download_prefix(&client, src, dst, args, formatter).await
492    } else {
493        // Download single object
494        download_file(&client, src, dst, args, formatter).await
495    }
496}
497
498async fn download_file(
499    client: &S3Client,
500    src: &RemotePath,
501    dst: &Path,
502    args: &CpArgs,
503    formatter: &Formatter,
504) -> ExitCode {
505    let src_display = format!("{}/{}/{}", src.alias, src.bucket, src.key);
506
507    // Determine destination path
508    let dst_path = if dst.is_dir() || dst.to_string_lossy().ends_with('/') {
509        let filename = src.key.rsplit('/').next().unwrap_or(&src.key);
510        dst.join(filename)
511    } else {
512        dst.to_path_buf()
513    };
514
515    let dst_display = dst_path.display().to_string();
516
517    if args.dry_run {
518        let styled_src = formatter.style_file(&src_display);
519        let styled_dst = formatter.style_file(&dst_display);
520        formatter.println(&format!("Would copy: {styled_src} -> {styled_dst}"));
521        return ExitCode::Success;
522    }
523
524    // Check if destination exists
525    if dst_path.exists() && !args.overwrite {
526        return formatter.fail_with_suggestion(
527            ExitCode::Conflict,
528            &format!("Destination exists: {dst_display}. Use --overwrite to replace."),
529            "Retry with --overwrite if replacing the destination file is intended.",
530        );
531    }
532
533    // Create parent directories
534    if let Some(parent) = dst_path.parent()
535        && !parent.exists()
536        && let Err(e) = std::fs::create_dir_all(parent)
537    {
538        return formatter.fail(
539            ExitCode::GeneralError,
540            &format!("Failed to create directory: {e}"),
541        );
542    }
543
544    let output_config = formatter.output_config();
545    let mut progress = None;
546
547    // Download object
548    let result = client
549        .get_object_with_progress(src, |bytes_downloaded, total_size| {
550            update_download_progress(&mut progress, &output_config, bytes_downloaded, total_size);
551        })
552        .await;
553
554    if let Some(ref pb) = progress {
555        pb.finish_and_clear();
556    }
557
558    match result {
559        Ok(data) => {
560            let size = data.len() as i64;
561
562            if let Err(e) = std::fs::write(&dst_path, &data) {
563                return formatter.fail(
564                    ExitCode::GeneralError,
565                    &format!("Failed to write {dst_display}: {e}"),
566                );
567            }
568
569            if formatter.is_json() {
570                let output = CpOutput {
571                    status: "success",
572                    source: src_display,
573                    target: dst_display,
574                    size_bytes: Some(size),
575                    size_human: Some(humansize::format_size(size as u64, humansize::BINARY)),
576                };
577                formatter.json(&output);
578            } else {
579                let styled_src = formatter.style_file(&src_display);
580                let styled_dst = formatter.style_file(&dst_display);
581                let styled_size =
582                    formatter.style_size(&humansize::format_size(size as u64, humansize::BINARY));
583                formatter.println(&format!("{styled_src} -> {styled_dst} ({styled_size})"));
584            }
585            ExitCode::Success
586        }
587        Err(e) => {
588            let err_str = e.to_string();
589            if err_str.contains("NotFound") || err_str.contains("NoSuchKey") {
590                formatter.fail_with_suggestion(
591                    ExitCode::NotFound,
592                    &format!("Object not found: {src_display}"),
593                    "Check the object key and bucket path, then retry the copy command.",
594                )
595            } else {
596                formatter.fail(
597                    ExitCode::NetworkError,
598                    &format!("Failed to download {src_display}: {e}"),
599                )
600            }
601        }
602    }
603}
604
605async fn download_prefix(
606    client: &S3Client,
607    src: &RemotePath,
608    dst: &Path,
609    args: &CpArgs,
610    formatter: &Formatter,
611) -> ExitCode {
612    use rc_core::ListOptions;
613
614    let mut success_count = 0;
615    let mut error_count = 0;
616    let mut continuation_token: Option<String> = None;
617
618    loop {
619        let options = ListOptions {
620            recursive: true,
621            max_keys: Some(1000),
622            continuation_token: continuation_token.clone(),
623            ..Default::default()
624        };
625
626        match client.list_objects(src, options).await {
627            Ok(result) => {
628                for item in result.items {
629                    if item.is_dir {
630                        continue;
631                    }
632
633                    // Calculate relative path from prefix
634                    let relative_key = item
635                        .key
636                        .strip_prefix(&src.key)
637                        .unwrap_or(&item.key)
638                        .trim_start_matches('/');
639                    let dst_path =
640                        dst.join(relative_key.replace('/', std::path::MAIN_SEPARATOR_STR));
641
642                    let obj_src = RemotePath::new(&src.alias, &src.bucket, &item.key);
643                    let result = download_file(client, &obj_src, &dst_path, args, formatter).await;
644
645                    if result == ExitCode::Success {
646                        success_count += 1;
647                    } else {
648                        error_count += 1;
649                        if !args.continue_on_error {
650                            return result;
651                        }
652                    }
653                }
654
655                if result.truncated {
656                    continuation_token = result.continuation_token;
657                } else {
658                    break;
659                }
660            }
661            Err(e) => {
662                return formatter.fail(
663                    ExitCode::NetworkError,
664                    &format!("Failed to list objects: {e}"),
665                );
666            }
667        }
668    }
669
670    if error_count > 0 {
671        formatter.warning(&format!(
672            "Completed with errors: {success_count} succeeded, {error_count} failed"
673        ));
674        ExitCode::GeneralError
675    } else if success_count == 0 {
676        formatter.warning("No objects found to download.");
677        ExitCode::Success
678    } else {
679        if !formatter.is_json() {
680            formatter.success(&format!("Downloaded {success_count} file(s)."));
681        }
682        ExitCode::Success
683    }
684}
685
686async fn copy_s3_to_s3(
687    src: &RemotePath,
688    dst: &RemotePath,
689    args: &CpArgs,
690    formatter: &Formatter,
691) -> ExitCode {
692    let target = ParsedPath::Remote(dst.clone());
693    let encryption = match parse_destination_encryption(&args.enc_s3, &args.enc_kms, &target) {
694        Ok(encryption) => encryption,
695        Err(error) => {
696            return formatter.fail(ExitCode::UsageError, &error);
697        }
698    };
699
700    // For S3-to-S3, we need to handle same or different aliases
701    let alias_manager = match AliasManager::new() {
702        Ok(am) => am,
703        Err(e) => {
704            formatter.error(&format!("Failed to load aliases: {e}"));
705            return ExitCode::GeneralError;
706        }
707    };
708
709    // For now, only support same-alias copies (server-side copy)
710    if src.alias != dst.alias {
711        return formatter.fail_with_suggestion(
712            ExitCode::UnsupportedFeature,
713            "Cross-alias S3-to-S3 copy not yet supported. Use download + upload.",
714            "Copy via a local path or split the operation into download and upload steps.",
715        );
716    }
717
718    let alias = match alias_manager.get(&src.alias) {
719        Ok(a) => a,
720        Err(_) => {
721            return formatter.fail_with_suggestion(
722                ExitCode::NotFound,
723                &format!("Alias '{}' not found", src.alias),
724                "Run `rc alias list` to inspect configured aliases or add one with `rc alias set ...`.",
725            );
726        }
727    };
728
729    let client = match S3Client::new(alias).await {
730        Ok(c) => c,
731        Err(e) => {
732            return formatter.fail(
733                ExitCode::NetworkError,
734                &format!("Failed to create S3 client: {e}"),
735            );
736        }
737    };
738
739    let src_display = format!("{}/{}/{}", src.alias, src.bucket, src.key);
740    let dst_display = format!("{}/{}/{}", dst.alias, dst.bucket, dst.key);
741
742    if args.dry_run {
743        let styled_src = formatter.style_file(&src_display);
744        let styled_dst = formatter.style_file(&dst_display);
745        formatter.println(&format!("Would copy: {styled_src} -> {styled_dst}"));
746        return ExitCode::Success;
747    }
748
749    match client.copy_object(src, dst, encryption.as_ref()).await {
750        Ok(info) => {
751            if formatter.is_json() {
752                let output = CpOutput {
753                    status: "success",
754                    source: src_display,
755                    target: dst_display,
756                    size_bytes: info.size_bytes,
757                    size_human: info.size_human,
758                };
759                formatter.json(&output);
760            } else {
761                let styled_src = formatter.style_file(&src_display);
762                let styled_dst = formatter.style_file(&dst_display);
763                let styled_size = formatter.style_size(&info.size_human.unwrap_or_default());
764                formatter.println(&format!("{styled_src} -> {styled_dst} ({styled_size})"));
765            }
766            ExitCode::Success
767        }
768        Err(e) => {
769            let err_str = e.to_string();
770            if err_str.contains("NotFound") || err_str.contains("NoSuchKey") {
771                formatter.fail_with_suggestion(
772                    ExitCode::NotFound,
773                    &format!("Source not found: {src_display}"),
774                    "Check the source bucket and object key, then retry the copy command.",
775                )
776            } else {
777                formatter.fail(ExitCode::NetworkError, &format!("Failed to copy: {e}"))
778            }
779        }
780    }
781}
782
783fn parse_kms_target(value: &str) -> Result<(String, String), String> {
784    let (target, key_id) = value
785        .split_once('=')
786        .ok_or_else(|| "Expected TARGET=KMS_KEY_ID for --enc-kms".to_string())?;
787
788    if target.is_empty() || key_id.is_empty() {
789        return Err("Expected TARGET=KMS_KEY_ID for --enc-kms".to_string());
790    }
791
792    Ok((target.to_string(), key_id.to_string()))
793}
794
795pub(crate) fn parse_destination_encryption(
796    enc_s3: &[String],
797    enc_kms: &[String],
798    target: &ParsedPath,
799) -> Result<Option<ObjectEncryptionRequest>, String> {
800    if enc_s3.is_empty() && enc_kms.is_empty() {
801        return Ok(None);
802    }
803
804    let remote = match target {
805        ParsedPath::Remote(remote) => remote,
806        ParsedPath::Local(_) => {
807            return Err("Destination encryption flags must reference a remote destination".into());
808        }
809    };
810
811    let target_display = remote.to_string();
812    let s3_matches = enc_s3.iter().any(|value| value == &target_display);
813    let kms_targets = enc_kms
814        .iter()
815        .map(|value| parse_kms_target(value))
816        .collect::<Result<Vec<_>, _>>()?;
817    let kms_match = kms_targets
818        .iter()
819        .find(|(candidate, _)| candidate == &target_display);
820
821    if !enc_s3.is_empty() && !s3_matches {
822        return Err(format!(
823            "--enc-s3 target must exactly match the remote destination: {target_display}"
824        ));
825    }
826
827    if !enc_kms.is_empty() && kms_match.is_none() {
828        return Err(format!(
829            "--enc-kms target must exactly match the remote destination: {target_display}"
830        ));
831    }
832
833    match (s3_matches, kms_match) {
834        (true, Some(_)) => Err(format!(
835            "--enc-s3 and --enc-kms cannot target the same destination: {target_display}"
836        )),
837        (true, None) => Ok(Some(ObjectEncryptionRequest::SseS3)),
838        (false, Some((_, key_id))) => Ok(Some(ObjectEncryptionRequest::SseKms {
839            key_id: key_id.clone(),
840        })),
841        (false, None) => Ok(None),
842    }
843}
844
845#[cfg(test)]
846mod tests {
847    use super::*;
848    use rc_core::{Alias, ConfigManager};
849    use tempfile::TempDir;
850
851    fn temp_alias_manager() -> (AliasManager, TempDir) {
852        let temp_dir = TempDir::new().expect("create temp dir");
853        let config_path = temp_dir.path().join("config.toml");
854        let config_manager = ConfigManager::with_path(config_path);
855        let alias_manager = AliasManager::with_config_manager(config_manager);
856        (alias_manager, temp_dir)
857    }
858
859    #[test]
860    fn test_parse_local_path() {
861        let result = parse_path("./file.txt").unwrap();
862        assert!(matches!(result, ParsedPath::Local(_)));
863    }
864
865    #[test]
866    fn test_parse_remote_path() {
867        let result = parse_path("myalias/bucket/file.txt").unwrap();
868        assert!(matches!(result, ParsedPath::Remote(_)));
869    }
870
871    #[test]
872    fn test_parse_local_absolute_path() {
873        // Use platform-appropriate absolute path
874        #[cfg(unix)]
875        let path = "/home/user/file.txt";
876        #[cfg(windows)]
877        let path = "C:\\Users\\user\\file.txt";
878
879        let result = parse_path(path).unwrap();
880        assert!(matches!(result, ParsedPath::Local(_)));
881        if let ParsedPath::Local(p) = result {
882            assert!(p.is_absolute());
883        }
884    }
885
886    #[test]
887    fn test_parse_local_relative_path() {
888        let result = parse_path("../file.txt").unwrap();
889        assert!(matches!(result, ParsedPath::Local(_)));
890    }
891
892    #[test]
893    fn test_parse_remote_path_bucket_only() {
894        let result = parse_path("myalias/bucket/").unwrap();
895        assert!(matches!(result, ParsedPath::Remote(_)));
896        if let ParsedPath::Remote(r) = result {
897            assert_eq!(r.alias, "myalias");
898            assert_eq!(r.bucket, "bucket");
899            assert!(r.key.is_empty());
900        }
901    }
902
903    #[test]
904    fn test_parse_remote_path_with_deep_key() {
905        let result = parse_path("myalias/bucket/dir1/dir2/file.txt").unwrap();
906        assert!(matches!(result, ParsedPath::Remote(_)));
907        if let ParsedPath::Remote(r) = result {
908            assert_eq!(r.alias, "myalias");
909            assert_eq!(r.bucket, "bucket");
910            assert_eq!(r.key, "dir1/dir2/file.txt");
911        }
912    }
913
914    #[test]
915    fn test_download_progress_created_for_large_transfer() {
916        let output_config = OutputConfig::default();
917        let mut progress = None;
918
919        update_download_progress(
920            &mut progress,
921            &output_config,
922            1024,
923            Some(DOWNLOAD_PROGRESS_THRESHOLD),
924        );
925
926        let progress = progress.expect("large download should create progress bar");
927        assert!(progress.is_visible());
928        progress.finish_and_clear();
929    }
930
931    #[test]
932    fn test_download_progress_skips_small_transfer() {
933        let output_config = OutputConfig::default();
934        let mut progress = None;
935
936        update_download_progress(
937            &mut progress,
938            &output_config,
939            1024,
940            Some(DOWNLOAD_PROGRESS_THRESHOLD - 1),
941        );
942
943        assert!(progress.is_none());
944    }
945
946    #[test]
947    fn test_download_progress_skips_unknown_total_size() {
948        let output_config = OutputConfig::default();
949        let mut progress = None;
950
951        update_download_progress(&mut progress, &output_config, 1024, None);
952
953        assert!(progress.is_none());
954    }
955
956    #[test]
957    fn test_download_progress_respects_no_progress_config() {
958        let output_config = OutputConfig {
959            no_progress: true,
960            ..Default::default()
961        };
962        let mut progress = None;
963
964        update_download_progress(
965            &mut progress,
966            &output_config,
967            1024,
968            Some(DOWNLOAD_PROGRESS_THRESHOLD),
969        );
970
971        let progress = progress.expect("large download should create progress state");
972        assert!(!progress.is_visible());
973    }
974
975    #[test]
976    fn test_select_upload_content_type_uses_guess_for_small_files() {
977        let selected =
978            select_upload_content_type(None, Some("text/plain"), MULTIPART_THRESHOLD - 1);
979
980        assert_eq!(selected, Some("text/plain"));
981    }
982
983    #[test]
984    fn test_select_upload_content_type_skips_guess_for_multipart_files() {
985        let selected =
986            select_upload_content_type(None, Some("text/plain"), MULTIPART_THRESHOLD + 1);
987
988        assert_eq!(selected, None);
989    }
990
991    #[test]
992    fn test_select_upload_content_type_uses_guess_at_multipart_boundary() {
993        let selected = select_upload_content_type(None, Some("text/plain"), MULTIPART_THRESHOLD);
994
995        assert_eq!(selected, Some("text/plain"));
996    }
997
998    #[test]
999    fn test_select_upload_content_type_keeps_explicit_type_for_multipart_files() {
1000        let selected = select_upload_content_type(
1001            Some("application/octet-stream"),
1002            Some("text/plain"),
1003            MULTIPART_THRESHOLD + 1,
1004        );
1005
1006        assert_eq!(selected, Some("application/octet-stream"));
1007    }
1008
1009    #[test]
1010    fn test_parse_cp_path_prefers_existing_local_path_when_alias_missing() {
1011        let (alias_manager, temp_dir) = temp_alias_manager();
1012        let full = temp_dir.path().join("issue-2094-local").join("file.txt");
1013        let full_str = full.to_string_lossy().to_string();
1014
1015        if let Some(parent) = full.parent() {
1016            std::fs::create_dir_all(parent).expect("create parent dirs");
1017        }
1018        std::fs::write(&full, b"test").expect("write local file");
1019
1020        let parsed = parse_cp_path(&full_str, Some(&alias_manager)).expect("parse path");
1021        assert!(matches!(parsed, ParsedPath::Local(_)));
1022    }
1023
1024    #[test]
1025    fn test_parse_cp_path_keeps_remote_when_alias_exists() {
1026        let (alias_manager, _temp_dir) = temp_alias_manager();
1027        alias_manager
1028            .set(Alias::new("target", "http://localhost:9000", "a", "b"))
1029            .expect("set alias");
1030
1031        let parsed = parse_cp_path("target/bucket/file.txt", Some(&alias_manager))
1032            .expect("parse remote path");
1033        assert!(matches!(parsed, ParsedPath::Remote(_)));
1034    }
1035
1036    #[test]
1037    fn test_parse_cp_path_keeps_remote_when_local_missing() {
1038        let (alias_manager, _temp_dir) = temp_alias_manager();
1039        let parsed = parse_cp_path("missing/bucket/file.txt", Some(&alias_manager))
1040            .expect("parse remote path");
1041        assert!(matches!(parsed, ParsedPath::Remote(_)));
1042    }
1043
1044    #[test]
1045    fn test_cp_args_defaults() {
1046        let args = CpArgs {
1047            source: "src".to_string(),
1048            target: "dst".to_string(),
1049            recursive: false,
1050            preserve: false,
1051            continue_on_error: false,
1052            overwrite: true,
1053            dry_run: false,
1054            storage_class: None,
1055            content_type: None,
1056            enc_s3: Vec::new(),
1057            enc_kms: Vec::new(),
1058        };
1059        assert!(args.overwrite);
1060        assert!(!args.recursive);
1061        assert!(!args.dry_run);
1062    }
1063
1064    #[test]
1065    fn parse_enc_kms_target_requires_equals_separator() {
1066        let error = parse_kms_target("local/bucket/file.txt").expect_err("missing key separator");
1067        assert!(error.contains("Expected TARGET=KMS_KEY_ID"));
1068    }
1069
1070    #[test]
1071    fn destination_encryption_rejects_local_targets() {
1072        let error = parse_destination_encryption(
1073            &[String::from("./local.txt")],
1074            &[],
1075            &ParsedPath::Local(std::path::PathBuf::from("./local.txt")),
1076        )
1077        .expect_err("local target should be rejected");
1078
1079        assert!(error.contains("must reference a remote destination"));
1080    }
1081
1082    #[test]
1083    fn destination_encryption_detects_conflicting_flags_for_same_target() {
1084        let target = ParsedPath::Remote(RemotePath::new("local", "bucket", "file.txt"));
1085        let error = parse_destination_encryption(
1086            &[String::from("local/bucket/file.txt")],
1087            &[String::from("local/bucket/file.txt=kms-key")],
1088            &target,
1089        )
1090        .expect_err("same target conflict should fail");
1091
1092        assert!(error.contains("cannot target the same destination"));
1093    }
1094
1095    #[test]
1096    fn destination_encryption_rejects_unmatched_s3_target() {
1097        let target = ParsedPath::Remote(RemotePath::new("local", "bucket", "file.txt"));
1098        let error =
1099            parse_destination_encryption(&[String::from("local/bucket/typo.txt")], &[], &target)
1100                .expect_err("unmatched s3 target should fail");
1101
1102        assert!(error.contains("must exactly match the remote destination"));
1103    }
1104
1105    #[test]
1106    fn destination_encryption_rejects_unmatched_kms_target() {
1107        let target = ParsedPath::Remote(RemotePath::new("local", "bucket", "file.txt"));
1108        let error = parse_destination_encryption(
1109            &[],
1110            &[String::from("local/bucket/typo.txt=kms-key")],
1111            &target,
1112        )
1113        .expect_err("unmatched kms target should fail");
1114
1115        assert!(error.contains("must exactly match the remote destination"));
1116    }
1117
1118    #[test]
1119    fn test_cp_output_serialization() {
1120        let output = CpOutput {
1121            status: "success",
1122            source: "src/file.txt".to_string(),
1123            target: "dst/file.txt".to_string(),
1124            size_bytes: Some(1024),
1125            size_human: Some("1 KiB".to_string()),
1126        };
1127        let json = serde_json::to_string(&output).unwrap();
1128        assert!(json.contains("\"status\":\"success\""));
1129        assert!(json.contains("\"size_bytes\":1024"));
1130    }
1131
1132    #[test]
1133    fn test_cp_output_skips_none_fields() {
1134        let output = CpOutput {
1135            status: "success",
1136            source: "src".to_string(),
1137            target: "dst".to_string(),
1138            size_bytes: None,
1139            size_human: None,
1140        };
1141        let json = serde_json::to_string(&output).unwrap();
1142        assert!(!json.contains("size_bytes"));
1143        assert!(!json.contains("size_human"));
1144    }
1145}