ccsync_core/comparison/
directory.rs

1//! Directory comparison for recursive syncing
2//!
3//! This module provides recursive directory comparison to identify
4//! files that are added, modified, removed, or unchanged between
5//! source and destination directories.
6
7use std::collections::HashSet;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use crate::error::Result;
12
13use super::hash::FileHasher;
14use super::timestamp::TimestampComparator;
15
16/// Result of comparing two directories recursively
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct DirectoryComparison {
19    /// Files present in source but not in destination
20    pub added: Vec<PathBuf>,
21    /// Files with different content between source and destination
22    pub modified: Vec<PathBuf>,
23    /// Files present in destination but not in source
24    pub removed: Vec<PathBuf>,
25    /// Files with identical content in both locations
26    pub unchanged: Vec<PathBuf>,
27}
28
29impl DirectoryComparison {
30    /// Check if directories are identical (no changes)
31    #[must_use]
32    pub const fn is_identical(&self) -> bool {
33        self.added.is_empty() && self.modified.is_empty() && self.removed.is_empty()
34    }
35
36    /// Count total number of changes
37    #[must_use]
38    pub const fn change_count(&self) -> usize {
39        self.added.len() + self.modified.len() + self.removed.len()
40    }
41}
42
43/// Directory comparator for recursive comparison
44pub struct DirectoryComparator;
45
46impl DirectoryComparator {
47    /// Compare two directories recursively
48    ///
49    /// Returns paths relative to the source/destination roots.
50    ///
51    /// # Errors
52    ///
53    /// Returns an error if directory traversal or file operations fail.
54    pub fn compare(source: &Path, destination: &Path) -> Result<DirectoryComparison> {
55        let mut added = Vec::new();
56        let mut modified = Vec::new();
57        let mut removed = Vec::new();
58        let mut unchanged = Vec::new();
59
60        // Collect all files in source
61        let source_files = Self::collect_files(source)?;
62        let dest_files = if destination.exists() {
63            Self::collect_files(destination)?
64        } else {
65            HashSet::new()
66        };
67
68        // Files in source
69        for rel_path in &source_files {
70            let source_file = source.join(rel_path);
71            let dest_file = destination.join(rel_path);
72
73            if dest_files.contains(rel_path) {
74                // File exists in both - check if modified
75                let source_hash = FileHasher::hash(&source_file)?;
76                let dest_hash = FileHasher::hash(&dest_file)?;
77
78                if source_hash == dest_hash {
79                    unchanged.push(rel_path.clone());
80                } else {
81                    modified.push(rel_path.clone());
82                }
83            } else {
84                // File only in source
85                added.push(rel_path.clone());
86            }
87        }
88
89        // Files only in destination
90        for rel_path in &dest_files {
91            if !source_files.contains(rel_path) {
92                removed.push(rel_path.clone());
93            }
94        }
95
96        Ok(DirectoryComparison {
97            added,
98            modified,
99            removed,
100            unchanged,
101        })
102    }
103
104    /// Determine if source directory is newer than destination
105    ///
106    /// Uses the newest file in each directory tree for comparison.
107    ///
108    /// # Errors
109    ///
110    /// Returns an error if file metadata cannot be read.
111    pub fn is_source_newer(source: &Path, destination: &Path) -> Result<bool> {
112        let source_newest = Self::find_newest_file(source)?;
113        let dest_newest = Self::find_newest_file(destination)?;
114
115        match (source_newest, dest_newest) {
116            (Some(src), Some(dst)) => TimestampComparator::is_newer(&src, &dst),
117            (Some(_), None) => Ok(true), // Source exists, dest doesn't
118            (None, Some(_) | None) => Ok(false), // Dest exists or both empty
119        }
120    }
121
122    /// Collect all files in a directory tree (relative paths)
123    fn collect_files(dir: &Path) -> Result<HashSet<PathBuf>> {
124        let mut files = HashSet::new();
125        Self::collect_files_recursive(dir, dir, &mut files)?;
126        Ok(files)
127    }
128
129    /// Recursively collect files, storing relative paths
130    fn collect_files_recursive(
131        base: &Path,
132        current: &Path,
133        files: &mut HashSet<PathBuf>,
134    ) -> Result<()> {
135        for entry in fs::read_dir(current)? {
136            let entry = entry?;
137            let path = entry.path();
138
139            if path.is_dir() {
140                Self::collect_files_recursive(base, &path, files)?;
141            } else if path.is_file() {
142                let rel_path = path.strip_prefix(base).unwrap().to_path_buf();
143                files.insert(rel_path);
144            }
145        }
146        Ok(())
147    }
148
149    /// Find the newest file in a directory tree
150    fn find_newest_file(dir: &Path) -> Result<Option<PathBuf>> {
151        if !dir.exists() {
152            return Ok(None);
153        }
154
155        let files = Self::collect_files(dir)?;
156        if files.is_empty() {
157            return Ok(None);
158        }
159
160        let mut newest: Option<PathBuf> = None;
161
162        for rel_path in files {
163            let full_path = dir.join(&rel_path);
164            newest = match newest {
165                None => Some(full_path),
166                Some(ref current_newest) => {
167                    if TimestampComparator::is_newer(&full_path, current_newest)? {
168                        Some(full_path)
169                    } else {
170                        Some(current_newest.clone())
171                    }
172                }
173            };
174        }
175
176        Ok(newest)
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use std::fs;
184    use tempfile::TempDir;
185
186    #[test]
187    fn test_compare_identical_directories() {
188        let tmp = TempDir::new().unwrap();
189        let src = tmp.path().join("src");
190        let dst = tmp.path().join("dst");
191
192        fs::create_dir(&src).unwrap();
193        fs::create_dir(&dst).unwrap();
194
195        fs::write(src.join("file1.txt"), "content").unwrap();
196        fs::write(dst.join("file1.txt"), "content").unwrap();
197
198        let result = DirectoryComparator::compare(&src, &dst).unwrap();
199
200        assert!(result.is_identical());
201        assert_eq!(result.unchanged.len(), 1);
202        assert!(result.unchanged.iter().any(|p| p == Path::new("file1.txt")));
203    }
204
205    #[test]
206    fn test_compare_added_files() {
207        let tmp = TempDir::new().unwrap();
208        let src = tmp.path().join("src");
209        let dst = tmp.path().join("dst");
210
211        fs::create_dir(&src).unwrap();
212        fs::create_dir(&dst).unwrap();
213
214        fs::write(src.join("new.txt"), "new content").unwrap();
215
216        let result = DirectoryComparator::compare(&src, &dst).unwrap();
217
218        assert_eq!(result.added.len(), 1);
219        assert!(result.added.iter().any(|p| p == Path::new("new.txt")));
220        assert_eq!(result.change_count(), 1);
221    }
222
223    #[test]
224    fn test_compare_modified_files() {
225        let tmp = TempDir::new().unwrap();
226        let src = tmp.path().join("src");
227        let dst = tmp.path().join("dst");
228
229        fs::create_dir(&src).unwrap();
230        fs::create_dir(&dst).unwrap();
231
232        fs::write(src.join("file.txt"), "new content").unwrap();
233        fs::write(dst.join("file.txt"), "old content").unwrap();
234
235        let result = DirectoryComparator::compare(&src, &dst).unwrap();
236
237        assert_eq!(result.modified.len(), 1);
238        assert!(result.modified.iter().any(|p| p == Path::new("file.txt")));
239    }
240
241    #[test]
242    fn test_compare_removed_files() {
243        let tmp = TempDir::new().unwrap();
244        let src = tmp.path().join("src");
245        let dst = tmp.path().join("dst");
246
247        fs::create_dir(&src).unwrap();
248        fs::create_dir(&dst).unwrap();
249
250        fs::write(dst.join("old.txt"), "old").unwrap();
251
252        let result = DirectoryComparator::compare(&src, &dst).unwrap();
253
254        assert_eq!(result.removed.len(), 1);
255        assert!(result.removed.iter().any(|p| p == Path::new("old.txt")));
256    }
257
258    #[test]
259    fn test_compare_nested_directories() {
260        let tmp = TempDir::new().unwrap();
261        let src = tmp.path().join("src");
262        let dst = tmp.path().join("dst");
263
264        fs::create_dir(&src).unwrap();
265        fs::create_dir(&dst).unwrap();
266
267        let src_sub = src.join("subdir");
268        let dst_sub = dst.join("subdir");
269        fs::create_dir(&src_sub).unwrap();
270        fs::create_dir(&dst_sub).unwrap();
271
272        fs::write(src_sub.join("nested.txt"), "content").unwrap();
273        fs::write(dst_sub.join("nested.txt"), "content").unwrap();
274
275        let result = DirectoryComparator::compare(&src, &dst).unwrap();
276
277        assert!(result.is_identical());
278        assert_eq!(result.unchanged.len(), 1);
279        assert!(result
280            .unchanged
281            .iter()
282            .any(|p| p == Path::new("subdir/nested.txt")));
283    }
284
285    #[test]
286    fn test_compare_destination_does_not_exist() {
287        let tmp = TempDir::new().unwrap();
288        let src = tmp.path().join("src");
289        let dst = tmp.path().join("dst");
290
291        fs::create_dir(&src).unwrap();
292        fs::write(src.join("file.txt"), "content").unwrap();
293
294        let result = DirectoryComparator::compare(&src, &dst).unwrap();
295
296        assert_eq!(result.added.len(), 1);
297        assert_eq!(result.removed.len(), 0);
298    }
299}