1use 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#[derive(Args, Debug)]
25#[command(after_help = CP_AFTER_HELP)]
26pub struct CpArgs {
27 pub source: String,
29
30 pub target: String,
32
33 #[arg(short, long)]
35 pub recursive: bool,
36
37 #[arg(short, long)]
39 pub preserve: bool,
40
41 #[arg(long)]
43 pub continue_on_error: bool,
44
45 #[arg(long, default_value = "true")]
47 pub overwrite: bool,
48
49 #[arg(long)]
51 pub dry_run: bool,
52
53 #[arg(long)]
55 pub storage_class: Option<String>,
56
57 #[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
73pub 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 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 match (&source, &target) {
103 (ParsedPath::Local(src), ParsedPath::Remote(dst)) => {
104 copy_local_to_s3(src, dst, &args, &formatter).await
106 }
107 (ParsedPath::Remote(src), ParsedPath::Local(dst)) => {
108 copy_s3_to_local(src, dst, &args, &formatter).await
110 }
111 (ParsedPath::Remote(src), ParsedPath::Remote(dst)) => {
112 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 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 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 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 upload_file(&client, src, dst, args, formatter).await
200 } else {
201 upload_directory(&client, src, dst, args, formatter).await
203 }
204}
205
206const MULTIPART_THRESHOLD: u64 = 64 * 1024 * 1024;
208
209fn print_upload_success(
210 formatter: &Formatter,
211 info: &rc_core::ObjectInfo,
212 src_display: &str,
213 dst_display: &str,
214) {
215 if formatter.is_json() {
216 let output = CpOutput {
217 status: "success",
218 source: src_display.to_string(),
219 target: dst_display.to_string(),
220 size_bytes: info.size_bytes,
221 size_human: info.size_human.clone(),
222 };
223 formatter.json(&output);
224 } else {
225 let styled_src = formatter.style_file(src_display);
226 let styled_dst = formatter.style_file(dst_display);
227 let styled_size = formatter.style_size(&info.size_human.clone().unwrap_or_default());
228 formatter.println(&format!("{styled_src} -> {styled_dst} ({styled_size})"));
229 }
230}
231
232async fn upload_file(
233 client: &S3Client,
234 src: &Path,
235 dst: &RemotePath,
236 args: &CpArgs,
237 formatter: &Formatter,
238) -> ExitCode {
239 let dst_key = if dst.key.is_empty() || dst.key.ends_with('/') {
241 let filename = src.file_name().unwrap_or_default().to_string_lossy();
243 format!("{}{}", dst.key, filename)
244 } else {
245 dst.key.clone()
246 };
247
248 let target = RemotePath::new(&dst.alias, &dst.bucket, &dst_key);
249 let src_display = src.display().to_string();
250 let dst_display = format!("{}/{}/{}", dst.alias, dst.bucket, dst_key);
251
252 if args.dry_run {
253 let styled_src = formatter.style_file(&src_display);
254 let styled_dst = formatter.style_file(&dst_display);
255 formatter.println(&format!("Would copy: {styled_src} -> {styled_dst}"));
256 return ExitCode::Success;
257 }
258
259 let guessed_type: Option<String> = mime_guess::from_path(src)
261 .first()
262 .map(|m| m.essence_str().to_string());
263 let content_type = args.content_type.as_deref().or(guessed_type.as_deref());
264
265 let file_size = match std::fs::metadata(src) {
267 Ok(m) => m.len(),
268 Err(e) => {
269 return formatter.fail(
270 ExitCode::GeneralError,
271 &format!("Failed to read {src_display}: {e}"),
272 );
273 }
274 };
275
276 let progress = if file_size >= MULTIPART_THRESHOLD {
278 tracing::debug!(
279 file_size,
280 threshold = MULTIPART_THRESHOLD,
281 "Using multipart upload for large file"
282 );
283 Some(ProgressBar::new(formatter.output_config(), file_size))
284 } else {
285 tracing::debug!(file_size, "Using single put_object for small file");
286 None
287 };
288
289 match client
291 .put_object_from_path(&target, src, content_type, |bytes_sent| {
292 if let Some(ref pb) = progress {
293 pb.set_position(bytes_sent);
294 }
295 })
296 .await
297 {
298 Ok(info) => {
299 if let Some(ref pb) = progress {
300 pb.finish_and_clear();
301 }
302 print_upload_success(formatter, &info, &src_display, &dst_display);
303 ExitCode::Success
304 }
305 Err(e) => {
306 if let Some(ref pb) = progress {
307 pb.finish_and_clear();
308 }
309 formatter.fail(
310 ExitCode::NetworkError,
311 &format!("Failed to upload {src_display}: {e}"),
312 )
313 }
314 }
315}
316
317async fn upload_directory(
318 client: &S3Client,
319 src: &Path,
320 dst: &RemotePath,
321 args: &CpArgs,
322 formatter: &Formatter,
323) -> ExitCode {
324 use std::fs;
325
326 let mut success_count = 0;
327 let mut error_count = 0;
328
329 fn walk_dir(dir: &Path, base: &Path) -> std::io::Result<Vec<(std::path::PathBuf, String)>> {
331 let mut files = Vec::new();
332 for entry in fs::read_dir(dir)? {
333 let entry = entry?;
334 let path = entry.path();
335 if path.is_file() {
336 let relative = path.strip_prefix(base).unwrap_or(&path);
337 let relative_str = relative.to_string_lossy().to_string();
338 files.push((path, relative_str));
339 } else if path.is_dir() {
340 files.extend(walk_dir(&path, base)?);
341 }
342 }
343 Ok(files)
344 }
345
346 let files = match walk_dir(src, src) {
347 Ok(f) => f,
348 Err(e) => {
349 return formatter.fail(
350 ExitCode::GeneralError,
351 &format!("Failed to read directory: {e}"),
352 );
353 }
354 };
355
356 for (file_path, relative_path) in files {
357 let dst_key = if dst.key.is_empty() {
359 relative_path.replace('\\', "/")
360 } else if dst.key.ends_with('/') {
361 format!("{}{}", dst.key, relative_path.replace('\\', "/"))
362 } else {
363 format!("{}/{}", dst.key, relative_path.replace('\\', "/"))
364 };
365
366 let target = RemotePath::new(&dst.alias, &dst.bucket, &dst_key);
367
368 let result = upload_file(client, &file_path, &target, args, formatter).await;
369
370 if result == ExitCode::Success {
371 success_count += 1;
372 } else {
373 error_count += 1;
374 if !args.continue_on_error {
375 return result;
376 }
377 }
378 }
379
380 if error_count > 0 {
381 formatter.warning(&format!(
382 "Completed with errors: {success_count} succeeded, {error_count} failed"
383 ));
384 ExitCode::GeneralError
385 } else {
386 if !formatter.is_json() {
387 formatter.success(&format!("Uploaded {success_count} file(s)."));
388 }
389 ExitCode::Success
390 }
391}
392
393async fn copy_s3_to_local(
394 src: &RemotePath,
395 dst: &Path,
396 args: &CpArgs,
397 formatter: &Formatter,
398) -> ExitCode {
399 let alias_manager = match AliasManager::new() {
401 Ok(am) => am,
402 Err(e) => {
403 formatter.error(&format!("Failed to load aliases: {e}"));
404 return ExitCode::GeneralError;
405 }
406 };
407
408 let alias = match alias_manager.get(&src.alias) {
409 Ok(a) => a,
410 Err(_) => {
411 return formatter.fail_with_suggestion(
412 ExitCode::NotFound,
413 &format!("Alias '{}' not found", src.alias),
414 "Run `rc alias list` to inspect configured aliases or add one with `rc alias set ...`.",
415 );
416 }
417 };
418
419 let client = match S3Client::new(alias).await {
420 Ok(c) => c,
421 Err(e) => {
422 return formatter.fail(
423 ExitCode::NetworkError,
424 &format!("Failed to create S3 client: {e}"),
425 );
426 }
427 };
428
429 let is_prefix = src.key.is_empty() || src.key.ends_with('/');
431
432 if is_prefix || args.recursive {
433 download_prefix(&client, src, dst, args, formatter).await
435 } else {
436 download_file(&client, src, dst, args, formatter).await
438 }
439}
440
441async fn download_file(
442 client: &S3Client,
443 src: &RemotePath,
444 dst: &Path,
445 args: &CpArgs,
446 formatter: &Formatter,
447) -> ExitCode {
448 let src_display = format!("{}/{}/{}", src.alias, src.bucket, src.key);
449
450 let dst_path = if dst.is_dir() || dst.to_string_lossy().ends_with('/') {
452 let filename = src.key.rsplit('/').next().unwrap_or(&src.key);
453 dst.join(filename)
454 } else {
455 dst.to_path_buf()
456 };
457
458 let dst_display = dst_path.display().to_string();
459
460 if args.dry_run {
461 let styled_src = formatter.style_file(&src_display);
462 let styled_dst = formatter.style_file(&dst_display);
463 formatter.println(&format!("Would copy: {styled_src} -> {styled_dst}"));
464 return ExitCode::Success;
465 }
466
467 if dst_path.exists() && !args.overwrite {
469 return formatter.fail_with_suggestion(
470 ExitCode::Conflict,
471 &format!("Destination exists: {dst_display}. Use --overwrite to replace."),
472 "Retry with --overwrite if replacing the destination file is intended.",
473 );
474 }
475
476 if let Some(parent) = dst_path.parent()
478 && !parent.exists()
479 && let Err(e) = std::fs::create_dir_all(parent)
480 {
481 return formatter.fail(
482 ExitCode::GeneralError,
483 &format!("Failed to create directory: {e}"),
484 );
485 }
486
487 match client.get_object(src).await {
489 Ok(data) => {
490 let size = data.len() as i64;
491
492 if let Err(e) = std::fs::write(&dst_path, &data) {
493 return formatter.fail(
494 ExitCode::GeneralError,
495 &format!("Failed to write {dst_display}: {e}"),
496 );
497 }
498
499 if formatter.is_json() {
500 let output = CpOutput {
501 status: "success",
502 source: src_display,
503 target: dst_display,
504 size_bytes: Some(size),
505 size_human: Some(humansize::format_size(size as u64, humansize::BINARY)),
506 };
507 formatter.json(&output);
508 } else {
509 let styled_src = formatter.style_file(&src_display);
510 let styled_dst = formatter.style_file(&dst_display);
511 let styled_size =
512 formatter.style_size(&humansize::format_size(size as u64, humansize::BINARY));
513 formatter.println(&format!("{styled_src} -> {styled_dst} ({styled_size})"));
514 }
515 ExitCode::Success
516 }
517 Err(e) => {
518 let err_str = e.to_string();
519 if err_str.contains("NotFound") || err_str.contains("NoSuchKey") {
520 formatter.fail_with_suggestion(
521 ExitCode::NotFound,
522 &format!("Object not found: {src_display}"),
523 "Check the object key and bucket path, then retry the copy command.",
524 )
525 } else {
526 formatter.fail(
527 ExitCode::NetworkError,
528 &format!("Failed to download {src_display}: {e}"),
529 )
530 }
531 }
532 }
533}
534
535async fn download_prefix(
536 client: &S3Client,
537 src: &RemotePath,
538 dst: &Path,
539 args: &CpArgs,
540 formatter: &Formatter,
541) -> ExitCode {
542 use rc_core::ListOptions;
543
544 let mut success_count = 0;
545 let mut error_count = 0;
546 let mut continuation_token: Option<String> = None;
547
548 loop {
549 let options = ListOptions {
550 recursive: true,
551 max_keys: Some(1000),
552 continuation_token: continuation_token.clone(),
553 ..Default::default()
554 };
555
556 match client.list_objects(src, options).await {
557 Ok(result) => {
558 for item in result.items {
559 if item.is_dir {
560 continue;
561 }
562
563 let relative_key = item.key.strip_prefix(&src.key).unwrap_or(&item.key);
565 let dst_path =
566 dst.join(relative_key.replace('/', std::path::MAIN_SEPARATOR_STR));
567
568 let obj_src = RemotePath::new(&src.alias, &src.bucket, &item.key);
569 let result = download_file(client, &obj_src, &dst_path, args, formatter).await;
570
571 if result == ExitCode::Success {
572 success_count += 1;
573 } else {
574 error_count += 1;
575 if !args.continue_on_error {
576 return result;
577 }
578 }
579 }
580
581 if result.truncated {
582 continuation_token = result.continuation_token;
583 } else {
584 break;
585 }
586 }
587 Err(e) => {
588 return formatter.fail(
589 ExitCode::NetworkError,
590 &format!("Failed to list objects: {e}"),
591 );
592 }
593 }
594 }
595
596 if error_count > 0 {
597 formatter.warning(&format!(
598 "Completed with errors: {success_count} succeeded, {error_count} failed"
599 ));
600 ExitCode::GeneralError
601 } else if success_count == 0 {
602 formatter.warning("No objects found to download.");
603 ExitCode::Success
604 } else {
605 if !formatter.is_json() {
606 formatter.success(&format!("Downloaded {success_count} file(s)."));
607 }
608 ExitCode::Success
609 }
610}
611
612async fn copy_s3_to_s3(
613 src: &RemotePath,
614 dst: &RemotePath,
615 args: &CpArgs,
616 formatter: &Formatter,
617) -> ExitCode {
618 let alias_manager = match AliasManager::new() {
620 Ok(am) => am,
621 Err(e) => {
622 formatter.error(&format!("Failed to load aliases: {e}"));
623 return ExitCode::GeneralError;
624 }
625 };
626
627 if src.alias != dst.alias {
629 return formatter.fail_with_suggestion(
630 ExitCode::UnsupportedFeature,
631 "Cross-alias S3-to-S3 copy not yet supported. Use download + upload.",
632 "Copy via a local path or split the operation into download and upload steps.",
633 );
634 }
635
636 let alias = match alias_manager.get(&src.alias) {
637 Ok(a) => a,
638 Err(_) => {
639 return formatter.fail_with_suggestion(
640 ExitCode::NotFound,
641 &format!("Alias '{}' not found", src.alias),
642 "Run `rc alias list` to inspect configured aliases or add one with `rc alias set ...`.",
643 );
644 }
645 };
646
647 let client = match S3Client::new(alias).await {
648 Ok(c) => c,
649 Err(e) => {
650 return formatter.fail(
651 ExitCode::NetworkError,
652 &format!("Failed to create S3 client: {e}"),
653 );
654 }
655 };
656
657 let src_display = format!("{}/{}/{}", src.alias, src.bucket, src.key);
658 let dst_display = format!("{}/{}/{}", dst.alias, dst.bucket, dst.key);
659
660 if args.dry_run {
661 let styled_src = formatter.style_file(&src_display);
662 let styled_dst = formatter.style_file(&dst_display);
663 formatter.println(&format!("Would copy: {styled_src} -> {styled_dst}"));
664 return ExitCode::Success;
665 }
666
667 match client.copy_object(src, dst).await {
668 Ok(info) => {
669 if formatter.is_json() {
670 let output = CpOutput {
671 status: "success",
672 source: src_display,
673 target: dst_display,
674 size_bytes: info.size_bytes,
675 size_human: info.size_human,
676 };
677 formatter.json(&output);
678 } else {
679 let styled_src = formatter.style_file(&src_display);
680 let styled_dst = formatter.style_file(&dst_display);
681 let styled_size = formatter.style_size(&info.size_human.unwrap_or_default());
682 formatter.println(&format!("{styled_src} -> {styled_dst} ({styled_size})"));
683 }
684 ExitCode::Success
685 }
686 Err(e) => {
687 let err_str = e.to_string();
688 if err_str.contains("NotFound") || err_str.contains("NoSuchKey") {
689 formatter.fail_with_suggestion(
690 ExitCode::NotFound,
691 &format!("Source not found: {src_display}"),
692 "Check the source bucket and object key, then retry the copy command.",
693 )
694 } else {
695 formatter.fail(ExitCode::NetworkError, &format!("Failed to copy: {e}"))
696 }
697 }
698 }
699}
700
701#[cfg(test)]
702mod tests {
703 use super::*;
704 use rc_core::{Alias, ConfigManager};
705 use tempfile::TempDir;
706
707 fn temp_alias_manager() -> (AliasManager, TempDir) {
708 let temp_dir = TempDir::new().expect("create temp dir");
709 let config_path = temp_dir.path().join("config.toml");
710 let config_manager = ConfigManager::with_path(config_path);
711 let alias_manager = AliasManager::with_config_manager(config_manager);
712 (alias_manager, temp_dir)
713 }
714
715 #[test]
716 fn test_parse_local_path() {
717 let result = parse_path("./file.txt").unwrap();
718 assert!(matches!(result, ParsedPath::Local(_)));
719 }
720
721 #[test]
722 fn test_parse_remote_path() {
723 let result = parse_path("myalias/bucket/file.txt").unwrap();
724 assert!(matches!(result, ParsedPath::Remote(_)));
725 }
726
727 #[test]
728 fn test_parse_local_absolute_path() {
729 #[cfg(unix)]
731 let path = "/home/user/file.txt";
732 #[cfg(windows)]
733 let path = "C:\\Users\\user\\file.txt";
734
735 let result = parse_path(path).unwrap();
736 assert!(matches!(result, ParsedPath::Local(_)));
737 if let ParsedPath::Local(p) = result {
738 assert!(p.is_absolute());
739 }
740 }
741
742 #[test]
743 fn test_parse_local_relative_path() {
744 let result = parse_path("../file.txt").unwrap();
745 assert!(matches!(result, ParsedPath::Local(_)));
746 }
747
748 #[test]
749 fn test_parse_remote_path_bucket_only() {
750 let result = parse_path("myalias/bucket/").unwrap();
751 assert!(matches!(result, ParsedPath::Remote(_)));
752 if let ParsedPath::Remote(r) = result {
753 assert_eq!(r.alias, "myalias");
754 assert_eq!(r.bucket, "bucket");
755 assert!(r.key.is_empty());
756 }
757 }
758
759 #[test]
760 fn test_parse_remote_path_with_deep_key() {
761 let result = parse_path("myalias/bucket/dir1/dir2/file.txt").unwrap();
762 assert!(matches!(result, ParsedPath::Remote(_)));
763 if let ParsedPath::Remote(r) = result {
764 assert_eq!(r.alias, "myalias");
765 assert_eq!(r.bucket, "bucket");
766 assert_eq!(r.key, "dir1/dir2/file.txt");
767 }
768 }
769
770 #[test]
771 fn test_parse_cp_path_prefers_existing_local_path_when_alias_missing() {
772 let (alias_manager, temp_dir) = temp_alias_manager();
773 let full = temp_dir.path().join("issue-2094-local").join("file.txt");
774 let full_str = full.to_string_lossy().to_string();
775
776 if let Some(parent) = full.parent() {
777 std::fs::create_dir_all(parent).expect("create parent dirs");
778 }
779 std::fs::write(&full, b"test").expect("write local file");
780
781 let parsed = parse_cp_path(&full_str, Some(&alias_manager)).expect("parse path");
782 assert!(matches!(parsed, ParsedPath::Local(_)));
783 }
784
785 #[test]
786 fn test_parse_cp_path_keeps_remote_when_alias_exists() {
787 let (alias_manager, _temp_dir) = temp_alias_manager();
788 alias_manager
789 .set(Alias::new("target", "http://localhost:9000", "a", "b"))
790 .expect("set alias");
791
792 let parsed = parse_cp_path("target/bucket/file.txt", Some(&alias_manager))
793 .expect("parse remote path");
794 assert!(matches!(parsed, ParsedPath::Remote(_)));
795 }
796
797 #[test]
798 fn test_parse_cp_path_keeps_remote_when_local_missing() {
799 let (alias_manager, _temp_dir) = temp_alias_manager();
800 let parsed = parse_cp_path("missing/bucket/file.txt", Some(&alias_manager))
801 .expect("parse remote path");
802 assert!(matches!(parsed, ParsedPath::Remote(_)));
803 }
804
805 #[test]
806 fn test_cp_args_defaults() {
807 let args = CpArgs {
808 source: "src".to_string(),
809 target: "dst".to_string(),
810 recursive: false,
811 preserve: false,
812 continue_on_error: false,
813 overwrite: true,
814 dry_run: false,
815 storage_class: None,
816 content_type: None,
817 };
818 assert!(args.overwrite);
819 assert!(!args.recursive);
820 assert!(!args.dry_run);
821 }
822
823 #[test]
824 fn test_cp_output_serialization() {
825 let output = CpOutput {
826 status: "success",
827 source: "src/file.txt".to_string(),
828 target: "dst/file.txt".to_string(),
829 size_bytes: Some(1024),
830 size_human: Some("1 KiB".to_string()),
831 };
832 let json = serde_json::to_string(&output).unwrap();
833 assert!(json.contains("\"status\":\"success\""));
834 assert!(json.contains("\"size_bytes\":1024"));
835 }
836
837 #[test]
838 fn test_cp_output_skips_none_fields() {
839 let output = CpOutput {
840 status: "success",
841 source: "src".to_string(),
842 target: "dst".to_string(),
843 size_bytes: None,
844 size_human: None,
845 };
846 let json = serde_json::to_string(&output).unwrap();
847 assert!(!json.contains("size_bytes"));
848 assert!(!json.contains("size_human"));
849 }
850}