1use clap::{Args, ValueEnum};
6use rc_core::{
7 AliasManager, ListOptions, ObjectInfo, ObjectStore as _, ParsedPath, RemotePath, parse_path,
8};
9use rc_s3::S3Client;
10use serde::Serialize;
11use std::collections::HashMap;
12use std::path::Path;
13
14use super::object_identity::identity_etag_from_metadata;
15use crate::exit_code::ExitCode;
16use crate::output::{Formatter, OutputConfig};
17
18#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, ValueEnum)]
23pub enum CompareMode {
24 #[default]
27 Auto,
28 Etag,
30 Size,
32}
33
34#[derive(Args, Debug)]
36pub struct DiffArgs {
37 pub first: String,
39
40 pub second: String,
42
43 #[arg(short, long)]
45 pub recursive: bool,
46
47 #[arg(long)]
49 pub diff_only: bool,
50
51 #[arg(long, value_enum, default_value_t = CompareMode::Auto)]
53 pub compare: CompareMode,
54}
55
56#[derive(Debug, Serialize, Clone)]
57pub struct DiffEntry {
58 pub key: String,
59 pub status: DiffStatus,
60 #[serde(skip_serializing_if = "Option::is_none")]
61 pub first_size: Option<i64>,
62 #[serde(skip_serializing_if = "Option::is_none")]
63 pub second_size: Option<i64>,
64 #[serde(skip_serializing_if = "Option::is_none")]
65 pub first_modified: Option<String>,
66 #[serde(skip_serializing_if = "Option::is_none")]
67 pub second_modified: Option<String>,
68}
69
70#[derive(Debug, Serialize, Clone, PartialEq, Eq)]
71#[serde(rename_all = "lowercase")]
72pub enum DiffStatus {
73 Same,
74 Different,
75 OnlyFirst,
76 OnlySecond,
77}
78
79#[derive(Debug, Serialize)]
80struct DiffOutput {
81 first: String,
82 second: String,
83 entries: Vec<DiffEntry>,
84 summary: DiffSummary,
85}
86
87#[derive(Debug, Serialize)]
88struct DiffSummary {
89 same: usize,
90 different: usize,
91 only_first: usize,
92 only_second: usize,
93 total: usize,
94}
95
96#[derive(Debug, Clone)]
97struct FileInfo {
98 key: String,
100 size: Option<i64>,
101 modified: Option<String>,
102 etag: Option<String>,
103 identity_etag: Option<String>,
106 snapshot_conflict: bool,
108}
109
110pub async fn execute(args: DiffArgs, output_config: OutputConfig) -> ExitCode {
112 let formatter = Formatter::new(output_config);
113
114 let first_parsed = parse_path(&args.first);
116 let second_parsed = parse_path(&args.second);
117
118 let (first_path, second_path) = match (&first_parsed, &second_parsed) {
120 (Ok(ParsedPath::Remote(f)), Ok(ParsedPath::Remote(s))) => (f.clone(), s.clone()),
121 (Ok(ParsedPath::Local(_)), _) | (_, Ok(ParsedPath::Local(_))) => {
122 formatter.error("Local paths are not yet supported in diff command");
123 return ExitCode::UsageError;
124 }
125 (Err(e), _) => {
126 formatter.error(&format!("Invalid first path: {e}"));
127 return ExitCode::UsageError;
128 }
129 (_, Err(e)) => {
130 formatter.error(&format!("Invalid second path: {e}"));
131 return ExitCode::UsageError;
132 }
133 };
134
135 let alias_manager = match AliasManager::new() {
137 Ok(am) => am,
138 Err(e) => {
139 formatter.error(&format!("Failed to load aliases: {e}"));
140 return ExitCode::GeneralError;
141 }
142 };
143
144 let first_alias = match alias_manager.get(&first_path.alias) {
146 Ok(a) => a,
147 Err(_) => {
148 formatter.error(&format!("Alias '{}' not found", first_path.alias));
149 return ExitCode::NotFound;
150 }
151 };
152
153 let second_alias = match alias_manager.get(&second_path.alias) {
154 Ok(a) => a,
155 Err(_) => {
156 formatter.error(&format!("Alias '{}' not found", second_path.alias));
157 return ExitCode::NotFound;
158 }
159 };
160
161 let first_client = match S3Client::new(first_alias).await {
162 Ok(c) => c,
163 Err(e) => {
164 formatter.error(&format!("Failed to create client for first path: {e}"));
165 return ExitCode::NetworkError;
166 }
167 };
168
169 let second_client = match S3Client::new(second_alias).await {
170 Ok(c) => c,
171 Err(e) => {
172 formatter.error(&format!("Failed to create client for second path: {e}"));
173 return ExitCode::NetworkError;
174 }
175 };
176
177 let first_objects = match list_objects_map(&first_client, &first_path, args.recursive).await {
179 Ok(o) => o,
180 Err(e) => {
181 formatter.error(&format!("Failed to list first path: {e}"));
182 return ExitCode::NetworkError;
183 }
184 };
185
186 let mut second_objects =
187 match list_objects_map(&second_client, &second_path, args.recursive).await {
188 Ok(o) => o,
189 Err(e) => {
190 formatter.error(&format!("Failed to list second path: {e}"));
191 return ExitCode::NetworkError;
192 }
193 };
194
195 enrich_second_identity(
196 &second_client,
197 &second_path,
198 &first_objects,
199 &mut second_objects,
200 args.compare,
201 )
202 .await;
203
204 let entries = compare_objects(
206 &first_objects,
207 &second_objects,
208 args.diff_only,
209 args.compare,
210 );
211
212 let mut summary = DiffSummary {
214 same: 0,
215 different: 0,
216 only_first: 0,
217 only_second: 0,
218 total: entries.len(),
219 };
220
221 for entry in &entries {
222 match entry.status {
223 DiffStatus::Same => summary.same += 1,
224 DiffStatus::Different => summary.different += 1,
225 DiffStatus::OnlyFirst => summary.only_first += 1,
226 DiffStatus::OnlySecond => summary.only_second += 1,
227 }
228 }
229
230 let has_differences =
232 summary.different > 0 || summary.only_first > 0 || summary.only_second > 0;
233
234 if formatter.is_json() {
235 let output = DiffOutput {
236 first: args.first.clone(),
237 second: args.second.clone(),
238 entries,
239 summary,
240 };
241 formatter.json(&output);
242 } else {
243 for entry in &entries {
245 let status_char = match entry.status {
246 DiffStatus::Same => "=",
247 DiffStatus::Different => "≠",
248 DiffStatus::OnlyFirst => "<",
249 DiffStatus::OnlySecond => ">",
250 };
251
252 let size_info = match entry.status {
253 DiffStatus::Same => entry.first_size.map(format_size).unwrap_or_default(),
254 DiffStatus::Different => {
255 let first = entry.first_size.map(format_size).unwrap_or_default();
256 let second = entry.second_size.map(format_size).unwrap_or_default();
257 format!("{first} → {second}")
258 }
259 DiffStatus::OnlyFirst => entry.first_size.map(format_size).unwrap_or_default(),
260 DiffStatus::OnlySecond => entry.second_size.map(format_size).unwrap_or_default(),
261 };
262
263 formatter.println(&format!(
264 "{status_char} {:<50} {size_info}",
265 formatter.sanitize_text(&entry.key)
266 ));
267 }
268
269 formatter.println("");
271 formatter.println(&format!(
272 "Summary: {} same, {} different, {} only in first, {} only in second",
273 summary.same, summary.different, summary.only_first, summary.only_second
274 ));
275 }
276
277 if has_differences {
279 ExitCode::GeneralError } else {
281 ExitCode::Success
282 }
283}
284
285async fn list_objects_map(
286 client: &S3Client,
287 path: &RemotePath,
288 recursive: bool,
289) -> Result<HashMap<String, FileInfo>, rc_core::Error> {
290 let mut objects = HashMap::new();
291 let mut continuation_token: Option<String> = None;
292 let base_prefix = &path.key;
293
294 loop {
295 let options = ListOptions {
296 recursive,
297 max_keys: Some(1000),
298 continuation_token: continuation_token.clone(),
299 ..Default::default()
300 };
301
302 let result = client.list_objects(path, options).await?;
303
304 for item in result.items {
305 if item.is_dir {
306 continue;
307 }
308
309 let relative_key = item.key.strip_prefix(base_prefix).unwrap_or(&item.key);
311 let relative_key = relative_key.trim_start_matches('/').to_string();
312
313 let map_key = if relative_key.is_empty() {
314 Path::new(&item.key)
316 .file_name()
317 .map(|s| s.to_string_lossy().to_string())
318 .unwrap_or_else(|| item.key.clone())
319 } else {
320 relative_key
321 };
322 objects.insert(
323 map_key,
324 FileInfo {
325 key: item.key,
326 size: item.size_bytes,
327 modified: item.last_modified.map(|t| t.to_string()),
328 etag: item.etag,
329 identity_etag: None,
330 snapshot_conflict: false,
331 },
332 );
333 }
334
335 if result.truncated {
336 continuation_token = result.continuation_token;
337 } else {
338 break;
339 }
340 }
341
342 Ok(objects)
343}
344
345fn objects_match(first: &FileInfo, second: &FileInfo, compare: CompareMode) -> bool {
352 if first.snapshot_conflict || second.snapshot_conflict {
353 return false;
354 }
355 let (Some(first_size), Some(second_size)) = (first.size, second.size) else {
356 return false;
357 };
358 if first_size != second_size {
359 return false;
360 }
361 match compare {
362 CompareMode::Size => true,
363 CompareMode::Etag => first.etag.is_some() && first.etag == second.etag,
364 CompareMode::Auto => {
365 if first.etag.is_some() && first.etag == second.etag {
366 return true;
367 }
368 first
369 .etag
370 .as_ref()
371 .zip(second.identity_etag.as_ref())
372 .is_some_and(|(first_etag, identity_etag)| first_etag == identity_etag)
373 }
374 }
375}
376
377fn second_needs_identity_lookup(first: &FileInfo, second: &FileInfo, compare: CompareMode) -> bool {
382 if !matches!(compare, CompareMode::Auto) {
383 return false;
384 }
385 if first.size.is_none() || first.size != second.size {
386 return false;
387 }
388 let Some(first_etag) = first.etag.as_ref() else {
389 return false;
390 };
391 if second.etag.as_ref() == Some(first_etag) {
392 return false;
393 }
394 second.identity_etag.is_none()
395}
396
397async fn enrich_second_identity(
402 client: &S3Client,
403 path: &RemotePath,
404 first: &HashMap<String, FileInfo>,
405 second: &mut HashMap<String, FileInfo>,
406 compare: CompareMode,
407) {
408 let pending: Vec<String> = second
409 .iter()
410 .filter(|(key, second_info)| {
411 first.get(*key).is_some_and(|first_info| {
412 second_needs_identity_lookup(first_info, second_info, compare)
413 })
414 })
415 .map(|(key, _)| key.clone())
416 .collect();
417
418 for map_key in pending {
419 let Some(second_info) = second.get(&map_key) else {
420 continue;
421 };
422 let object_path = RemotePath::new(&path.alias, &path.bucket, &second_info.key);
423 match client.head_object(&object_path).await {
424 Ok(info) => {
425 let listed_matches = second_snapshot_matches_head(second_info, &info);
426 if let Some(entry) = second.get_mut(&map_key) {
427 entry.snapshot_conflict = !listed_matches;
428 if listed_matches {
429 entry.identity_etag = identity_etag_from_metadata(info.metadata.as_ref());
430 }
431 }
432 }
433 Err(_) => {
434 if let Some(entry) = second.get_mut(&map_key) {
435 entry.snapshot_conflict = true;
436 }
437 }
438 }
439 }
440}
441
442fn second_snapshot_matches_head(listed: &FileInfo, head: &ObjectInfo) -> bool {
443 listed.size == head.size_bytes && listed.etag == head.etag
444}
445
446fn compare_objects(
447 first: &HashMap<String, FileInfo>,
448 second: &HashMap<String, FileInfo>,
449 diff_only: bool,
450 compare: CompareMode,
451) -> Vec<DiffEntry> {
452 let mut entries = Vec::new();
453
454 for (key, first_info) in first {
456 if let Some(second_info) = second.get(key) {
457 let status = if objects_match(first_info, second_info, compare) {
459 DiffStatus::Same
460 } else {
461 DiffStatus::Different
462 };
463
464 if !diff_only || status != DiffStatus::Same {
465 entries.push(DiffEntry {
466 key: key.clone(),
467 status,
468 first_size: first_info.size,
469 second_size: second_info.size,
470 first_modified: first_info.modified.clone(),
471 second_modified: second_info.modified.clone(),
472 });
473 }
474 } else {
475 entries.push(DiffEntry {
477 key: key.clone(),
478 status: DiffStatus::OnlyFirst,
479 first_size: first_info.size,
480 second_size: None,
481 first_modified: first_info.modified.clone(),
482 second_modified: None,
483 });
484 }
485 }
486
487 for (key, second_info) in second {
489 if !first.contains_key(key) {
490 entries.push(DiffEntry {
491 key: key.clone(),
492 status: DiffStatus::OnlySecond,
493 first_size: None,
494 second_size: second_info.size,
495 first_modified: None,
496 second_modified: second_info.modified.clone(),
497 });
498 }
499 }
500
501 entries.sort_by(|a, b| a.key.cmp(&b.key));
503 entries
504}
505
506fn format_size(size: i64) -> String {
507 humansize::format_size(size as u64, humansize::BINARY)
508}
509
510#[cfg(test)]
511mod tests {
512 use super::*;
513
514 fn entry(size: i64, etag: Option<&str>) -> FileInfo {
515 FileInfo {
516 key: "prefix/file.txt".to_string(),
517 size: Some(size),
518 modified: None,
519 etag: etag.map(ToOwned::to_owned),
520 identity_etag: None,
521 snapshot_conflict: false,
522 }
523 }
524
525 fn entry_with_identity(size: i64, etag: &str, identity_etag: &str) -> FileInfo {
526 FileInfo {
527 identity_etag: Some(identity_etag.to_string()),
528 ..entry(size, Some(etag))
529 }
530 }
531
532 fn one(key: &str, info: FileInfo) -> HashMap<String, FileInfo> {
533 HashMap::from([(key.to_string(), info)])
534 }
535
536 #[test]
537 fn test_compare_objects_same() {
538 let first = one("file.txt", entry(100, Some("abc123")));
539 let second = one("file.txt", entry(100, Some("abc123")));
540
541 let entries = compare_objects(&first, &second, false, CompareMode::Auto);
542 assert_eq!(entries.len(), 1);
543 assert_eq!(entries[0].status, DiffStatus::Same);
544 }
545
546 #[test]
547 fn test_compare_objects_different() {
548 let first = one("file.txt", entry(100, Some("abc123")));
549 let second = one("file.txt", entry(200, Some("def456")));
550
551 let entries = compare_objects(&first, &second, false, CompareMode::Auto);
552 assert_eq!(entries.len(), 1);
553 assert_eq!(entries[0].status, DiffStatus::Different);
554 }
555
556 #[test]
557 fn identity_head_must_match_the_listed_size_and_etag() {
558 let listed = entry(100, Some("listed-etag"));
559 let mut head = ObjectInfo::file("prefix/file.txt", 100);
560 head.etag = Some("listed-etag".to_string());
561 assert!(second_snapshot_matches_head(&listed, &head));
562
563 head.size_bytes = Some(101);
564 assert!(!second_snapshot_matches_head(&listed, &head));
565 head.size_bytes = Some(100);
566 head.etag = Some("changed-etag".to_string());
567 assert!(!second_snapshot_matches_head(&listed, &head));
568
569 let mut conflicted = listed.clone();
570 conflicted.snapshot_conflict = true;
571 assert!(!objects_match(
572 &entry(100, Some("listed-etag")),
573 &conflicted,
574 CompareMode::Auto
575 ));
576 }
577
578 #[test]
579 fn test_compare_objects_missing_etag_is_different() {
580 let first = one("file.txt", entry(100, None));
581 let second = one("file.txt", entry(100, Some("second-etag")));
582
583 let entries = compare_objects(&first, &second, false, CompareMode::Auto);
584
585 assert_eq!(entries[0].status, DiffStatus::Different);
586 }
587
588 #[test]
589 fn test_compare_objects_only_first() {
590 let first = one("file.txt", entry(100, None));
591 let second = HashMap::new();
592
593 let entries = compare_objects(&first, &second, false, CompareMode::Auto);
594 assert_eq!(entries.len(), 1);
595 assert_eq!(entries[0].status, DiffStatus::OnlyFirst);
596 }
597
598 #[test]
599 fn test_compare_objects_only_second() {
600 let first = HashMap::new();
601 let second = one("file.txt", entry(100, None));
602
603 let entries = compare_objects(&first, &second, false, CompareMode::Auto);
604 assert_eq!(entries.len(), 1);
605 assert_eq!(entries[0].status, DiffStatus::OnlySecond);
606 }
607
608 #[test]
609 fn auto_compare_treats_a_recorded_source_identity_as_same() {
610 let first = one("file.txt", entry(100, Some("source-etag")));
611 let second = one(
612 "file.txt",
613 entry_with_identity(100, "multipart-etag-1", "source-etag"),
614 );
615
616 let entries = compare_objects(&first, &second, false, CompareMode::Auto);
617
618 assert_eq!(
619 entries[0].status,
620 DiffStatus::Same,
621 "auto must agree with mirror --compare auto"
622 );
623 }
624
625 #[test]
626 fn etag_compare_ignores_a_recorded_source_identity() {
627 let first = one("file.txt", entry(100, Some("source-etag")));
628 let second = one(
629 "file.txt",
630 entry_with_identity(100, "multipart-etag-1", "source-etag"),
631 );
632
633 let entries = compare_objects(&first, &second, false, CompareMode::Etag);
634
635 assert_eq!(entries[0].status, DiffStatus::Different);
636 }
637
638 #[test]
639 fn size_compare_ignores_etag_differences() {
640 let first = one("file.txt", entry(100, Some("source-etag")));
641 let second = one("file.txt", entry(100, Some("other-etag")));
642
643 let entries = compare_objects(&first, &second, false, CompareMode::Size);
644
645 assert_eq!(entries[0].status, DiffStatus::Same);
646 }
647
648 #[test]
649 fn auto_compare_reports_a_mismatched_identity_as_different() {
650 let first = one("file.txt", entry(100, Some("source-etag")));
651 let second = one(
652 "file.txt",
653 entry_with_identity(100, "multipart-etag-1", "other-etag"),
654 );
655
656 let entries = compare_objects(&first, &second, false, CompareMode::Auto);
657
658 assert_eq!(entries[0].status, DiffStatus::Different);
659 }
660
661 #[test]
662 fn size_mismatch_is_different_in_every_compare_mode() {
663 let first = one("file.txt", entry(100, Some("source-etag")));
664 let second = one(
665 "file.txt",
666 entry_with_identity(200, "source-etag", "source-etag"),
667 );
668
669 for compare in [CompareMode::Auto, CompareMode::Etag, CompareMode::Size] {
670 let entries = compare_objects(&first, &second, false, compare);
671 assert_eq!(
672 entries[0].status,
673 DiffStatus::Different,
674 "{compare:?} must not call different sizes the same"
675 );
676 }
677 }
678
679 #[test]
680 fn unknown_sizes_are_never_assumed_equal() {
681 let mut missing = entry(100, Some("source-etag"));
682 missing.size = None;
683 let first = one("file.txt", missing.clone());
684 let second = one("file.txt", missing);
685
686 for compare in [CompareMode::Auto, CompareMode::Etag, CompareMode::Size] {
687 let entries = compare_objects(&first, &second, false, compare);
688 assert_eq!(
689 entries[0].status,
690 DiffStatus::Different,
691 "{compare:?} must not assume equality without sizes"
692 );
693 }
694 }
695
696 #[test]
697 fn identity_lookup_is_limited_to_auto_same_size_etag_mismatches() {
698 let source = entry(100, Some("source-etag"));
699 let mismatched = entry(100, Some("other-etag"));
700
701 assert!(second_needs_identity_lookup(
702 &source,
703 &mismatched,
704 CompareMode::Auto
705 ));
706
707 assert!(
708 !second_needs_identity_lookup(
709 &source,
710 &entry(100, Some("source-etag")),
711 CompareMode::Auto
712 ),
713 "matching ETags already prove equality"
714 );
715 assert!(
716 !second_needs_identity_lookup(
717 &source,
718 &entry_with_identity(100, "other-etag", "source-etag"),
719 CompareMode::Auto
720 ),
721 "an entry that already has an identity needs no lookup"
722 );
723 assert!(
724 !second_needs_identity_lookup(
725 &source,
726 &entry(200, Some("other-etag")),
727 CompareMode::Auto
728 ),
729 "different sizes can never match"
730 );
731 assert!(!second_needs_identity_lookup(
732 &source,
733 &mismatched,
734 CompareMode::Etag
735 ));
736 assert!(!second_needs_identity_lookup(
737 &source,
738 &mismatched,
739 CompareMode::Size
740 ));
741
742 let mut unknown_size = source.clone();
743 unknown_size.size = None;
744 assert!(
745 !second_needs_identity_lookup(&unknown_size, &mismatched, CompareMode::Auto),
746 "an unknown source size cannot be reconciled by metadata"
747 );
748
749 let mut no_etag = source.clone();
750 no_etag.etag = None;
751 assert!(
752 !second_needs_identity_lookup(&no_etag, &mismatched, CompareMode::Auto),
753 "without a source ETag there is nothing to match an identity against"
754 );
755 }
756
757 #[test]
758 fn diff_only_hides_matching_entries_in_auto_mode() {
759 let first = HashMap::from([
760 ("same.txt".to_string(), entry(100, Some("source-etag"))),
761 ("changed.txt".to_string(), entry(100, Some("source-etag"))),
762 ]);
763 let second = HashMap::from([
764 (
765 "same.txt".to_string(),
766 entry_with_identity(100, "multipart-etag-1", "source-etag"),
767 ),
768 ("changed.txt".to_string(), entry(100, Some("other-etag"))),
769 ]);
770
771 let entries = compare_objects(&first, &second, true, CompareMode::Auto);
772
773 assert_eq!(entries.len(), 1);
774 assert_eq!(entries[0].key, "changed.txt");
775 assert_eq!(entries[0].status, DiffStatus::Different);
776 }
777}