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 larger than this size use multipart upload.
207const MULTIPART_THRESHOLD: u64 = rc_s3::multipart::DEFAULT_PART_SIZE;
208/// Download progress threshold: avoid flicker for tiny downloads while surfacing meaningful waits.
209const DOWNLOAD_PROGRESS_THRESHOLD: u64 = 4 * 1024 * 1024;
210
211fn update_download_progress(
212    progress: &mut Option<ProgressBar>,
213    output_config: &OutputConfig,
214    bytes_downloaded: u64,
215    total_size: Option<u64>,
216) {
217    let Some(total_size) = total_size else {
218        return;
219    };
220
221    if total_size < DOWNLOAD_PROGRESS_THRESHOLD {
222        return;
223    }
224
225    let progress_bar =
226        progress.get_or_insert_with(|| ProgressBar::new(output_config.clone(), total_size));
227    progress_bar.set_position(bytes_downloaded);
228}
229
230fn print_upload_success(
231    formatter: &Formatter,
232    info: &rc_core::ObjectInfo,
233    src_display: &str,
234    dst_display: &str,
235) {
236    if formatter.is_json() {
237        let output = CpOutput {
238            status: "success",
239            source: src_display.to_string(),
240            target: dst_display.to_string(),
241            size_bytes: info.size_bytes,
242            size_human: info.size_human.clone(),
243        };
244        formatter.json(&output);
245    } else {
246        let styled_src = formatter.style_file(src_display);
247        let styled_dst = formatter.style_file(dst_display);
248        let styled_size = formatter.style_size(&info.size_human.clone().unwrap_or_default());
249        formatter.println(&format!("{styled_src} -> {styled_dst} ({styled_size})"));
250    }
251}
252
253async fn upload_file(
254    client: &S3Client,
255    src: &Path,
256    dst: &RemotePath,
257    args: &CpArgs,
258    formatter: &Formatter,
259) -> ExitCode {
260    // Determine destination key
261    let dst_key = if dst.key.is_empty() || dst.key.ends_with('/') {
262        // If destination is a directory, use source filename
263        let filename = src.file_name().unwrap_or_default().to_string_lossy();
264        format!("{}{}", dst.key, filename)
265    } else {
266        dst.key.clone()
267    };
268
269    let target = RemotePath::new(&dst.alias, &dst.bucket, &dst_key);
270    let src_display = src.display().to_string();
271    let dst_display = format!("{}/{}/{}", dst.alias, dst.bucket, dst_key);
272
273    if args.dry_run {
274        let styled_src = formatter.style_file(&src_display);
275        let styled_dst = formatter.style_file(&dst_display);
276        formatter.println(&format!("Would copy: {styled_src} -> {styled_dst}"));
277        return ExitCode::Success;
278    }
279
280    // Get file size for progress bar decision
281    let file_size = match std::fs::metadata(src) {
282        Ok(m) => m.len(),
283        Err(e) => {
284            return formatter.fail(
285                ExitCode::GeneralError,
286                &format!("Failed to read {src_display}: {e}"),
287            );
288        }
289    };
290
291    // Determine content type
292    let guessed_type: Option<String> = mime_guess::from_path(src)
293        .first()
294        .map(|m| m.essence_str().to_string());
295    let content_type = select_upload_content_type(
296        args.content_type.as_deref(),
297        guessed_type.as_deref(),
298        file_size,
299    );
300
301    // Show progress bar for large files
302    let progress = if file_size > MULTIPART_THRESHOLD {
303        tracing::debug!(
304            file_size,
305            threshold = MULTIPART_THRESHOLD,
306            "Using multipart upload for large file"
307        );
308        Some(ProgressBar::new(formatter.output_config(), file_size))
309    } else {
310        tracing::debug!(file_size, "Using single put_object for small file");
311        None
312    };
313
314    // Upload
315    match client
316        .put_object_from_path(&target, src, content_type, |bytes_sent| {
317            if let Some(ref pb) = progress {
318                pb.set_position(bytes_sent);
319            }
320        })
321        .await
322    {
323        Ok(info) => {
324            if let Some(ref pb) = progress {
325                pb.finish_and_clear();
326            }
327            print_upload_success(formatter, &info, &src_display, &dst_display);
328            ExitCode::Success
329        }
330        Err(e) => {
331            if let Some(ref pb) = progress {
332                pb.finish_and_clear();
333            }
334            formatter.fail(
335                ExitCode::NetworkError,
336                &format!("Failed to upload {src_display}: {e}"),
337            )
338        }
339    }
340}
341
342fn select_upload_content_type<'a>(
343    explicit_type: Option<&'a str>,
344    guessed_type: Option<&'a str>,
345    file_size: u64,
346) -> Option<&'a str> {
347    if file_size > MULTIPART_THRESHOLD {
348        explicit_type
349    } else {
350        explicit_type.or(guessed_type)
351    }
352}
353
354async fn upload_directory(
355    client: &S3Client,
356    src: &Path,
357    dst: &RemotePath,
358    args: &CpArgs,
359    formatter: &Formatter,
360) -> ExitCode {
361    use std::fs;
362
363    let mut success_count = 0;
364    let mut error_count = 0;
365
366    // Walk directory
367    fn walk_dir(dir: &Path, base: &Path) -> std::io::Result<Vec<(std::path::PathBuf, String)>> {
368        let mut files = Vec::new();
369        for entry in fs::read_dir(dir)? {
370            let entry = entry?;
371            let path = entry.path();
372            if path.is_file() {
373                let relative = path.strip_prefix(base).unwrap_or(&path);
374                let relative_str = relative.to_string_lossy().to_string();
375                files.push((path, relative_str));
376            } else if path.is_dir() {
377                files.extend(walk_dir(&path, base)?);
378            }
379        }
380        Ok(files)
381    }
382
383    let files = match walk_dir(src, src) {
384        Ok(f) => f,
385        Err(e) => {
386            return formatter.fail(
387                ExitCode::GeneralError,
388                &format!("Failed to read directory: {e}"),
389            );
390        }
391    };
392
393    for (file_path, relative_path) in files {
394        // Build destination key
395        let dst_key = if dst.key.is_empty() {
396            relative_path.replace('\\', "/")
397        } else if dst.key.ends_with('/') {
398            format!("{}{}", dst.key, relative_path.replace('\\', "/"))
399        } else {
400            format!("{}/{}", dst.key, relative_path.replace('\\', "/"))
401        };
402
403        let target = RemotePath::new(&dst.alias, &dst.bucket, &dst_key);
404
405        let result = upload_file(client, &file_path, &target, args, formatter).await;
406
407        if result == ExitCode::Success {
408            success_count += 1;
409        } else {
410            error_count += 1;
411            if !args.continue_on_error {
412                return result;
413            }
414        }
415    }
416
417    if error_count > 0 {
418        formatter.warning(&format!(
419            "Completed with errors: {success_count} succeeded, {error_count} failed"
420        ));
421        ExitCode::GeneralError
422    } else {
423        if !formatter.is_json() {
424            formatter.success(&format!("Uploaded {success_count} file(s)."));
425        }
426        ExitCode::Success
427    }
428}
429
430async fn copy_s3_to_local(
431    src: &RemotePath,
432    dst: &Path,
433    args: &CpArgs,
434    formatter: &Formatter,
435) -> ExitCode {
436    // Load alias and create client
437    let alias_manager = match AliasManager::new() {
438        Ok(am) => am,
439        Err(e) => {
440            formatter.error(&format!("Failed to load aliases: {e}"));
441            return ExitCode::GeneralError;
442        }
443    };
444
445    let alias = match alias_manager.get(&src.alias) {
446        Ok(a) => a,
447        Err(_) => {
448            return formatter.fail_with_suggestion(
449                ExitCode::NotFound,
450                &format!("Alias '{}' not found", src.alias),
451                "Run `rc alias list` to inspect configured aliases or add one with `rc alias set ...`.",
452            );
453        }
454    };
455
456    let client = match S3Client::new(alias).await {
457        Ok(c) => c,
458        Err(e) => {
459            return formatter.fail(
460                ExitCode::NetworkError,
461                &format!("Failed to create S3 client: {e}"),
462            );
463        }
464    };
465
466    // Check if source is a prefix (directory-like)
467    let is_prefix = src.key.is_empty() || src.key.ends_with('/');
468
469    if is_prefix || args.recursive {
470        // Download multiple objects
471        download_prefix(&client, src, dst, args, formatter).await
472    } else {
473        // Download single object
474        download_file(&client, src, dst, args, formatter).await
475    }
476}
477
478async fn download_file(
479    client: &S3Client,
480    src: &RemotePath,
481    dst: &Path,
482    args: &CpArgs,
483    formatter: &Formatter,
484) -> ExitCode {
485    let src_display = format!("{}/{}/{}", src.alias, src.bucket, src.key);
486
487    // Determine destination path
488    let dst_path = if dst.is_dir() || dst.to_string_lossy().ends_with('/') {
489        let filename = src.key.rsplit('/').next().unwrap_or(&src.key);
490        dst.join(filename)
491    } else {
492        dst.to_path_buf()
493    };
494
495    let dst_display = dst_path.display().to_string();
496
497    if args.dry_run {
498        let styled_src = formatter.style_file(&src_display);
499        let styled_dst = formatter.style_file(&dst_display);
500        formatter.println(&format!("Would copy: {styled_src} -> {styled_dst}"));
501        return ExitCode::Success;
502    }
503
504    // Check if destination exists
505    if dst_path.exists() && !args.overwrite {
506        return formatter.fail_with_suggestion(
507            ExitCode::Conflict,
508            &format!("Destination exists: {dst_display}. Use --overwrite to replace."),
509            "Retry with --overwrite if replacing the destination file is intended.",
510        );
511    }
512
513    // Create parent directories
514    if let Some(parent) = dst_path.parent()
515        && !parent.exists()
516        && let Err(e) = std::fs::create_dir_all(parent)
517    {
518        return formatter.fail(
519            ExitCode::GeneralError,
520            &format!("Failed to create directory: {e}"),
521        );
522    }
523
524    let output_config = formatter.output_config();
525    let mut progress = None;
526
527    // Download object
528    let result = client
529        .get_object_with_progress(src, |bytes_downloaded, total_size| {
530            update_download_progress(&mut progress, &output_config, bytes_downloaded, total_size);
531        })
532        .await;
533
534    if let Some(ref pb) = progress {
535        pb.finish_and_clear();
536    }
537
538    match result {
539        Ok(data) => {
540            let size = data.len() as i64;
541
542            if let Err(e) = std::fs::write(&dst_path, &data) {
543                return formatter.fail(
544                    ExitCode::GeneralError,
545                    &format!("Failed to write {dst_display}: {e}"),
546                );
547            }
548
549            if formatter.is_json() {
550                let output = CpOutput {
551                    status: "success",
552                    source: src_display,
553                    target: dst_display,
554                    size_bytes: Some(size),
555                    size_human: Some(humansize::format_size(size as u64, humansize::BINARY)),
556                };
557                formatter.json(&output);
558            } else {
559                let styled_src = formatter.style_file(&src_display);
560                let styled_dst = formatter.style_file(&dst_display);
561                let styled_size =
562                    formatter.style_size(&humansize::format_size(size as u64, humansize::BINARY));
563                formatter.println(&format!("{styled_src} -> {styled_dst} ({styled_size})"));
564            }
565            ExitCode::Success
566        }
567        Err(e) => {
568            let err_str = e.to_string();
569            if err_str.contains("NotFound") || err_str.contains("NoSuchKey") {
570                formatter.fail_with_suggestion(
571                    ExitCode::NotFound,
572                    &format!("Object not found: {src_display}"),
573                    "Check the object key and bucket path, then retry the copy command.",
574                )
575            } else {
576                formatter.fail(
577                    ExitCode::NetworkError,
578                    &format!("Failed to download {src_display}: {e}"),
579                )
580            }
581        }
582    }
583}
584
585async fn download_prefix(
586    client: &S3Client,
587    src: &RemotePath,
588    dst: &Path,
589    args: &CpArgs,
590    formatter: &Formatter,
591) -> ExitCode {
592    use rc_core::ListOptions;
593
594    let mut success_count = 0;
595    let mut error_count = 0;
596    let mut continuation_token: Option<String> = None;
597
598    loop {
599        let options = ListOptions {
600            recursive: true,
601            max_keys: Some(1000),
602            continuation_token: continuation_token.clone(),
603            ..Default::default()
604        };
605
606        match client.list_objects(src, options).await {
607            Ok(result) => {
608                for item in result.items {
609                    if item.is_dir {
610                        continue;
611                    }
612
613                    // Calculate relative path from prefix
614                    let relative_key = item.key.strip_prefix(&src.key).unwrap_or(&item.key);
615                    let dst_path =
616                        dst.join(relative_key.replace('/', std::path::MAIN_SEPARATOR_STR));
617
618                    let obj_src = RemotePath::new(&src.alias, &src.bucket, &item.key);
619                    let result = download_file(client, &obj_src, &dst_path, args, formatter).await;
620
621                    if result == ExitCode::Success {
622                        success_count += 1;
623                    } else {
624                        error_count += 1;
625                        if !args.continue_on_error {
626                            return result;
627                        }
628                    }
629                }
630
631                if result.truncated {
632                    continuation_token = result.continuation_token;
633                } else {
634                    break;
635                }
636            }
637            Err(e) => {
638                return formatter.fail(
639                    ExitCode::NetworkError,
640                    &format!("Failed to list objects: {e}"),
641                );
642            }
643        }
644    }
645
646    if error_count > 0 {
647        formatter.warning(&format!(
648            "Completed with errors: {success_count} succeeded, {error_count} failed"
649        ));
650        ExitCode::GeneralError
651    } else if success_count == 0 {
652        formatter.warning("No objects found to download.");
653        ExitCode::Success
654    } else {
655        if !formatter.is_json() {
656            formatter.success(&format!("Downloaded {success_count} file(s)."));
657        }
658        ExitCode::Success
659    }
660}
661
662async fn copy_s3_to_s3(
663    src: &RemotePath,
664    dst: &RemotePath,
665    args: &CpArgs,
666    formatter: &Formatter,
667) -> ExitCode {
668    // For S3-to-S3, we need to handle same or different aliases
669    let alias_manager = match AliasManager::new() {
670        Ok(am) => am,
671        Err(e) => {
672            formatter.error(&format!("Failed to load aliases: {e}"));
673            return ExitCode::GeneralError;
674        }
675    };
676
677    // For now, only support same-alias copies (server-side copy)
678    if src.alias != dst.alias {
679        return formatter.fail_with_suggestion(
680            ExitCode::UnsupportedFeature,
681            "Cross-alias S3-to-S3 copy not yet supported. Use download + upload.",
682            "Copy via a local path or split the operation into download and upload steps.",
683        );
684    }
685
686    let alias = match alias_manager.get(&src.alias) {
687        Ok(a) => a,
688        Err(_) => {
689            return formatter.fail_with_suggestion(
690                ExitCode::NotFound,
691                &format!("Alias '{}' not found", src.alias),
692                "Run `rc alias list` to inspect configured aliases or add one with `rc alias set ...`.",
693            );
694        }
695    };
696
697    let client = match S3Client::new(alias).await {
698        Ok(c) => c,
699        Err(e) => {
700            return formatter.fail(
701                ExitCode::NetworkError,
702                &format!("Failed to create S3 client: {e}"),
703            );
704        }
705    };
706
707    let src_display = format!("{}/{}/{}", src.alias, src.bucket, src.key);
708    let dst_display = format!("{}/{}/{}", dst.alias, dst.bucket, dst.key);
709
710    if args.dry_run {
711        let styled_src = formatter.style_file(&src_display);
712        let styled_dst = formatter.style_file(&dst_display);
713        formatter.println(&format!("Would copy: {styled_src} -> {styled_dst}"));
714        return ExitCode::Success;
715    }
716
717    match client.copy_object(src, dst).await {
718        Ok(info) => {
719            if formatter.is_json() {
720                let output = CpOutput {
721                    status: "success",
722                    source: src_display,
723                    target: dst_display,
724                    size_bytes: info.size_bytes,
725                    size_human: info.size_human,
726                };
727                formatter.json(&output);
728            } else {
729                let styled_src = formatter.style_file(&src_display);
730                let styled_dst = formatter.style_file(&dst_display);
731                let styled_size = formatter.style_size(&info.size_human.unwrap_or_default());
732                formatter.println(&format!("{styled_src} -> {styled_dst} ({styled_size})"));
733            }
734            ExitCode::Success
735        }
736        Err(e) => {
737            let err_str = e.to_string();
738            if err_str.contains("NotFound") || err_str.contains("NoSuchKey") {
739                formatter.fail_with_suggestion(
740                    ExitCode::NotFound,
741                    &format!("Source not found: {src_display}"),
742                    "Check the source bucket and object key, then retry the copy command.",
743                )
744            } else {
745                formatter.fail(ExitCode::NetworkError, &format!("Failed to copy: {e}"))
746            }
747        }
748    }
749}
750
751#[cfg(test)]
752mod tests {
753    use super::*;
754    use rc_core::{Alias, ConfigManager};
755    use tempfile::TempDir;
756
757    fn temp_alias_manager() -> (AliasManager, TempDir) {
758        let temp_dir = TempDir::new().expect("create temp dir");
759        let config_path = temp_dir.path().join("config.toml");
760        let config_manager = ConfigManager::with_path(config_path);
761        let alias_manager = AliasManager::with_config_manager(config_manager);
762        (alias_manager, temp_dir)
763    }
764
765    #[test]
766    fn test_parse_local_path() {
767        let result = parse_path("./file.txt").unwrap();
768        assert!(matches!(result, ParsedPath::Local(_)));
769    }
770
771    #[test]
772    fn test_parse_remote_path() {
773        let result = parse_path("myalias/bucket/file.txt").unwrap();
774        assert!(matches!(result, ParsedPath::Remote(_)));
775    }
776
777    #[test]
778    fn test_parse_local_absolute_path() {
779        // Use platform-appropriate absolute path
780        #[cfg(unix)]
781        let path = "/home/user/file.txt";
782        #[cfg(windows)]
783        let path = "C:\\Users\\user\\file.txt";
784
785        let result = parse_path(path).unwrap();
786        assert!(matches!(result, ParsedPath::Local(_)));
787        if let ParsedPath::Local(p) = result {
788            assert!(p.is_absolute());
789        }
790    }
791
792    #[test]
793    fn test_parse_local_relative_path() {
794        let result = parse_path("../file.txt").unwrap();
795        assert!(matches!(result, ParsedPath::Local(_)));
796    }
797
798    #[test]
799    fn test_parse_remote_path_bucket_only() {
800        let result = parse_path("myalias/bucket/").unwrap();
801        assert!(matches!(result, ParsedPath::Remote(_)));
802        if let ParsedPath::Remote(r) = result {
803            assert_eq!(r.alias, "myalias");
804            assert_eq!(r.bucket, "bucket");
805            assert!(r.key.is_empty());
806        }
807    }
808
809    #[test]
810    fn test_parse_remote_path_with_deep_key() {
811        let result = parse_path("myalias/bucket/dir1/dir2/file.txt").unwrap();
812        assert!(matches!(result, ParsedPath::Remote(_)));
813        if let ParsedPath::Remote(r) = result {
814            assert_eq!(r.alias, "myalias");
815            assert_eq!(r.bucket, "bucket");
816            assert_eq!(r.key, "dir1/dir2/file.txt");
817        }
818    }
819
820    #[test]
821    fn test_download_progress_created_for_large_transfer() {
822        let output_config = OutputConfig::default();
823        let mut progress = None;
824
825        update_download_progress(
826            &mut progress,
827            &output_config,
828            1024,
829            Some(DOWNLOAD_PROGRESS_THRESHOLD),
830        );
831
832        let progress = progress.expect("large download should create progress bar");
833        assert!(progress.is_visible());
834        progress.finish_and_clear();
835    }
836
837    #[test]
838    fn test_download_progress_skips_small_transfer() {
839        let output_config = OutputConfig::default();
840        let mut progress = None;
841
842        update_download_progress(
843            &mut progress,
844            &output_config,
845            1024,
846            Some(DOWNLOAD_PROGRESS_THRESHOLD - 1),
847        );
848
849        assert!(progress.is_none());
850    }
851
852    #[test]
853    fn test_download_progress_skips_unknown_total_size() {
854        let output_config = OutputConfig::default();
855        let mut progress = None;
856
857        update_download_progress(&mut progress, &output_config, 1024, None);
858
859        assert!(progress.is_none());
860    }
861
862    #[test]
863    fn test_download_progress_respects_no_progress_config() {
864        let output_config = OutputConfig {
865            no_progress: true,
866            ..Default::default()
867        };
868        let mut progress = None;
869
870        update_download_progress(
871            &mut progress,
872            &output_config,
873            1024,
874            Some(DOWNLOAD_PROGRESS_THRESHOLD),
875        );
876
877        let progress = progress.expect("large download should create progress state");
878        assert!(!progress.is_visible());
879    }
880
881    #[test]
882    fn test_select_upload_content_type_uses_guess_for_small_files() {
883        let selected =
884            select_upload_content_type(None, Some("text/plain"), MULTIPART_THRESHOLD - 1);
885
886        assert_eq!(selected, Some("text/plain"));
887    }
888
889    #[test]
890    fn test_select_upload_content_type_skips_guess_for_multipart_files() {
891        let selected =
892            select_upload_content_type(None, Some("text/plain"), MULTIPART_THRESHOLD + 1);
893
894        assert_eq!(selected, None);
895    }
896
897    #[test]
898    fn test_select_upload_content_type_uses_guess_at_multipart_boundary() {
899        let selected = select_upload_content_type(None, Some("text/plain"), MULTIPART_THRESHOLD);
900
901        assert_eq!(selected, Some("text/plain"));
902    }
903
904    #[test]
905    fn test_select_upload_content_type_keeps_explicit_type_for_multipart_files() {
906        let selected = select_upload_content_type(
907            Some("application/octet-stream"),
908            Some("text/plain"),
909            MULTIPART_THRESHOLD + 1,
910        );
911
912        assert_eq!(selected, Some("application/octet-stream"));
913    }
914
915    #[test]
916    fn test_parse_cp_path_prefers_existing_local_path_when_alias_missing() {
917        let (alias_manager, temp_dir) = temp_alias_manager();
918        let full = temp_dir.path().join("issue-2094-local").join("file.txt");
919        let full_str = full.to_string_lossy().to_string();
920
921        if let Some(parent) = full.parent() {
922            std::fs::create_dir_all(parent).expect("create parent dirs");
923        }
924        std::fs::write(&full, b"test").expect("write local file");
925
926        let parsed = parse_cp_path(&full_str, Some(&alias_manager)).expect("parse path");
927        assert!(matches!(parsed, ParsedPath::Local(_)));
928    }
929
930    #[test]
931    fn test_parse_cp_path_keeps_remote_when_alias_exists() {
932        let (alias_manager, _temp_dir) = temp_alias_manager();
933        alias_manager
934            .set(Alias::new("target", "http://localhost:9000", "a", "b"))
935            .expect("set alias");
936
937        let parsed = parse_cp_path("target/bucket/file.txt", Some(&alias_manager))
938            .expect("parse remote path");
939        assert!(matches!(parsed, ParsedPath::Remote(_)));
940    }
941
942    #[test]
943    fn test_parse_cp_path_keeps_remote_when_local_missing() {
944        let (alias_manager, _temp_dir) = temp_alias_manager();
945        let parsed = parse_cp_path("missing/bucket/file.txt", Some(&alias_manager))
946            .expect("parse remote path");
947        assert!(matches!(parsed, ParsedPath::Remote(_)));
948    }
949
950    #[test]
951    fn test_cp_args_defaults() {
952        let args = CpArgs {
953            source: "src".to_string(),
954            target: "dst".to_string(),
955            recursive: false,
956            preserve: false,
957            continue_on_error: false,
958            overwrite: true,
959            dry_run: false,
960            storage_class: None,
961            content_type: None,
962        };
963        assert!(args.overwrite);
964        assert!(!args.recursive);
965        assert!(!args.dry_run);
966    }
967
968    #[test]
969    fn test_cp_output_serialization() {
970        let output = CpOutput {
971            status: "success",
972            source: "src/file.txt".to_string(),
973            target: "dst/file.txt".to_string(),
974            size_bytes: Some(1024),
975            size_human: Some("1 KiB".to_string()),
976        };
977        let json = serde_json::to_string(&output).unwrap();
978        assert!(json.contains("\"status\":\"success\""));
979        assert!(json.contains("\"size_bytes\":1024"));
980    }
981
982    #[test]
983    fn test_cp_output_skips_none_fields() {
984        let output = CpOutput {
985            status: "success",
986            source: "src".to_string(),
987            target: "dst".to_string(),
988            size_bytes: None,
989            size_human: None,
990        };
991        let json = serde_json::to_string(&output).unwrap();
992        assert!(!json.contains("size_bytes"));
993        assert!(!json.contains("size_human"));
994    }
995}