1use clap::Args;
6use rc_core::{AliasManager, ListOptions, ObjectStore as _, ParsedPath, RemotePath, parse_path};
7use rc_s3::S3Client;
8use serde::Serialize;
9use std::collections::HashMap;
10use std::path::Path;
11
12use crate::exit_code::ExitCode;
13use crate::output::{Formatter, OutputConfig};
14
15#[derive(Args, Debug)]
17pub struct DiffArgs {
18 pub first: String,
20
21 pub second: String,
23
24 #[arg(short, long)]
26 pub recursive: bool,
27
28 #[arg(long)]
30 pub diff_only: bool,
31}
32
33#[derive(Debug, Serialize, Clone)]
34pub struct DiffEntry {
35 pub key: String,
36 pub status: DiffStatus,
37 #[serde(skip_serializing_if = "Option::is_none")]
38 pub first_size: Option<i64>,
39 #[serde(skip_serializing_if = "Option::is_none")]
40 pub second_size: Option<i64>,
41 #[serde(skip_serializing_if = "Option::is_none")]
42 pub first_modified: Option<String>,
43 #[serde(skip_serializing_if = "Option::is_none")]
44 pub second_modified: Option<String>,
45}
46
47#[derive(Debug, Serialize, Clone, PartialEq, Eq)]
48#[serde(rename_all = "lowercase")]
49pub enum DiffStatus {
50 Same,
51 Different,
52 OnlyFirst,
53 OnlySecond,
54}
55
56#[derive(Debug, Serialize)]
57struct DiffOutput {
58 first: String,
59 second: String,
60 entries: Vec<DiffEntry>,
61 summary: DiffSummary,
62}
63
64#[derive(Debug, Serialize)]
65struct DiffSummary {
66 same: usize,
67 different: usize,
68 only_first: usize,
69 only_second: usize,
70 total: usize,
71}
72
73#[derive(Debug, Clone)]
74struct FileInfo {
75 size: Option<i64>,
76 modified: Option<String>,
77 etag: Option<String>,
78}
79
80pub async fn execute(args: DiffArgs, output_config: OutputConfig) -> ExitCode {
82 let formatter = Formatter::new(output_config);
83
84 let first_parsed = parse_path(&args.first);
86 let second_parsed = parse_path(&args.second);
87
88 let (first_path, second_path) = match (&first_parsed, &second_parsed) {
90 (Ok(ParsedPath::Remote(f)), Ok(ParsedPath::Remote(s))) => (f.clone(), s.clone()),
91 (Ok(ParsedPath::Local(_)), _) | (_, Ok(ParsedPath::Local(_))) => {
92 formatter.error("Local paths are not yet supported in diff command");
93 return ExitCode::UsageError;
94 }
95 (Err(e), _) => {
96 formatter.error(&format!("Invalid first path: {e}"));
97 return ExitCode::UsageError;
98 }
99 (_, Err(e)) => {
100 formatter.error(&format!("Invalid second path: {e}"));
101 return ExitCode::UsageError;
102 }
103 };
104
105 let alias_manager = match AliasManager::new() {
107 Ok(am) => am,
108 Err(e) => {
109 formatter.error(&format!("Failed to load aliases: {e}"));
110 return ExitCode::GeneralError;
111 }
112 };
113
114 let first_alias = match alias_manager.get(&first_path.alias) {
116 Ok(a) => a,
117 Err(_) => {
118 formatter.error(&format!("Alias '{}' not found", first_path.alias));
119 return ExitCode::NotFound;
120 }
121 };
122
123 let second_alias = match alias_manager.get(&second_path.alias) {
124 Ok(a) => a,
125 Err(_) => {
126 formatter.error(&format!("Alias '{}' not found", second_path.alias));
127 return ExitCode::NotFound;
128 }
129 };
130
131 let first_client = match S3Client::new(first_alias).await {
132 Ok(c) => c,
133 Err(e) => {
134 formatter.error(&format!("Failed to create client for first path: {e}"));
135 return ExitCode::NetworkError;
136 }
137 };
138
139 let second_client = match S3Client::new(second_alias).await {
140 Ok(c) => c,
141 Err(e) => {
142 formatter.error(&format!("Failed to create client for second path: {e}"));
143 return ExitCode::NetworkError;
144 }
145 };
146
147 let first_objects = match list_objects_map(&first_client, &first_path, args.recursive).await {
149 Ok(o) => o,
150 Err(e) => {
151 formatter.error(&format!("Failed to list first path: {e}"));
152 return ExitCode::NetworkError;
153 }
154 };
155
156 let second_objects = match list_objects_map(&second_client, &second_path, args.recursive).await
157 {
158 Ok(o) => o,
159 Err(e) => {
160 formatter.error(&format!("Failed to list second path: {e}"));
161 return ExitCode::NetworkError;
162 }
163 };
164
165 let entries = compare_objects(&first_objects, &second_objects, args.diff_only);
167
168 let mut summary = DiffSummary {
170 same: 0,
171 different: 0,
172 only_first: 0,
173 only_second: 0,
174 total: entries.len(),
175 };
176
177 for entry in &entries {
178 match entry.status {
179 DiffStatus::Same => summary.same += 1,
180 DiffStatus::Different => summary.different += 1,
181 DiffStatus::OnlyFirst => summary.only_first += 1,
182 DiffStatus::OnlySecond => summary.only_second += 1,
183 }
184 }
185
186 let has_differences =
188 summary.different > 0 || summary.only_first > 0 || summary.only_second > 0;
189
190 if formatter.is_json() {
191 let output = DiffOutput {
192 first: args.first.clone(),
193 second: args.second.clone(),
194 entries,
195 summary,
196 };
197 formatter.json(&output);
198 } else {
199 for entry in &entries {
201 let status_char = match entry.status {
202 DiffStatus::Same => "=",
203 DiffStatus::Different => "≠",
204 DiffStatus::OnlyFirst => "<",
205 DiffStatus::OnlySecond => ">",
206 };
207
208 let size_info = match entry.status {
209 DiffStatus::Same => entry.first_size.map(format_size).unwrap_or_default(),
210 DiffStatus::Different => {
211 let first = entry.first_size.map(format_size).unwrap_or_default();
212 let second = entry.second_size.map(format_size).unwrap_or_default();
213 format!("{first} → {second}")
214 }
215 DiffStatus::OnlyFirst => entry.first_size.map(format_size).unwrap_or_default(),
216 DiffStatus::OnlySecond => entry.second_size.map(format_size).unwrap_or_default(),
217 };
218
219 formatter.println(&format!(
220 "{status_char} {:<50} {size_info}",
221 formatter.sanitize_text(&entry.key)
222 ));
223 }
224
225 formatter.println("");
227 formatter.println(&format!(
228 "Summary: {} same, {} different, {} only in first, {} only in second",
229 summary.same, summary.different, summary.only_first, summary.only_second
230 ));
231 }
232
233 if has_differences {
235 ExitCode::GeneralError } else {
237 ExitCode::Success
238 }
239}
240
241async fn list_objects_map(
242 client: &S3Client,
243 path: &RemotePath,
244 recursive: bool,
245) -> Result<HashMap<String, FileInfo>, rc_core::Error> {
246 let mut objects = HashMap::new();
247 let mut continuation_token: Option<String> = None;
248 let base_prefix = &path.key;
249
250 loop {
251 let options = ListOptions {
252 recursive,
253 max_keys: Some(1000),
254 continuation_token: continuation_token.clone(),
255 ..Default::default()
256 };
257
258 let result = client.list_objects(path, options).await?;
259
260 for item in result.items {
261 if item.is_dir {
262 continue;
263 }
264
265 let relative_key = item.key.strip_prefix(base_prefix).unwrap_or(&item.key);
267 let relative_key = relative_key.trim_start_matches('/').to_string();
268
269 if relative_key.is_empty() {
270 let filename = Path::new(&item.key)
272 .file_name()
273 .map(|s| s.to_string_lossy().to_string())
274 .unwrap_or(item.key.clone());
275 objects.insert(
276 filename,
277 FileInfo {
278 size: item.size_bytes,
279 modified: item.last_modified.map(|t| t.to_string()),
280 etag: item.etag,
281 },
282 );
283 } else {
284 objects.insert(
285 relative_key,
286 FileInfo {
287 size: item.size_bytes,
288 modified: item.last_modified.map(|t| t.to_string()),
289 etag: item.etag,
290 },
291 );
292 }
293 }
294
295 if result.truncated {
296 continuation_token = result.continuation_token;
297 } else {
298 break;
299 }
300 }
301
302 Ok(objects)
303}
304
305fn compare_objects(
306 first: &HashMap<String, FileInfo>,
307 second: &HashMap<String, FileInfo>,
308 diff_only: bool,
309) -> Vec<DiffEntry> {
310 let mut entries = Vec::new();
311
312 for (key, first_info) in first {
314 if let Some(second_info) = second.get(key) {
315 let is_same = first_info.size == second_info.size
317 && matches!(
318 (&first_info.etag, &second_info.etag),
319 (Some(first_etag), Some(second_etag)) if first_etag == second_etag
320 );
321
322 let status = if is_same {
323 DiffStatus::Same
324 } else {
325 DiffStatus::Different
326 };
327
328 if !diff_only || status != DiffStatus::Same {
329 entries.push(DiffEntry {
330 key: key.clone(),
331 status,
332 first_size: first_info.size,
333 second_size: second_info.size,
334 first_modified: first_info.modified.clone(),
335 second_modified: second_info.modified.clone(),
336 });
337 }
338 } else {
339 entries.push(DiffEntry {
341 key: key.clone(),
342 status: DiffStatus::OnlyFirst,
343 first_size: first_info.size,
344 second_size: None,
345 first_modified: first_info.modified.clone(),
346 second_modified: None,
347 });
348 }
349 }
350
351 for (key, second_info) in second {
353 if !first.contains_key(key) {
354 entries.push(DiffEntry {
355 key: key.clone(),
356 status: DiffStatus::OnlySecond,
357 first_size: None,
358 second_size: second_info.size,
359 first_modified: None,
360 second_modified: second_info.modified.clone(),
361 });
362 }
363 }
364
365 entries.sort_by(|a, b| a.key.cmp(&b.key));
367 entries
368}
369
370fn format_size(size: i64) -> String {
371 humansize::format_size(size as u64, humansize::BINARY)
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377
378 #[test]
379 fn test_compare_objects_same() {
380 let mut first = HashMap::new();
381 first.insert(
382 "file.txt".to_string(),
383 FileInfo {
384 size: Some(100),
385 modified: None,
386 etag: Some("abc123".to_string()),
387 },
388 );
389
390 let mut second = HashMap::new();
391 second.insert(
392 "file.txt".to_string(),
393 FileInfo {
394 size: Some(100),
395 modified: None,
396 etag: Some("abc123".to_string()),
397 },
398 );
399
400 let entries = compare_objects(&first, &second, false);
401 assert_eq!(entries.len(), 1);
402 assert_eq!(entries[0].status, DiffStatus::Same);
403 }
404
405 #[test]
406 fn test_compare_objects_different() {
407 let mut first = HashMap::new();
408 first.insert(
409 "file.txt".to_string(),
410 FileInfo {
411 size: Some(100),
412 modified: None,
413 etag: Some("abc123".to_string()),
414 },
415 );
416
417 let mut second = HashMap::new();
418 second.insert(
419 "file.txt".to_string(),
420 FileInfo {
421 size: Some(200),
422 modified: None,
423 etag: Some("def456".to_string()),
424 },
425 );
426
427 let entries = compare_objects(&first, &second, false);
428 assert_eq!(entries.len(), 1);
429 assert_eq!(entries[0].status, DiffStatus::Different);
430 }
431
432 #[test]
433 fn test_compare_objects_missing_etag_is_different() {
434 let first = HashMap::from([(
435 "file.txt".to_string(),
436 FileInfo {
437 size: Some(100),
438 modified: None,
439 etag: None,
440 },
441 )]);
442 let second = HashMap::from([(
443 "file.txt".to_string(),
444 FileInfo {
445 size: Some(100),
446 modified: None,
447 etag: Some("second-etag".to_string()),
448 },
449 )]);
450
451 let entries = compare_objects(&first, &second, false);
452
453 assert_eq!(entries[0].status, DiffStatus::Different);
454 }
455
456 #[test]
457 fn test_compare_objects_only_first() {
458 let mut first = HashMap::new();
459 first.insert(
460 "file.txt".to_string(),
461 FileInfo {
462 size: Some(100),
463 modified: None,
464 etag: None,
465 },
466 );
467
468 let second = HashMap::new();
469
470 let entries = compare_objects(&first, &second, false);
471 assert_eq!(entries.len(), 1);
472 assert_eq!(entries[0].status, DiffStatus::OnlyFirst);
473 }
474
475 #[test]
476 fn test_compare_objects_only_second() {
477 let first = HashMap::new();
478
479 let mut second = HashMap::new();
480 second.insert(
481 "file.txt".to_string(),
482 FileInfo {
483 size: Some(100),
484 modified: None,
485 etag: None,
486 },
487 );
488
489 let entries = compare_objects(&first, &second, false);
490 assert_eq!(entries.len(), 1);
491 assert_eq!(entries[0].status, DiffStatus::OnlySecond);
492 }
493}