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