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