1use crate::{
2 Classification, ColumnFormatter, DirectoryComparer, FileComparer, FileComparisonResult,
3 FileHashCache, FileItem, FileIterator, OutputFormat, Progress, ProgressBuilder, ProgressValue,
4 SharedProgress,
5};
6use globset::GlobSet;
7use indicatif::FormattedDuration;
8use rayon::prelude::*;
9use simple_path::SimplePath;
10use std::{
11 collections::HashMap,
12 fs,
13 io::{self, Read, stdout},
14 path::{Path, PathBuf},
15 sync::{
16 Arc,
17 atomic::{self, AtomicUsize},
18 mpsc,
19 },
20 time,
21};
22
23type FileWithDirIndex = (FileItem, usize);
24
25#[derive(Debug, Clone)]
26enum DupEvent {
27 StartHashing,
28 Total(ProgressValue),
29 Result(FileItem, blake3::Hash),
30 Error,
31}
32
33#[derive(Debug)]
34enum CheckEvent {
35 StartChecking,
36 Total(ProgressValue),
37 Result(FileComparisonResult),
38 Progress(ProgressValue),
39 Error(FileItem),
40}
41
42enum DupState {
43 Single(FileItem, usize),
44 Hashing,
45}
46
47pub struct FileHasher {
49 dirs: Vec<PathBuf>,
50 pub buffer_size: usize,
51 cache: Option<Arc<FileHashCache>>,
52 num_hashed: AtomicUsize,
53 num_hash_looked_up: AtomicUsize,
54 pub exclude: Option<GlobSet>,
55 pub progress: Option<Arc<ProgressBuilder>>,
56 pub output_format: OutputFormat,
57 pub jobs: usize,
58}
59
60impl FileHasher {
61 const DEFAULT_JOBS: usize = DirectoryComparer::DEFAULT_JOBS;
62
63 pub fn new<P: AsRef<Path>>(dirs: &[P]) -> anyhow::Result<Self> {
65 if dirs.is_empty() {
66 anyhow::bail!("At least one directory must be specified.");
67 }
68 Ok(Self {
69 dirs: dirs.iter().map(|p| p.as_ref().to_path_buf()).collect(),
70 buffer_size: FileComparer::DEFAULT_BUFFER_SIZE,
71 cache: None,
72 num_hashed: AtomicUsize::new(0),
73 num_hash_looked_up: AtomicUsize::new(0),
74 exclude: None,
75 progress: None,
76 output_format: OutputFormat::Default,
77 jobs: Self::DEFAULT_JOBS,
78 })
79 }
80
81 pub(crate) fn new_with_cache<P: AsRef<Path>>(dirs: &[P]) -> anyhow::Result<Self> {
82 let mut hasher = Self::new(dirs)?;
83 hasher.cache = Some(hasher.new_cache()?);
84 Ok(hasher)
85 }
86
87 fn new_cache(&self) -> anyhow::Result<Arc<FileHashCache>> {
88 let common_ancestor = crate::common_ancestor(&self.dirs)
89 .ok_or_else(|| anyhow::anyhow!("No common ancestor found"))?;
90 Ok(FileHashCache::find_or_new(&common_ancestor))
91 }
92
93 pub(crate) fn cache(&mut self) -> anyhow::Result<Arc<FileHashCache>> {
95 if self.cache.is_none() {
96 self.cache = Some(self.new_cache()?);
97 }
98 Ok(Arc::clone(self.cache.as_ref().unwrap()))
99 }
100
101 pub(crate) fn remove_cache_entry(&mut self, path: &Path) -> anyhow::Result<()> {
103 let cache = self.cache()?;
104 let relative = SimplePath::strip_prefix(path, cache.base_dir())?;
105 cache.remove(relative);
106 Ok(())
107 }
108
109 pub fn save_cache(&self) -> anyhow::Result<()> {
111 log::info!(
112 "Hash stats for {:?}: {} computed, {} looked up",
113 self.dirs,
114 self.num_hashed.load(atomic::Ordering::Relaxed),
115 self.num_hash_looked_up.load(atomic::Ordering::Relaxed)
116 );
117 if let Some(cache) = &self.cache {
118 cache.save()?;
119 }
120 Ok(())
121 }
122
123 pub(crate) fn clear_cache(&mut self) -> anyhow::Result<()> {
125 let cache = self.cache()?;
126 for dir in &self.dirs {
127 let relative = SimplePath::strip_prefix(dir, cache.base_dir())?;
128 cache.clear(relative);
129 }
130 Ok(())
131 }
132
133 pub fn check(&self, update: bool) -> anyhow::Result<()> {
135 match self.output_format {
136 OutputFormat::Default | OutputFormat::Symbol => {}
137 _ => anyhow::bail!("Check mode only supports default or symbol output format."),
138 }
139 if self.dirs.len() > 1 {
140 anyhow::bail!("Check mode only supports one directory.");
141 }
142 let start_time = time::Instant::now();
143 if let Some(p) = &self.progress {
144 p.set_propagate();
145 }
146 let progress = self
147 .progress
148 .as_ref()
149 .map(|progress| progress.add_primary())
150 .unwrap_or_else(SharedProgress::none);
151 progress.use_bytes();
152 progress.set_message("Scanning directory...");
153 let mut num_new = 0;
154 let mut num_modified = 0;
155 let mut num_error = 0;
156 std::thread::scope(|scope| {
157 let (tx, rx) = mpsc::channel();
158 scope.spawn(|| {
159 if let Err(e) = self.check_streaming(tx, update) {
160 log::error!("Error during check: {}", e);
161 }
162 });
163 while let Ok(event) = rx.recv() {
164 match event {
165 CheckEvent::StartChecking => {
166 progress.set_message("Checking files...");
167 }
168 CheckEvent::Total(value) => {
169 progress.set_length(value);
170 progress.set_message("");
171 }
172 CheckEvent::Result(result) => {
173 progress.suspend_for(stdout(), || {
174 result.print(self.output_format, "cached", "current")
175 });
176 if result.classification == Classification::OnlyInDir2 {
177 num_new += 1;
178 } else if result.is_identical_content() == Some(false) {
179 num_modified += 1;
180 }
181 }
182 CheckEvent::Progress(value) => progress.inc(value),
183 CheckEvent::Error(file) => {
184 progress.inc(ProgressValue::with_skip(file.size()));
185 num_error += 1;
186 }
187 }
188 }
189 });
190 progress.finish();
191 self.print_check_summary(&start_time, num_new, num_modified, num_error)?;
192 Ok(())
193 }
194
195 fn print_check_summary(
196 &self,
197 start_time: &time::Instant,
198 num_new: usize,
199 num_modified: usize,
200 num_error: usize,
201 ) -> io::Result<()> {
202 let summary = [
203 ("Elapsed:", 0),
204 (
205 "Hash computed:",
206 self.num_hashed.load(atomic::Ordering::Relaxed),
207 ),
208 ("New files:", num_new),
209 ("Modified files:", num_modified),
210 ("Errors:", num_error),
211 ];
212 let formatter = ColumnFormatter::new(summary.iter().map(|(s, _)| *s));
213 let mut writer = std::io::stderr();
214 formatter.write_value(
215 &mut writer,
216 summary[0].0,
217 FormattedDuration(start_time.elapsed()),
218 )?;
219 formatter.write_values(&mut writer, &summary[1..])
220 }
221
222 fn check_streaming(&self, tx: mpsc::Sender<CheckEvent>, update: bool) -> anyhow::Result<()> {
223 assert_eq!(self.dirs.len(), 1);
224 let cache = self.new_cache()?;
225 let base_dir = &self.dirs[0];
226 let relative = SimplePath::strip_prefix(base_dir, cache.base_dir())?;
227 cache.set_remove_if_no_access(relative);
228 let cache_clone = Arc::clone(&cache);
229 std::thread::scope(|global_scope| {
230 let mut it = FileIterator::new(base_dir);
231 it.cache = Some(Arc::clone(&cache));
232 it.exclude = self.exclude.as_ref();
233 let it_rx = it.spawn_in_scope(global_scope);
234 tx.send(CheckEvent::StartChecking)?;
235 let pool = crate::build_thread_pool(self.jobs)?;
236 pool.scope(move |scope| -> anyhow::Result<()> {
237 let mut total = ProgressValue::default();
238 for file in it_rx {
239 self.check_file(file, &cache, update, &mut total, &tx, scope);
240 }
241 tx.send(CheckEvent::Total(total))?;
242 Ok(())
243 })
244 })?;
245 cache_clone.save()?;
246 Ok(())
247 }
248
249 fn check_file<'scope>(
250 &'scope self,
251 file: FileItem,
252 cache: &Arc<FileHashCache>,
253 update: bool,
254 total: &mut ProgressValue,
255 tx: &mpsc::Sender<CheckEvent>,
256 scope: &rayon::Scope<'scope>,
257 ) {
258 *total += ProgressValue::with_size(file.size());
259 let tx = tx.clone();
260 let cache = Arc::clone(cache);
261 scope.spawn(move |_| {
262 if let Err(error) = self._check_file(&file, cache, update, &tx) {
263 log::error!("Failed to check file '{}': {}", file, error);
264 if tx.send(CheckEvent::Error(file)).is_err() {
265 log::error!("Send failed");
266 }
267 }
268 });
269 }
270
271 fn _check_file(
272 &self,
273 file: &FileItem,
274 cache: Arc<FileHashCache>,
275 update: bool,
276 tx: &mpsc::Sender<CheckEvent>,
277 ) -> anyhow::Result<()> {
278 assert!(file.path().is_absolute());
279 let path_in_cache = file.relative_path(cache.base_dir());
280 match cache.get_entry(path_in_cache) {
281 Some(cached) => {
282 let mut result =
283 FileComparisonResult::new(file.path().into(), Classification::InBoth);
284 result.update_moodified(cached.modified, file.modified());
285 if cached.size != 0 {
286 result.update_size(cached.size, file.size());
287 }
288 if !update && cached.size != 0 && file.size() != cached.size {
289 tx.send(CheckEvent::Result(result))?;
290 tx.send(CheckEvent::Progress(ProgressValue::with_skip(file.size())))?;
291 return Ok(());
292 }
293 let hash = self.compute_hash(file)?;
294 result.is_content_same = Some(hash == cached.hash);
295 if hash == cached.hash {
296 if cached.should_update(file, update) {
297 cache.insert(path_in_cache, file, hash);
298 }
299 } else {
300 if update {
301 cache.insert(path_in_cache, file, hash);
302 }
303 tx.send(CheckEvent::Result(result))?;
304 }
305 }
306 None => {
307 if update {
308 let hash = self.compute_hash(file)?;
309 cache.insert(path_in_cache, file, hash);
310 } else {
311 tx.send(CheckEvent::Progress(ProgressValue::with_skip(file.size())))?;
312 }
313 tx.send(CheckEvent::Result(FileComparisonResult::new(
314 file.path().into(),
315 Classification::OnlyInDir2,
316 )))?;
317 }
318 }
319 Ok(())
320 }
321
322 pub fn run(&self) -> anyhow::Result<()> {
324 let start_time = time::Instant::now();
325 let mut duplicates = self.find_duplicates()?;
326 let mut total_wasted_space = 0;
327 if !duplicates.is_empty() {
328 duplicates.sort_by_key(|a| a.size);
329 total_wasted_space = self.print_duplicates_results(&duplicates)?;
330 }
331 self.print_duplicates_summary(&start_time, total_wasted_space)?;
332 Ok(())
333 }
334
335 fn print_duplicates_results(&self, duplicates: &Vec<DuplicatedFiles>) -> anyhow::Result<u64> {
336 let mut total_wasted_space = 0;
337 for dupes in duplicates {
338 dupes.print(self.output_format)?;
339 total_wasted_space += dupes.wasted_size();
340 }
341 Ok(total_wasted_space)
342 }
343
344 fn print_duplicates_summary(
345 &self,
346 start_time: &time::Instant,
347 total_wasted_space: u64,
348 ) -> io::Result<()> {
349 let elapsed = FormattedDuration(start_time.elapsed()).to_string();
350 let num_hashed = self.num_hashed.load(atomic::Ordering::Relaxed).to_string();
351 let total_wasted_space = crate::human_readable_size(total_wasted_space);
352 let summary = [
353 ("Elapsed:", elapsed),
354 ("Hash computed:", num_hashed),
355 ("Total wasted space:", total_wasted_space),
356 ];
357 let formatter = ColumnFormatter::new(summary.iter().map(|(s, _)| *s));
358 formatter.write_values(&mut io::stderr(), &summary)
359 }
360
361 pub fn find_duplicates(&self) -> anyhow::Result<Vec<DuplicatedFiles>> {
363 let progress = self
364 .progress
365 .as_ref()
366 .map(|progress| progress.add_primary())
367 .unwrap_or_else(SharedProgress::none);
368 progress.set_message("Scanning directories...");
369
370 let (tx, rx) = mpsc::channel();
371 let mut by_hash: HashMap<blake3::Hash, DuplicatedFiles> = HashMap::new();
372 std::thread::scope(|scope| {
373 scope.spawn(|| {
374 if let Err(e) = self.find_duplicates_streaming(tx) {
375 log::error!("Error during duplicate finding: {}", e);
376 }
377 });
378
379 while let Ok(event) = rx.recv() {
380 match event {
381 DupEvent::StartHashing => progress.set_message("Hashing files..."),
382 DupEvent::Total(value) => progress.set_length(value),
383 DupEvent::Result(file, hash) => {
384 progress.inc(ProgressValue::with_size(file.size()));
385 let entry = by_hash.entry(hash).or_insert_with(|| DuplicatedFiles {
386 paths: Vec::new(),
387 size: file.size(),
388 });
389 assert_eq!(
391 entry.size,
392 file.size(),
393 "Hash collision: sizes do not match"
394 );
395 entry.paths.push(file.into_path_buf());
396 }
397 DupEvent::Error => {}
398 }
399 }
400 });
401 progress.finish();
402
403 let mut duplicates = Vec::new();
404 for (_, mut dupes) in by_hash {
405 if dupes.paths.len() > 1 {
406 dupes.paths.sort();
407 duplicates.push(dupes);
408 }
409 }
410 Ok(duplicates)
411 }
412
413 fn find_duplicates_streaming(&self, tx: mpsc::Sender<DupEvent>) -> anyhow::Result<()> {
414 std::thread::scope(|global_scope| {
415 let (it_rx, caches) = self.stream_file_items(global_scope)?;
416 let caches = &caches;
417 let pool = crate::build_thread_pool(self.jobs)?;
418 pool.scope(move |scope| -> anyhow::Result<()> {
419 let mut by_size: HashMap<u64, DupState> = HashMap::new();
420 let mut total = ProgressValue::default();
421 tx.send(DupEvent::StartHashing)?;
422 for (file, dir_index) in it_rx {
423 let size = file.size();
424 if size == 0 {
425 continue;
426 }
427 let cache = &caches[dir_index];
428 match by_size.entry(size) {
429 std::collections::hash_map::Entry::Occupied(mut occ) => match occ.get_mut()
430 {
431 DupState::Single(file0, dir_index0) => {
432 let cache0 = &caches[*dir_index0];
435 self.send_hash(file0, cache0, &tx, scope);
436 self.send_hash(&file, cache, &tx, scope);
437 total += ProgressValue::with_size(file0.size());
438 total += ProgressValue::with_size(file.size());
439
440 *occ.get_mut() = DupState::Hashing;
442 }
443 DupState::Hashing => {
444 self.send_hash(&file, cache, &tx, scope);
446 total += ProgressValue::with_size(file.size());
447 }
448 },
449 std::collections::hash_map::Entry::Vacant(vac) => {
450 vac.insert(DupState::Single(file, dir_index));
451 }
452 }
453 }
454 tx.send(DupEvent::Total(total))?;
455 Ok(())
456 })?;
457 pool.install(|| caches.into_par_iter().try_for_each(|cache| cache.save()))?;
458 Ok::<(), anyhow::Error>(())
459 })?;
460 Ok(())
461 }
462
463 fn stream_file_items<'scope, 'env>(
464 &'env self,
465 scope: &'scope std::thread::Scope<'scope, 'env>,
466 ) -> anyhow::Result<(mpsc::Receiver<FileWithDirIndex>, Vec<Arc<FileHashCache>>)> {
467 let (it_tx, it_rx) = mpsc::channel();
468 let mut caches = Vec::with_capacity(self.dirs.len());
469 for (dir_index, dir) in self.dirs.iter().enumerate() {
470 let mut it = FileIterator::new(dir);
471 let cache = FileHashCache::find_or_new(dir);
472 it.cache = Some(Arc::clone(&cache));
473 it.exclude = self.exclude.as_ref();
474 let it_tx = it_tx.clone();
475 scope.spawn(move || it.send_to_as(it_tx, |path| (path, dir_index)));
476 caches.push(cache);
477 }
478 Ok((it_rx, caches))
479 }
480
481 fn send_hash<'scope>(
482 &'scope self,
483 file: &FileItem,
484 cache: &Arc<FileHashCache>,
485 tx: &mpsc::Sender<DupEvent>,
486 scope: &rayon::Scope<'scope>,
487 ) {
488 let (hash, relative) = self
489 .get_hash_from_cache(file, cache)
490 .expect("path should be in cache base_dir");
491 if let Some(hash) = hash {
492 let _ = tx.send(DupEvent::Result(file.clone(), hash));
493 return;
494 }
495
496 let file = file.clone();
497 let relative = relative.to_path_buf();
498 let tx = tx.clone();
499 let cache = Arc::clone(cache);
500 scope.spawn(move |_| {
501 if let Ok(hash) = self.compute_hash(&file) {
502 cache.insert(&relative, &file, hash);
503 let _ = tx.send(DupEvent::Result(file, hash));
504 } else {
505 log::error!("Failed to hash file: '{}'", file);
506 let _ = tx.send(DupEvent::Error);
507 }
508 });
509 }
510
511 pub fn get_hash(&self, file: &FileItem) -> anyhow::Result<blake3::Hash> {
513 let cache = self.cache.as_ref().expect("cache should be initialized");
514 let (hash, relative) = self.get_hash_from_cache(file, cache)?;
515 if let Some(hash) = hash {
516 return Ok(hash);
517 }
518
519 let hash = self.compute_hash(file)?;
520 cache.insert(relative, file, hash);
521 Ok(hash)
522 }
523
524 fn get_hash_from_cache<'a>(
525 &self,
526 file: &'a FileItem,
527 cache: &FileHashCache,
528 ) -> io::Result<(Option<blake3::Hash>, &'a Path)> {
529 let relative = file.relative_path(cache.base_dir());
530 if let Some(hash) = cache.get(relative, file) {
531 self.num_hash_looked_up
532 .fetch_add(1, atomic::Ordering::Relaxed);
533 return Ok((Some(hash), relative));
534 }
535 Ok((None, relative))
536 }
537
538 fn compute_hash(&self, file: &FileItem) -> io::Result<blake3::Hash> {
539 let start_time = time::Instant::now();
540 let mut f = fs::File::open(file.path())?;
541 let mut progress = self
542 .progress
543 .as_ref()
544 .map(|progress| progress.add_file(file.path(), file.size()))
545 .unwrap_or_else(Progress::none);
546 let mut hasher = blake3::Hasher::new();
547 if self.buffer_size == 0 {
548 if file.size() > 0 {
549 let mmap = unsafe { memmap2::MmapOptions::new().map(&f)? };
550 hasher.update(&mmap[..]);
551 progress.inc(ProgressValue::with_size(file.size()));
552 }
553 } else {
554 let mut buf = vec![0u8; self.buffer_size];
555 loop {
556 let n = f.read(&mut buf)?;
557 if n == 0 {
558 break;
559 }
560 hasher.update(&buf[..n]);
561 progress.inc_size(n as u64);
562 }
563 progress.inc_file(1);
564 }
565 progress.finish();
566 self.num_hashed.fetch_add(1, atomic::Ordering::Relaxed);
567 let hash = hasher.finalize();
568 log::trace!(
569 "Computed hash in {}: '{}'",
570 FormattedDuration(start_time.elapsed()),
571 file
572 );
573 Ok(hash)
574 }
575}
576
577#[derive(Clone, Debug)]
579pub struct DuplicatedFiles {
580 pub paths: Vec<PathBuf>,
581 pub size: u64,
582}
583
584impl DuplicatedFiles {
585 fn wasted_size(&self) -> u64 {
586 self.size * (self.paths.len() as u64 - 1)
587 }
588
589 fn print(&self, output_format: OutputFormat) -> anyhow::Result<()> {
590 match output_format {
591 OutputFormat::Default => self.write_human(stdout())?,
592 OutputFormat::PowerShell => self.write_pwsh(stdout())?,
593 OutputFormat::Shell => self.write_shell(stdout())?,
594 OutputFormat::Yaml | OutputFormat::Symbol => self.write_yaml(stdout())?,
595 }
596 Ok(())
597 }
598
599 fn write_human(&self, mut writer: impl io::Write) -> anyhow::Result<()> {
600 writeln!(
601 writer,
602 "Identical {} files of {}:",
603 self.paths.len(),
604 crate::human_readable_size(self.size)
605 )?;
606 for path in &self.paths {
607 writeln!(writer, " {}", path.display())?;
608 }
609 Ok(())
610 }
611
612 fn write_yaml(&self, mut writer: impl io::Write) -> anyhow::Result<()> {
613 writeln!(writer, "- paths:")?;
614 for path in &self.paths {
615 writeln!(writer, " - '{}'", Self::escape_quotes_by_double(path))?;
616 }
617 writeln!(writer, " size: {}", self.size)?;
618 Ok(())
619 }
620
621 fn write_shell(&self, writer: impl io::Write) -> anyhow::Result<()> {
622 self.write_shell_with(writer, "cp", Self::escape_quotes_for_shell)
623 }
624
625 fn write_pwsh(&self, writer: impl io::Write) -> anyhow::Result<()> {
626 self.write_shell_with(
627 writer,
628 "Copy-Item -LiteralPath",
629 Self::escape_quotes_by_double,
630 )
631 }
632
633 fn write_shell_with(
634 &self,
635 mut writer: impl io::Write,
636 cmd: &str,
637 stringify: impl Fn(&Path) -> String,
638 ) -> anyhow::Result<()> {
639 let mut iter = self.paths.iter();
640 if let Some(path0) = iter.next() {
641 let path0 = stringify(path0);
642 for path in iter {
643 writeln!(writer, "{cmd} '{path0}' '{}'", stringify(path))?;
644 }
645 }
646 Ok(())
647 }
648
649 fn escape_quotes_for_shell(path: &Path) -> String {
651 path.to_string_lossy().replace('\'', "'\\''")
652 }
653
654 fn escape_quotes_by_double(path: &Path) -> String {
657 path.to_string_lossy().replace('\'', "''")
658 }
659}
660
661#[cfg(test)]
662mod tests {
663 use super::*;
664 use std::cmp::Ordering;
665
666 fn default_exclude() -> globset::GlobSet {
667 let mut builder = globset::GlobSetBuilder::new();
668 builder.add(
669 globset::GlobBuilder::new(".hash_cache")
670 .case_insensitive(true)
671 .build()
672 .unwrap(),
673 );
674 builder.build().unwrap()
675 }
676
677 #[test]
678 fn find_duplicates() -> anyhow::Result<()> {
679 let dir = tempfile::tempdir()?;
680
681 let file1_path = dir.path().join("same1.txt");
682 fs::write(&file1_path, "same content")?;
683
684 let file2_path = dir.path().join("same2.txt");
685 fs::write(&file2_path, "same content")?;
686
687 let diff_path = dir.path().join("diff.txt");
688 fs::write(&diff_path, "different content")?;
689
690 let mut hasher = FileHasher::new(&[dir.path()])?;
691 hasher.buffer_size = 8192;
692 let duplicates = hasher.find_duplicates()?;
693
694 assert_eq!(hasher.num_hashed.load(atomic::Ordering::Relaxed), 2);
695 assert_eq!(hasher.num_hash_looked_up.load(atomic::Ordering::Relaxed), 0);
696
697 assert_eq!(duplicates.len(), 1);
698 let group = &duplicates[0];
699 assert_eq!(group.paths.len(), 2);
700 assert_eq!(group.size, 12); assert!(group.paths.contains(&file1_path));
703 assert!(group.paths.contains(&file2_path));
704
705 Ok(())
706 }
707
708 #[test]
709 fn find_duplicates_merge_cache() -> anyhow::Result<()> {
710 let dir = tempfile::tempdir()?;
711 let dir_path = dir.path();
712
713 let sub_dir = dir_path.join("a").join("a");
714 fs::create_dir_all(&sub_dir)?;
715
716 let file1_path = sub_dir.join("1");
717 fs::write(&file1_path, "same content")?;
718
719 let file2_path = sub_dir.join("2");
720 fs::write(&file2_path, "same content")?;
721
722 let cache_aa_path = sub_dir.join(FileHashCache::FILE_NAME);
724 fs::File::create(&cache_aa_path)?;
725
726 let hasher_aa = FileHasher::new(&[&sub_dir])?;
728 let duplicates_aa = hasher_aa.find_duplicates()?;
729 assert_eq!(duplicates_aa.len(), 1);
730 assert!(cache_aa_path.exists());
731 assert_eq!(hasher_aa.num_hashed.load(atomic::Ordering::Relaxed), 2);
732 assert_eq!(
733 hasher_aa.num_hash_looked_up.load(atomic::Ordering::Relaxed),
734 0
735 );
736
737 let root_a = dir_path.join("a");
739 let cache_a_path = root_a.join(FileHashCache::FILE_NAME);
740 fs::File::create(&cache_a_path)?;
741
742 let hasher_a = FileHasher::new(&[&root_a])?;
744 let duplicates_a = hasher_a.find_duplicates()?;
745 assert_eq!(duplicates_a.len(), 1);
746 assert_eq!(hasher_a.num_hashed.load(atomic::Ordering::Relaxed), 0);
747 assert_eq!(
748 hasher_a.num_hash_looked_up.load(atomic::Ordering::Relaxed),
749 2
750 );
751
752 assert!(cache_a_path.exists());
754 assert!(!cache_aa_path.exists());
755
756 Ok(())
757 }
758
759 #[test]
760 fn find_duplicates_with_exclude() -> anyhow::Result<()> {
761 let dir = tempfile::tempdir()?;
762
763 let file1_path = dir.path().join("same1.txt");
764 fs::write(&file1_path, "same content")?;
765
766 let file2_path = dir.path().join("same2.txt");
767 fs::write(&file2_path, "same content")?;
768
769 let exclude_path = dir.path().join("exclude.txt");
770 fs::write(&exclude_path, "same content")?;
771
772 let mut hasher = FileHasher::new(&[dir.path()])?;
773 hasher.buffer_size = 8192;
774 let mut builder = globset::GlobSetBuilder::new();
775 builder.add(
776 globset::GlobBuilder::new("exclude.txt")
777 .case_insensitive(true)
778 .build()?,
779 );
780 let filter = builder.build()?;
781 hasher.exclude = Some(filter);
782
783 let duplicates = hasher.find_duplicates()?;
784 assert_eq!(duplicates.len(), 1);
785 let group = &duplicates[0];
786 assert_eq!(group.paths.len(), 2);
787 assert!(group.paths.contains(&file1_path));
788 assert!(group.paths.contains(&file2_path));
789 assert!(!group.paths.contains(&exclude_path));
790 Ok(())
791 }
792
793 #[derive(Default)]
794 struct CheckCollector {
795 start_seen: bool,
796 total_files: Option<u64>,
797 results: Vec<FileComparisonResult>,
798 file_done_count: u64,
799 num_error: usize,
800 }
801
802 impl CheckCollector {
803 fn collect(rx: mpsc::Receiver<CheckEvent>, base_dir: &Path) -> Self {
804 let mut collector = Self::default();
805 collector._collect(rx, base_dir);
806 collector
807 }
808
809 fn _collect(&mut self, rx: mpsc::Receiver<CheckEvent>, base_dir: &Path) {
810 while let Ok(event) = rx.recv() {
811 match event {
812 CheckEvent::StartChecking => self.start_seen = true,
813 CheckEvent::Total(total) => self.total_files = Some(total.num_files),
814 CheckEvent::Result(mut result) => {
815 result.relative_path = result
816 .relative_path
817 .strip_prefix(base_dir)
818 .unwrap()
819 .to_path_buf();
820 self.results.push(result);
821 }
822 CheckEvent::Progress(progress_val) => {
823 self.file_done_count += progress_val.num_files;
824 }
825 CheckEvent::Error(_) => {
826 self.num_error += 1;
827 }
828 }
829 }
830 }
831 }
832
833 #[test]
834 fn check_mode_empty_cache() -> anyhow::Result<()> {
835 let dir = tempfile::tempdir()?;
836 let dir_path = dir.path().to_path_buf();
837 println!("{:?}", dir_path);
838 let file1_path = dir.path().join("file1.txt");
839 fs::write(&file1_path, "content 1")?;
840 let file2_path = dir.path().join("file2.txt");
841 fs::write(&file2_path, "content 2")?;
842
843 let mut hasher = FileHasher::new(&[&dir_path])?;
844 hasher.exclude = Some(default_exclude());
845 let (tx, rx) = mpsc::channel();
846 hasher.check_streaming(tx, false)?;
847 let collector = CheckCollector::collect(rx, &dir_path);
848 assert!(collector.start_seen);
849 assert_eq!(collector.total_files, Some(2));
850 assert_eq!(collector.file_done_count, 2);
851 assert_eq!(collector.num_error, 0);
852
853 let mut results = collector.results;
854 results.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
855 assert_eq!(results.len(), 2);
856 assert_eq!(results[0].relative_path, Path::new("file1.txt"));
857 assert_eq!(results[0].classification, Classification::OnlyInDir2);
858 assert_eq!(results[1].relative_path, Path::new("file2.txt"));
859 assert_eq!(results[1].classification, Classification::OnlyInDir2);
860
861 assert!(!dir.path().join(FileHashCache::FILE_NAME).exists());
862 Ok(())
863 }
864
865 #[test]
866 fn check_mode_with_cache() -> anyhow::Result<()> {
867 let dir = tempfile::tempdir()?;
868 let dir_path = dir.path().to_path_buf();
869 let file1_path = dir.path().join("file1.txt");
870 let file2_path = dir.path().join("file2.txt");
871 fs::write(&file1_path, "content 1")?;
872 fs::write(&file2_path, "content 2")?;
873 let file1 = FileItem::try_from(file1_path.as_path())?;
874 let file2 = FileItem::try_from(file2_path.as_path())?;
875
876 let mut hasher = FileHasher::new_with_cache(&[&dir_path])?;
877 hasher.exclude = Some(default_exclude());
878 let _hash1 = hasher.get_hash(&file1)?;
879 let _hash2 = hasher.get_hash(&file2)?;
880 hasher.save_cache()?;
881 assert!(dir.path().join(FileHashCache::FILE_NAME).exists());
882
883 let mut hasher = FileHasher::new(&[&dir_path])?;
884 hasher.exclude = Some(default_exclude());
885 let (tx, rx) = mpsc::channel();
886 hasher.check_streaming(tx, false)?;
887 let collector = CheckCollector::collect(rx, &dir_path);
888 assert_eq!(collector.results.len(), 0);
889 assert_eq!(collector.file_done_count, 0);
890
891 fs::write(&file1_path, "content 1 modified")?;
892
893 let file2_meta_before = fs::metadata(&file2_path)?;
894 let mtime_before = file2_meta_before.modified()?;
895 std::thread::sleep(time::Duration::from_millis(10));
896 fs::write(&file2_path, "content 2")?;
897 let file2_meta_after = fs::metadata(&file2_path)?;
898 let mtime_after = file2_meta_after.modified()?;
899 assert!(mtime_after > mtime_before);
900
901 let mut hasher = FileHasher::new(&[&dir_path])?;
902 hasher.exclude = Some(default_exclude());
903 let (tx, rx) = mpsc::channel();
904 hasher.check_streaming(tx, false)?;
905 let collector = CheckCollector::collect(rx, &dir_path);
906 assert_eq!(collector.results.len(), 1);
907 let results = collector.results;
908 assert_eq!(results[0].relative_path, Path::new("file1.txt"));
909 assert_eq!(results[0].modified_time_comparison, Some(Ordering::Less));
910 assert_eq!(results[0].size_comparison, Some(Ordering::Less));
911 assert_eq!(results[0].is_content_same, None);
912 assert_eq!(collector.file_done_count, 1);
913 Ok(())
914 }
915
916 #[test]
917 fn check_update_mode() -> anyhow::Result<()> {
918 let dir = tempfile::tempdir()?;
919 let dir_path = dir.path().to_path_buf();
920 let file1_path = dir.path().join("file1.txt");
921 fs::write(&file1_path, "content 1")?;
922
923 let mut hasher = FileHasher::new(&[&dir_path])?;
924 hasher.exclude = Some(default_exclude());
925 let (tx, rx) = mpsc::channel();
926 hasher.check_streaming(tx, true)?;
927 let _ = CheckCollector::collect(rx, &dir_path);
928 hasher.save_cache()?;
929 assert!(dir.path().join(FileHashCache::FILE_NAME).exists());
930
931 let cache = FileHashCache::new(&dir_path);
932 let file1 = FileItem::try_from(file1_path.as_path())?;
933 let hash1 = cache.get(&PathBuf::from("file1.txt"), &file1);
934 assert!(hash1.is_some());
935
936 std::thread::sleep(time::Duration::from_millis(10));
937 fs::write(&file1_path, "content 1 modified")?;
938 let file1_mod = FileItem::try_from(file1_path.as_path())?;
939
940 let mut hasher = FileHasher::new(&[&dir_path])?;
941 hasher.exclude = Some(default_exclude());
942 let (tx, rx) = mpsc::channel();
943 hasher.check_streaming(tx, true)?;
944 let _ = CheckCollector::collect(rx, &dir_path);
945 hasher.save_cache()?;
946
947 let cache = FileHashCache::new(&dir_path);
948 let hash_mod = cache.get(&PathBuf::from("file1.txt"), &file1_mod);
949 assert!(hash_mod.is_some());
950 assert_ne!(hash1, hash_mod);
951
952 std::thread::sleep(time::Duration::from_millis(10));
953 fs::write(&file1_path, "content 1 modified")?;
954 let file1_mod2 = FileItem::try_from(file1_path.as_path())?;
955 assert!(file1_mod2.modified() > file1_mod.modified());
956
957 assert!(
958 cache
959 .get(&PathBuf::from("file1.txt"), &file1_mod2)
960 .is_none()
961 );
962
963 let mut hasher = FileHasher::new(&[&dir_path])?;
964 hasher.exclude = Some(default_exclude());
965 let (tx, rx) = mpsc::channel();
966 hasher.check_streaming(tx, true)?;
967 let _ = CheckCollector::collect(rx, &dir_path);
968 hasher.save_cache()?;
969
970 let cache = FileHashCache::new(&dir_path);
971 assert!(
972 cache
973 .get(&PathBuf::from("file1.txt"), &file1_mod2)
974 .is_some()
975 );
976 Ok(())
977 }
978
979 #[test]
980 fn check_cleanup_deleted_files() -> anyhow::Result<()> {
981 let dir = tempfile::tempdir()?;
982 let dir_path = dir.path().to_path_buf();
983 let file1_path = dir.path().join("file1.txt");
984 let file2_path = dir.path().join("file2.txt");
985 fs::write(&file1_path, "content 1")?;
986 fs::write(&file2_path, "content 2")?;
987 let file1 = FileItem::try_from(file1_path.as_path())?;
988 let file2 = FileItem::try_from(file2_path.as_path())?;
989
990 let mut hasher = FileHasher::new(&[&dir_path])?;
991 hasher.exclude = Some(default_exclude());
992 let (tx, rx) = mpsc::channel();
993 hasher.check_streaming(tx, true)?;
994 let _ = CheckCollector::collect(rx, &dir_path);
995 hasher.save_cache()?;
996
997 let cache = FileHashCache::new(&dir_path);
999 assert!(cache.get(&PathBuf::from("file1.txt"), &file1).is_some());
1000 assert!(cache.get(&PathBuf::from("file2.txt"), &file2).is_some());
1001
1002 fs::remove_file(&file2_path)?;
1004
1005 let mut hasher = FileHasher::new(&[&dir_path])?;
1007 hasher.exclude = Some(default_exclude());
1008 let (tx, rx) = mpsc::channel();
1009 hasher.check_streaming(tx, true)?;
1010 let _ = CheckCollector::collect(rx, &dir_path);
1011 hasher.save_cache()?;
1012
1013 let cache = FileHashCache::new(&dir_path);
1015 assert!(cache.get(&PathBuf::from("file2.txt"), &file2).is_none());
1016 assert!(cache.get(&PathBuf::from("file1.txt"), &file1).is_some());
1017 Ok(())
1018 }
1019
1020 #[test]
1021 fn find_duplicates_multiple_dirs() -> anyhow::Result<()> {
1022 let tmp = tempfile::tempdir()?;
1023 let dir1 = tmp.path().join("dir1");
1024 let dir2 = tmp.path().join("dir2");
1025 fs::create_dir(&dir1)?;
1026 fs::create_dir(&dir2)?;
1027 let file1_path = dir1.join("file1.txt");
1028 fs::write(&file1_path, "same content")?;
1029 let file2_path = dir2.join("file2.txt");
1030 fs::write(&file2_path, "same content")?;
1031 let hasher = FileHasher::new(&[&dir1, &dir2])?;
1032 let duplicates = hasher.find_duplicates()?;
1033 assert_eq!(duplicates.len(), 1);
1034 let group = &duplicates[0];
1035 assert_eq!(group.paths.len(), 2);
1036 assert_eq!(group.size, 12);
1037 assert!(group.paths.contains(&file1_path));
1038 assert!(group.paths.contains(&file2_path));
1039
1040 Ok(())
1041 }
1042
1043 #[test]
1044 fn check_fails_with_multiple_dirs() -> anyhow::Result<()> {
1045 let tmp = tempfile::tempdir()?;
1046 let dir1 = tmp.path().join("dir1");
1047 let dir2 = tmp.path().join("dir2");
1048 fs::create_dir(&dir1)?;
1049 fs::create_dir(&dir2)?;
1050 let hasher = FileHasher::new(&[&dir1, &dir2])?;
1051 assert!(hasher.check(false).is_err());
1052 Ok(())
1053 }
1054
1055 #[test]
1056 fn escape_shell() {
1057 let escape_shell = |p: &str| DuplicatedFiles::escape_quotes_for_shell(Path::new(p));
1058 assert_eq!(escape_shell(""), "");
1059 assert_eq!(escape_shell("abc"), "abc");
1060 assert_eq!(escape_shell("a'b"), "a'\\''b");
1061 assert_eq!(escape_shell("a'b'"), "a'\\''b'\\''");
1062
1063 let escape_shell_double = |p: &str| DuplicatedFiles::escape_quotes_by_double(Path::new(p));
1064 assert_eq!(escape_shell_double(""), "");
1065 assert_eq!(escape_shell_double("abc"), "abc");
1066 assert_eq!(escape_shell_double("a'b"), "a''b");
1067 assert_eq!(escape_shell_double("a'b'"), "a''b''");
1068 }
1069
1070 #[test]
1071 fn write_dups_shell_empty() -> anyhow::Result<()> {
1072 let dup_empty = DuplicatedFiles {
1073 paths: vec![],
1074 size: 100,
1075 };
1076 let mut buf = Vec::new();
1077 dup_empty.write_shell(&mut buf)?;
1078 assert_eq!(String::from_utf8(buf)?, "");
1079 Ok(())
1080 }
1081
1082 #[test]
1083 fn write_dups_shell_one() -> anyhow::Result<()> {
1084 let dup_one = DuplicatedFiles {
1085 paths: vec![PathBuf::from("a.txt")],
1086 size: 100,
1087 };
1088 let mut buf = Vec::new();
1089 dup_one.write_shell(&mut buf)?;
1090 assert_eq!(String::from_utf8(buf)?, "");
1091 Ok(())
1092 }
1093
1094 #[test]
1095 fn write_dups_shell_two() -> anyhow::Result<()> {
1096 let dup_multiple = DuplicatedFiles {
1097 paths: vec![PathBuf::from("a.txt"), PathBuf::from("b.txt")],
1098 size: 100,
1099 };
1100 let mut buf = Vec::new();
1101 dup_multiple.write_shell(&mut buf)?;
1102 assert_eq!(String::from_utf8(buf)?, "cp 'a.txt' 'b.txt'\n");
1103 Ok(())
1104 }
1105
1106 #[test]
1107 fn write_dups_shell_three() -> anyhow::Result<()> {
1108 let dup_multiple = DuplicatedFiles {
1109 paths: vec![
1110 PathBuf::from("a.txt"),
1111 PathBuf::from("b.txt"),
1112 PathBuf::from("c.txt"),
1113 ],
1114 size: 100,
1115 };
1116 let mut buf = Vec::new();
1117 dup_multiple.write_shell(&mut buf)?;
1118 assert_eq!(
1119 String::from_utf8(buf)?,
1120 "cp 'a.txt' 'b.txt'\ncp 'a.txt' 'c.txt'\n"
1121 );
1122 Ok(())
1123 }
1124
1125 #[test]
1126 fn write_dups_shell_quotes() -> anyhow::Result<()> {
1127 let dup_quotes = DuplicatedFiles {
1128 paths: vec![PathBuf::from("a'b.txt"), PathBuf::from("c'd.txt")],
1129 size: 100,
1130 };
1131 let mut buf = Vec::new();
1132 dup_quotes.write_shell(&mut buf)?;
1133 assert_eq!(String::from_utf8(buf)?, "cp 'a'\\''b.txt' 'c'\\''d.txt'\n");
1134
1135 let mut buf = Vec::new();
1136 dup_quotes.write_pwsh(&mut buf)?;
1137 assert_eq!(
1138 String::from_utf8(buf)?,
1139 "Copy-Item -LiteralPath 'a''b.txt' 'c''d.txt'\n"
1140 );
1141 Ok(())
1142 }
1143}