Skip to main content

compare_dir/
file_comparer.rs

1use crate::{FileHasher, FileItem, OutputFormat, SystemTimeExt};
2use indicatif::FormattedDuration;
3use std::cmp::Ordering;
4use std::fs;
5use std::io::Read;
6use std::path::PathBuf;
7use std::time::SystemTime;
8
9/// How a file is classified during comparison.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum Classification {
12    /// File exists only in the first directory.
13    OnlyInDir1,
14    /// File exists only in the second directory.
15    OnlyInDir2,
16    /// File exists in both directories.
17    InBoth,
18}
19
20/// Compares the content of two files.
21pub struct FileComparer<'a> {
22    file1: &'a FileItem,
23    file2: &'a FileItem,
24    pub buffer_size: usize,
25    pub hashers: Option<(&'a FileHasher, &'a FileHasher)>,
26}
27
28impl<'a> FileComparer<'a> {
29    pub const DEFAULT_BUFFER_SIZE_KB: usize = 2 * 1024;
30    pub const DEFAULT_BUFFER_SIZE: usize = Self::DEFAULT_BUFFER_SIZE_KB * 1024;
31
32    pub fn new(file1: &'a FileItem, file2: &'a FileItem) -> Self {
33        Self {
34            file1,
35            file2,
36            buffer_size: Self::DEFAULT_BUFFER_SIZE,
37            hashers: None,
38        }
39    }
40
41    pub fn sizes(&self) -> (u64, u64) {
42        (self.file1.size(), self.file2.size())
43    }
44
45    pub fn modified(&self) -> (std::time::SystemTime, std::time::SystemTime) {
46        (self.file1.modified(), self.file2.modified())
47    }
48
49    pub(crate) fn compare_contents(&self) -> anyhow::Result<bool> {
50        let len1 = self.file1.size();
51        let len2 = self.file2.size();
52        if len1 != len2 {
53            return Ok(false);
54        }
55        if let Some((hasher1, hasher2)) = self.hashers {
56            let (hash1, hash2) = rayon::join(
57                || hasher1.get_hash(self.file1),
58                || hasher2.get_hash(self.file2),
59            );
60            return Ok(hash1? == hash2?);
61        }
62        if len1 == 0 {
63            // Early return after checking the hash, to ensure the hash cache is
64            // updated even if the size is 0.
65            return Ok(true);
66        }
67
68        let start_time = std::time::Instant::now();
69        let mut f1 = fs::File::open(self.file1.path())?;
70        let mut f2 = fs::File::open(self.file2.path())?;
71        if self.buffer_size == 0 {
72            let mmap1 = unsafe { memmap2::MmapOptions::new().map(&f1)? };
73            let mmap2 = unsafe { memmap2::MmapOptions::new().map(&f2)? };
74            let result = mmap1[..] == mmap2[..];
75            log::debug!(
76                "Compared in {}: '{}'",
77                FormattedDuration(start_time.elapsed()),
78                self.file1
79            );
80            return Ok(result);
81        }
82
83        let mut buf1 = vec![0u8; self.buffer_size];
84        let mut buf2 = vec![0u8; self.buffer_size];
85        loop {
86            // Safety from Deadlocks: rayon::join is specifically designed for nested parallelism.
87            // It uses work-stealing, meaning if all threads in the pool are busy, the thread
88            // calling join will just execute both tasks itself.
89            let (n1, n2) = rayon::join(|| f1.read(&mut buf1), || f2.read(&mut buf2));
90            let n1 = n1?;
91            let n2 = n2?;
92            if n1 != n2 || buf1[..n1] != buf2[..n2] {
93                log::debug!(
94                    "Compared in {}: '{}'",
95                    FormattedDuration(start_time.elapsed()),
96                    self.file1
97                );
98                return Ok(false);
99            }
100            if n1 == 0 {
101                log::debug!(
102                    "Compared in {}: '{}'",
103                    FormattedDuration(start_time.elapsed()),
104                    self.file1
105                );
106                return Ok(true);
107            }
108        }
109    }
110}
111
112/// Detailed result of comparing a single file.
113#[derive(Debug, Clone)]
114pub struct FileComparisonResult {
115    /// The path relative to the root of the directories.
116    pub relative_path: PathBuf,
117    /// Whether the file exists in one or both directories.
118    pub classification: Classification,
119    /// Comparison of the last modified time, if applicable.
120    pub modified_time_comparison: Option<Ordering>,
121    /// Comparison of the file size, if applicable.
122    pub size_comparison: Option<Ordering>,
123    /// Whether the content is byte-for-byte identical, if applicable.
124    pub is_content_same: Option<bool>,
125}
126
127impl FileComparisonResult {
128    pub fn new(relative_path: PathBuf, classification: Classification) -> Self {
129        Self {
130            relative_path,
131            classification,
132            modified_time_comparison: None,
133            size_comparison: None,
134            is_content_same: None,
135        }
136    }
137
138    pub fn update(
139        &mut self,
140        comparer: &FileComparer,
141        should_compare_content: bool,
142    ) -> anyhow::Result<()> {
143        let (t1, t2) = comparer.modified();
144        self.modified_time_comparison = Some(t1.cmp(&t2));
145
146        let (s1, s2) = comparer.sizes();
147        self.size_comparison = Some(s1.cmp(&s2));
148
149        if should_compare_content && s1 == s2 {
150            self.is_content_same = Some(comparer.compare_contents()?);
151        }
152        Ok(())
153    }
154
155    pub(crate) fn update_moodified(&mut self, t1: SystemTime, t2: SystemTime) {
156        self.modified_time_comparison = Some(t1.cmp_nearly(t2));
157    }
158
159    pub(crate) fn update_size(&mut self, s1: u64, s2: u64) {
160        self.size_comparison = Some(s1.cmp(&s2));
161    }
162
163    /// True if the two files are identical; i.e., modified times and sizes are
164    /// the same. Contents are the same too, or content comparison was skipped.
165    pub fn is_identical(&self) -> bool {
166        self.classification == Classification::InBoth
167            && self.modified_time_comparison == Some(Ordering::Equal)
168            && self.size_comparison == Some(Ordering::Equal)
169            && self.is_content_same != Some(false)
170    }
171
172    pub(crate) fn is_identical_content(&self) -> Option<bool> {
173        match self.size_comparison {
174            None | Some(Ordering::Equal) => self.is_content_same,
175            _ => Some(false),
176        }
177    }
178
179    pub(crate) fn print(&self, output_format: OutputFormat, dir1_name: &str, dir2_name: &str) {
180        match output_format {
181            OutputFormat::Default => println!(
182                "{}: {}",
183                self.relative_path.display(),
184                self.to_string(dir1_name, dir2_name)
185            ),
186            OutputFormat::Symbol => println!(
187                "{} {}",
188                self.to_symbol_string(),
189                self.relative_path.display()
190            ),
191            _ => unreachable!(),
192        }
193    }
194
195    pub fn to_symbol_string(&self) -> String {
196        String::from_iter([
197            match self.classification {
198                Classification::OnlyInDir1 => '>',
199                Classification::OnlyInDir2 => '<',
200                Classification::InBoth => '=',
201            },
202            match self.modified_time_comparison {
203                None => ' ',
204                Some(Ordering::Greater) => '>',
205                Some(Ordering::Less) => '<',
206                Some(Ordering::Equal) => '=',
207            },
208            match self.size_comparison {
209                None => ' ',
210                Some(Ordering::Greater) => '>',
211                Some(Ordering::Less) => '<',
212                Some(Ordering::Equal) => {
213                    if self.is_content_same == Some(false) {
214                        '!'
215                    } else {
216                        '='
217                    }
218                }
219            },
220        ])
221    }
222
223    pub fn to_string(&self, dir1_name: &str, dir2_name: &str) -> String {
224        let mut parts = Vec::new();
225        match self.classification {
226            Classification::OnlyInDir1 => parts.push(format!("Only in {}", dir1_name)),
227            Classification::OnlyInDir2 => parts.push(format!("Only in {}", dir2_name)),
228            Classification::InBoth => {}
229        }
230        let mut has_equals = false;
231        match self.modified_time_comparison {
232            Some(Ordering::Greater) => parts.push(format!("{} is newer", dir1_name)),
233            Some(Ordering::Less) => parts.push(format!("{} is newer", dir2_name)),
234            Some(Ordering::Equal) => has_equals = true,
235            None => {}
236        }
237        match self.size_comparison {
238            Some(Ordering::Greater) => parts.push(format!("Size of {} is larger", dir1_name)),
239            Some(Ordering::Less) => parts.push(format!("Size of {} is larger", dir2_name)),
240            Some(Ordering::Equal) => has_equals = true,
241            None => {}
242        }
243        match self.is_content_same {
244            Some(false) => parts.push("Contents differ".to_string()),
245            Some(true) => has_equals = true,
246            None => {}
247        }
248
249        if parts.is_empty() {
250            if !has_equals {
251                return "Unknown".to_string();
252            }
253            return "Identical".to_string();
254        }
255        parts.join(", ")
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    fn check_compare(content1: &[u8], content2: &[u8], expected: bool) -> anyhow::Result<()> {
264        let dir1 = tempfile::tempdir()?;
265        let dir2 = tempfile::tempdir()?;
266        let file1_path = dir1.path().join("file");
267        let file2_path = dir2.path().join("file");
268        fs::write(&file1_path, content1)?;
269        fs::write(&file2_path, content2)?;
270        let file1 = FileItem::try_from(file1_path.as_path())?;
271        let file2 = FileItem::try_from(file2_path.as_path())?;
272
273        // Without hashers
274        let mut comparer = FileComparer::new(&file1, &file2);
275        comparer.buffer_size = 8192;
276        assert_eq!(comparer.compare_contents()?, expected);
277
278        // Use mmap without hashers
279        comparer.buffer_size = 0;
280        assert_eq!(comparer.compare_contents()?, expected);
281
282        // With hashers
283        let hasher1 = FileHasher::new_with_cache(&[dir1.path()])?;
284        let hasher2 = FileHasher::new_with_cache(&[dir2.path()])?;
285        comparer.hashers = Some((&hasher1, &hasher2));
286        assert_eq!(comparer.compare_contents()?, expected);
287
288        Ok(())
289    }
290
291    #[test]
292    fn compare_contents_identical() -> anyhow::Result<()> {
293        check_compare(b"hello world", b"hello world", true)
294    }
295
296    #[test]
297    fn compare_contents_different() -> anyhow::Result<()> {
298        check_compare(b"hello world", b"hello rust", false)
299    }
300
301    #[test]
302    fn compare_contents_different_size() -> anyhow::Result<()> {
303        check_compare(b"hello world", b"hello", false)
304    }
305
306    #[test]
307    fn compare_contents_empty_files() -> anyhow::Result<()> {
308        check_compare(b"", b"", true)
309    }
310
311    #[test]
312    fn comparison_result_empty() {
313        let result = FileComparisonResult::new(PathBuf::from("test.txt"), Classification::InBoth);
314        assert!(!result.is_identical());
315        assert_eq!(result.to_string("dir1", "dir2"), "Unknown");
316        assert_eq!(result.to_symbol_string(), "=  ");
317    }
318
319    #[test]
320    fn comparison_result_contents_skipped() {
321        let mut result =
322            FileComparisonResult::new(PathBuf::from("test.txt"), Classification::InBoth);
323        result.modified_time_comparison = Some(Ordering::Equal);
324        result.size_comparison = Some(Ordering::Equal);
325        assert!(result.is_identical());
326        assert_eq!(result.to_string("dir1", "dir2"), "Identical");
327        assert_eq!(result.to_symbol_string(), "===");
328    }
329}