Skip to main content

rustfs_cli/commands/
diff.rs

1//! diff command - Compare objects between two locations
2//!
3//! Shows differences between two S3 paths or between local and remote.
4
5use 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/// Compare objects between two locations
16#[derive(Args, Debug)]
17pub struct DiffArgs {
18    /// First path (alias/bucket/prefix or local path)
19    pub first: String,
20
21    /// Second path (alias/bucket/prefix or local path)
22    pub second: String,
23
24    /// Recursive comparison
25    #[arg(short, long)]
26    pub recursive: bool,
27
28    /// Show only differences (default: show all)
29    #[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
80/// Execute the diff command
81pub async fn execute(args: DiffArgs, output_config: OutputConfig) -> ExitCode {
82    let formatter = Formatter::new(output_config);
83
84    // Parse both paths
85    let first_parsed = parse_path(&args.first);
86    let second_parsed = parse_path(&args.second);
87
88    // Both must be remote for now (local support can be added later)
89    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    // Load aliases
106    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    // Create clients for both paths
115    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    // List objects from both paths
148    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    // Compare objects
166    let entries = compare_objects(&first_objects, &second_objects, args.diff_only);
167
168    // Calculate summary
169    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    // Determine exit code before moving summary
187    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        // Print diff entries
200        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        // Print summary
226        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    // Return appropriate exit code
234    if has_differences {
235        ExitCode::GeneralError // Indicates differences found
236    } 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            // Get relative key (remove base prefix)
266            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                // Single object case
271                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    // Check objects in first
313    for (key, first_info) in first {
314        if let Some(second_info) = second.get(key) {
315            // Object exists in both
316            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            // Only in first
340            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    // Check objects only in second
352    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    // Sort by key
366    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}