ccsync_core/comparison/
directory.rs1use 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#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct DirectoryComparison {
19 pub added: Vec<PathBuf>,
21 pub modified: Vec<PathBuf>,
23 pub removed: Vec<PathBuf>,
25 pub unchanged: Vec<PathBuf>,
27}
28
29impl DirectoryComparison {
30 #[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 #[must_use]
38 pub const fn change_count(&self) -> usize {
39 self.added.len() + self.modified.len() + self.removed.len()
40 }
41}
42
43pub struct DirectoryComparator;
45
46impl DirectoryComparator {
47 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 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 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 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 added.push(rel_path.clone());
86 }
87 }
88
89 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 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), (None, Some(_) | None) => Ok(false), }
120 }
121
122 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 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 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}