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!("{status_char} {:<50} {size_info}", entry.key));
220 }
221
222 formatter.println("");
224 formatter.println(&format!(
225 "Summary: {} same, {} different, {} only in first, {} only in second",
226 summary.same, summary.different, summary.only_first, summary.only_second
227 ));
228 }
229
230 if has_differences {
232 ExitCode::GeneralError } else {
234 ExitCode::Success
235 }
236}
237
238async fn list_objects_map(
239 client: &S3Client,
240 path: &RemotePath,
241 recursive: bool,
242) -> Result<HashMap<String, FileInfo>, rc_core::Error> {
243 let mut objects = HashMap::new();
244 let mut continuation_token: Option<String> = None;
245 let base_prefix = &path.key;
246
247 loop {
248 let options = ListOptions {
249 recursive,
250 max_keys: Some(1000),
251 continuation_token: continuation_token.clone(),
252 ..Default::default()
253 };
254
255 let result = client.list_objects(path, options).await?;
256
257 for item in result.items {
258 if item.is_dir {
259 continue;
260 }
261
262 let relative_key = item.key.strip_prefix(base_prefix).unwrap_or(&item.key);
264 let relative_key = relative_key.trim_start_matches('/').to_string();
265
266 if relative_key.is_empty() {
267 let filename = Path::new(&item.key)
269 .file_name()
270 .map(|s| s.to_string_lossy().to_string())
271 .unwrap_or(item.key.clone());
272 objects.insert(
273 filename,
274 FileInfo {
275 size: item.size_bytes,
276 modified: item.last_modified.map(|t| t.to_string()),
277 etag: item.etag,
278 },
279 );
280 } else {
281 objects.insert(
282 relative_key,
283 FileInfo {
284 size: item.size_bytes,
285 modified: item.last_modified.map(|t| t.to_string()),
286 etag: item.etag,
287 },
288 );
289 }
290 }
291
292 if result.truncated {
293 continuation_token = result.continuation_token;
294 } else {
295 break;
296 }
297 }
298
299 Ok(objects)
300}
301
302fn compare_objects(
303 first: &HashMap<String, FileInfo>,
304 second: &HashMap<String, FileInfo>,
305 diff_only: bool,
306) -> Vec<DiffEntry> {
307 let mut entries = Vec::new();
308
309 for (key, first_info) in first {
311 if let Some(second_info) = second.get(key) {
312 let is_same = first_info.size == second_info.size
314 && (first_info.etag == second_info.etag || first_info.etag.is_none());
315
316 let status = if is_same {
317 DiffStatus::Same
318 } else {
319 DiffStatus::Different
320 };
321
322 if !diff_only || status != DiffStatus::Same {
323 entries.push(DiffEntry {
324 key: key.clone(),
325 status,
326 first_size: first_info.size,
327 second_size: second_info.size,
328 first_modified: first_info.modified.clone(),
329 second_modified: second_info.modified.clone(),
330 });
331 }
332 } else {
333 entries.push(DiffEntry {
335 key: key.clone(),
336 status: DiffStatus::OnlyFirst,
337 first_size: first_info.size,
338 second_size: None,
339 first_modified: first_info.modified.clone(),
340 second_modified: None,
341 });
342 }
343 }
344
345 for (key, second_info) in second {
347 if !first.contains_key(key) {
348 entries.push(DiffEntry {
349 key: key.clone(),
350 status: DiffStatus::OnlySecond,
351 first_size: None,
352 second_size: second_info.size,
353 first_modified: None,
354 second_modified: second_info.modified.clone(),
355 });
356 }
357 }
358
359 entries.sort_by(|a, b| a.key.cmp(&b.key));
361 entries
362}
363
364fn format_size(size: i64) -> String {
365 humansize::format_size(size as u64, humansize::BINARY)
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371
372 #[test]
373 fn test_compare_objects_same() {
374 let mut first = HashMap::new();
375 first.insert(
376 "file.txt".to_string(),
377 FileInfo {
378 size: Some(100),
379 modified: None,
380 etag: Some("abc123".to_string()),
381 },
382 );
383
384 let mut second = HashMap::new();
385 second.insert(
386 "file.txt".to_string(),
387 FileInfo {
388 size: Some(100),
389 modified: None,
390 etag: Some("abc123".to_string()),
391 },
392 );
393
394 let entries = compare_objects(&first, &second, false);
395 assert_eq!(entries.len(), 1);
396 assert_eq!(entries[0].status, DiffStatus::Same);
397 }
398
399 #[test]
400 fn test_compare_objects_different() {
401 let mut first = HashMap::new();
402 first.insert(
403 "file.txt".to_string(),
404 FileInfo {
405 size: Some(100),
406 modified: None,
407 etag: Some("abc123".to_string()),
408 },
409 );
410
411 let mut second = HashMap::new();
412 second.insert(
413 "file.txt".to_string(),
414 FileInfo {
415 size: Some(200),
416 modified: None,
417 etag: Some("def456".to_string()),
418 },
419 );
420
421 let entries = compare_objects(&first, &second, false);
422 assert_eq!(entries.len(), 1);
423 assert_eq!(entries[0].status, DiffStatus::Different);
424 }
425
426 #[test]
427 fn test_compare_objects_only_first() {
428 let mut first = HashMap::new();
429 first.insert(
430 "file.txt".to_string(),
431 FileInfo {
432 size: Some(100),
433 modified: None,
434 etag: None,
435 },
436 );
437
438 let second = HashMap::new();
439
440 let entries = compare_objects(&first, &second, false);
441 assert_eq!(entries.len(), 1);
442 assert_eq!(entries[0].status, DiffStatus::OnlyFirst);
443 }
444
445 #[test]
446 fn test_compare_objects_only_second() {
447 let first = HashMap::new();
448
449 let mut second = HashMap::new();
450 second.insert(
451 "file.txt".to_string(),
452 FileInfo {
453 size: Some(100),
454 modified: None,
455 etag: None,
456 },
457 );
458
459 let entries = compare_objects(&first, &second, false);
460 assert_eq!(entries.len(), 1);
461 assert_eq!(entries[0].status, DiffStatus::OnlySecond);
462 }
463}