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!("{status_char} {:<50} {size_info}", entry.key));
220        }
221
222        // Print summary
223        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    // Return appropriate exit code
231    if has_differences {
232        ExitCode::GeneralError // Indicates differences found
233    } 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            // Get relative key (remove base prefix)
263            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                // Single object case
268                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    // Check objects in first
310    for (key, first_info) in first {
311        if let Some(second_info) = second.get(key) {
312            // Object exists in both
313            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            // Only in first
334            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    // Check objects only in second
346    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    // Sort by key
360    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}