1use crate::{crossdev, walk};
2use anyhow::Context;
3use byte_unit::{Byte, Unit, UnitType};
4use serde::Deserialize;
5use std::collections::BTreeSet;
6use std::path::PathBuf;
7use std::sync::Arc;
8use std::sync::atomic::{AtomicBool, Ordering};
9use std::time::Duration;
10use std::{fmt, path::Path};
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
14pub enum ByteFormat {
15 #[serde(rename = "metric")]
17 Metric,
18 #[serde(rename = "binary")]
20 Binary,
21 #[serde(rename = "bytes")]
23 Bytes,
24 #[serde(rename = "gb")]
26 GB,
27 #[serde(rename = "gib")]
29 GiB,
30 #[serde(rename = "mb")]
32 MB,
33 #[serde(rename = "mib")]
35 MiB,
36}
37
38impl ByteFormat {
39 #[must_use]
41 pub fn width(self) -> usize {
42 use ByteFormat::{Binary, Bytes, MB, MiB};
43 match self {
44 Binary => 11,
45 Bytes | MB | MiB => 12,
46 _ => 10,
47 }
48 }
49 #[must_use]
51 pub fn total_width(self) -> usize {
52 use ByteFormat::{Binary, Bytes, GB, GiB, MB, Metric, MiB};
53 const THE_SPACE_BETWEEN_UNIT_AND_NUMBER: usize = 1;
54
55 self.width()
56 + match self {
57 Binary | MiB | GiB => 3,
58 Metric | MB | GB => 2,
59 Bytes => 1,
60 }
61 + THE_SPACE_BETWEEN_UNIT_AND_NUMBER
62 }
63 #[must_use]
65 pub fn display(self, bytes: u128) -> impl fmt::Display {
66 ByteFormatDisplay {
67 format: self,
68 bytes,
69 }
70 }
71}
72
73struct ByteFormatDisplay {
75 format: ByteFormat,
76 bytes: u128,
77}
78
79impl fmt::Display for ByteFormatDisplay {
80 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
81 use ByteFormat::{Binary, Bytes, GB, GiB, MB, Metric, MiB};
82
83 let bytes = Byte::from_u128(self.bytes).expect("supported byte count");
84 let adjusted = match self.format {
85 Bytes => return write!(f, "{} b", self.bytes),
86 Binary => bytes.get_appropriate_unit(UnitType::Binary),
87 Metric => bytes.get_appropriate_unit(UnitType::Decimal),
88 GB => bytes.get_adjusted_unit(Unit::GB),
89 GiB => bytes.get_adjusted_unit(Unit::GiB),
90 MB => bytes.get_adjusted_unit(Unit::MB),
91 MiB => bytes.get_adjusted_unit(Unit::MiB),
92 };
93 let b = format!("{adjusted:.2}");
94 let mut splits = b.split(' ');
95 match (splits.next(), splits.next()) {
96 (Some(bytes), Some(unit)) => write!(
97 f,
98 "{} {:>unit_width$}",
99 bytes,
100 unit,
101 unit_width = match self.format {
102 Binary => 3,
103 _ => 2,
104 }
105 ),
106 _ => f.write_str(&b),
107 }
108 }
109}
110
111#[derive(Debug)]
113pub(crate) struct Throttle {
114 trigger: Arc<AtomicBool>,
115}
116
117impl Throttle {
118 pub(crate) fn new(duration: Duration, initial_sleep: Option<Duration>) -> Self {
122 let instance = Self {
123 trigger: Arc::default(),
124 };
125
126 let trigger = Arc::downgrade(&instance.trigger);
127 std::thread::spawn(move || {
128 if let Some(duration) = initial_sleep {
129 std::thread::sleep(duration);
130 }
131 while let Some(t) = trigger.upgrade() {
132 t.store(true, Ordering::Relaxed);
133 std::thread::sleep(duration);
134 }
135 });
136
137 instance
138 }
139
140 pub(crate) fn throttled<F>(&self, f: F)
142 where
143 F: FnOnce(),
144 {
145 if self.can_update() {
146 f();
147 }
148 }
149
150 pub(crate) fn can_update(&self) -> bool {
152 self.trigger.swap(false, Ordering::Relaxed)
153 }
154}
155
156#[derive(Clone, Debug)]
166pub struct IgnorePatterns {
167 search: gix::ignore::Search,
168}
169
170impl IgnorePatterns {
171 pub fn from_files(files: &[PathBuf]) -> anyhow::Result<Option<Self>> {
178 let mut search = gix::ignore::Search::default();
179 for file in files {
180 let buf = std::fs::read(file).with_context(|| {
181 format!("Failed to read ignore patterns from {}", file.display())
182 })?;
183 search.add_patterns_buffer(
184 &buf,
185 file.clone(),
186 None,
187 gix::ignore::search::Ignore::default(),
188 );
189 }
190 let pattern_count = search
191 .patterns
192 .iter()
193 .map(|list| list.patterns.len())
194 .sum::<usize>();
195 Ok(if pattern_count != 0 {
196 log::info!(
197 "Loaded {pattern_count} ignore pattern(s) from {file_count} file(s)",
198 file_count = files.len()
199 );
200 Some(Self { search })
201 } else {
202 None
203 })
204 }
205
206 #[must_use]
213 pub fn is_excluded(&self, relative_path: &Path, is_dir: bool) -> bool {
214 if relative_path.as_os_str().is_empty() {
215 return false;
216 }
217 let relative_path =
218 gix::path::to_unix_separators_on_windows(gix::path::into_bstr(relative_path));
219 self.search
220 .pattern_matching_relative_path(
221 relative_path.as_ref(),
222 Some(is_dir),
223 gix::ignore::glob::pattern::Case::Sensitive,
224 )
225 .is_some_and(|match_| !match_.pattern.is_negative())
226 }
227
228 #[must_use]
234 pub fn excludes_input_path(&self, path: &Path, cwd: &Path) -> bool {
235 pattern_relative_path(path, cwd, path)
236 .is_some_and(|relative_path| self.is_excluded(relative_path, path.is_dir()))
237 }
238}
239
240#[derive(Clone)]
242pub struct WalkOptions {
243 pub threads: usize,
245 pub count_hard_links: bool,
247 pub apparent_size: bool,
249 pub cross_filesystems: bool,
251 pub ignore_dirs: BTreeSet<PathBuf>,
253 pub ignore_patterns: Option<IgnorePatterns>,
256}
257
258type ExcludeEntry = Arc<dyn Fn(usize, &walk::Entry) -> bool + Send + Sync>;
260
261pub(crate) struct WalkRoot {
263 pub index: usize,
265 pub path: PathBuf,
267 pub pattern_root: Option<PathBuf>,
274 pub device_id: u64,
277}
278
279impl WalkOptions {
280 pub(crate) fn iter_from_paths(
281 &self,
282 roots: Vec<WalkRoot>,
283 skip_root: bool,
284 order: walk::Order,
285 ) -> impl Iterator<Item = (usize, walk::RootEvent)> + use<> {
286 let num_roots = roots
287 .iter()
288 .map(|root| root.index)
289 .max()
290 .map_or(0, |idx| idx + 1);
291 let path_count = roots.len();
292 let (device_ids, root_paths, paths_with_idx) = roots.into_iter().fold(
293 (
294 vec![0; num_roots],
295 vec![None; num_roots],
296 Vec::with_capacity(path_count),
297 ),
298 |(mut device_ids, mut root_paths, mut paths), root| {
299 device_ids[root.index] = root.device_id;
300 root_paths[root.index] = root.pattern_root;
301 paths.push((root.index, root.path));
302 (device_ids, root_paths, paths)
303 },
304 );
305 let ignore_dirs = self.ignore_dirs.clone();
306 let cwd = std::env::current_dir().unwrap_or_default();
307 let cross_filesystems = self.cross_filesystems;
308
309 let is_excluded: ExcludeEntry = {
313 let patterns = self.ignore_patterns.clone();
314 let cwd = cwd.clone();
315 Arc::new(move |root_idx: usize, entry: &walk::Entry| {
316 let Some((patterns, pattern_root)) =
317 patterns.as_ref().zip(root_paths[root_idx].as_deref())
318 else {
319 return false;
320 };
321 let path = entry.path();
322 pattern_relative_path(&path, &cwd, pattern_root).is_some_and(|relative_path| {
323 patterns.is_excluded(relative_path, entry.file_type.is_dir())
324 })
325 })
326 };
327 let is_excluded_while_walking = Arc::clone(&is_excluded);
328
329 walk::walk_roots(
330 paths_with_idx,
331 self.threads,
332 order,
333 move |root_idx, entry| {
334 (cross_filesystems
335 || entry.metadata.as_ref().map_or(true, |metadata| {
336 crossdev::is_same_device(device_ids[root_idx], metadata)
337 }))
338 && (entry.depth == 0 || !ignore_directory(&entry.path(), &ignore_dirs, &cwd))
339 && !is_excluded_while_walking(root_idx, entry)
340 },
341 )
342 .filter(move |(root_idx, event)| match event {
343 walk::RootEvent::Entry(Ok(entry)) => {
344 (!skip_root || entry.depth > 0) && !is_excluded(*root_idx, entry)
345 }
346 walk::RootEvent::Entry(Err(_)) | walk::RootEvent::Finished => true,
347 })
348 }
349}
350
351#[derive(Default)]
353pub struct WalkResult {
354 pub num_errors: u64,
356}
357
358impl WalkResult {
359 #[must_use]
363 pub fn to_exit_code(&self) -> i32 {
364 i32::from(self.num_errors > 0)
365 }
366}
367
368pub fn canonicalize_ignore_dirs(ignore_dirs: &[PathBuf]) -> BTreeSet<PathBuf> {
372 let dirs = ignore_dirs
373 .iter()
374 .map(gix::path::realpath)
375 .filter_map(Result::ok)
376 .collect();
377 log::info!("Ignoring canonicalized {dirs:?}");
378 dirs
379}
380
381fn pattern_relative_path<'a>(
388 path: &'a Path,
389 cwd: &Path,
390 traversal_root: &Path,
391) -> Option<&'a Path> {
392 if path.is_relative() {
393 return Some(path);
394 }
395 path.strip_prefix(cwd)
396 .or_else(|_| path.strip_prefix(traversal_root))
397 .ok()
398}
399
400fn ignore_directory(path: &Path, ignore_dirs: &BTreeSet<PathBuf>, cwd: &Path) -> bool {
401 if ignore_dirs.is_empty() {
402 return false;
403 }
404 let path = gix::path::realpath_opts(path, cwd, 32);
405 path.is_ok_and(|path| {
406 let ignored = ignore_dirs.contains(&path);
407 if ignored {
408 log::debug!("Ignored {}", path.display());
409 }
410 ignored
411 })
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417
418 #[test]
419 fn test_ignore_directories() {
420 let cwd = std::env::current_dir().unwrap();
421 #[cfg(unix)]
422 let mut parameters = vec![
423 ("/usr", vec!["/usr"], true),
424 ("/usr/local", vec!["/usr"], false),
425 ("/smth", vec!["/usr"], false),
426 ("/usr/local/..", vec!["/usr/local/.."], true),
427 ("/usr", vec!["/usr/local/.."], true),
428 ("/usr/local/share/../..", vec!["/usr"], true),
429 ];
430
431 #[cfg(windows)]
432 let mut parameters = vec![
433 ("C:\\Windows", vec!["C:\\Windows"], true),
434 ("C:\\Windows\\System", vec!["C:\\Windows"], false),
435 ("C:\\Smth", vec!["C:\\Windows"], false),
436 (
437 "C:\\Windows\\System\\..",
438 vec!["C:\\Windows\\System\\.."],
439 true,
440 ),
441 ("C:\\Windows", vec!["C:\\Windows\\System\\.."], true),
442 (
443 "C:\\Windows\\System\\Speech\\..\\..",
444 vec!["C:\\Windows"],
445 true,
446 ),
447 ];
448
449 parameters.extend([
450 ("src", vec!["src"], true),
451 ("src/interactive", vec!["src"], false),
452 ("src/interactive/..", vec!["src"], true),
453 ]);
454
455 for (path, ignore_dirs, expected_result) in parameters {
456 let ignore_dirs = canonicalize_ignore_dirs(
457 &ignore_dirs.into_iter().map(Into::into).collect::<Vec<_>>(),
458 );
459 assert_eq!(
460 ignore_directory(path.as_ref(), &ignore_dirs, &cwd),
461 expected_result,
462 "result='{expected_result}' for path='{path}' and ignore_dir='{ignore_dirs:?}' "
463 );
464 }
465 }
466
467 #[test]
468 fn explicitly_selected_ignored_root_is_traversed() {
469 let root = tempfile::tempdir().unwrap();
470 let child = root.path().join("child");
471 std::fs::create_dir(&child).unwrap();
472 let options = WalkOptions {
473 threads: 2,
474 count_hard_links: false,
475 apparent_size: false,
476 cross_filesystems: true,
477 ignore_dirs: canonicalize_ignore_dirs(&[root.path().to_owned()]),
478 ignore_patterns: None,
479 };
480
481 let paths = options
482 .iter_from_paths(
483 vec![WalkRoot {
484 index: 0,
485 pattern_root: None,
486 path: root.path().to_owned(),
487 device_id: crossdev::init(root.path()).unwrap(),
488 }],
489 false,
490 walk::Order::Completion,
491 )
492 .filter_map(|(_, event)| match event {
493 walk::RootEvent::Entry(entry) => Some(entry.unwrap().path()),
494 walk::RootEvent::Finished => None,
495 })
496 .collect::<Vec<_>>();
497
498 assert!(paths.contains(&child));
499 }
500
501 #[test]
502 fn ignore_patterns_use_gitignore_semantics() {
503 let patterns = patterns_from(
504 "# a comment, and the blank line below, match nothing\n\
505 \n\
506 *.log\n\
507 !keep.log\n\
508 build/\n\
509 /anchored\n\
510 **/node_modules/\n",
511 );
512
513 for (path, is_dir, expected) in [
514 ("debug.log", false, true),
515 ("nested/debug.log", false, true),
516 ("keep.log", false, false),
517 ("build", true, true),
518 ("build", false, false),
520 ("anchored", true, true),
521 ("nested/anchored", true, false),
522 ("nested/deeply/node_modules", true, true),
523 ("src", true, false),
524 ("", true, false),
525 ] {
526 assert_eq!(
527 patterns.is_excluded(Path::new(path), is_dir),
528 expected,
529 "expected is_excluded({path:?}, is_dir={is_dir}) to be {expected}"
530 );
531 }
532 }
533
534 #[test]
535 fn later_ignore_files_win_over_earlier_ones() {
536 let dir = tempfile::tempdir().unwrap();
537 let (first, second) = (dir.path().join("first"), dir.path().join("second"));
538 std::fs::write(&first, "*.tmp\n").unwrap();
539 std::fs::write(&second, "!important.tmp\n").unwrap();
540
541 let patterns = IgnorePatterns::from_files(&[first, second])
542 .unwrap()
543 .unwrap();
544
545 assert!(patterns.is_excluded(Path::new("scratch.tmp"), false));
546 assert!(
547 !patterns.is_excluded(Path::new("important.tmp"), false),
548 "the negation in the second file overrides the first file"
549 );
550 }
551
552 #[test]
553 fn unreadable_ignore_files_are_an_error() {
554 let err = IgnorePatterns::from_files(&[PathBuf::from("does-not-exist")])
555 .expect_err("a missing pattern file must not be silently skipped");
556 assert!(err.to_string().contains("does-not-exist"));
557 }
558
559 #[test]
560 fn empty_ignore_files_produce_none() {
561 let file = tempfile::NamedTempFile::new().unwrap();
562 std::fs::write(file.path(), "# comment only\n").unwrap();
563 assert!(
564 IgnorePatterns::from_files(&[file.path().to_owned()])
565 .unwrap()
566 .is_none(),
567 "no patterns means no need to match anything"
568 );
569 }
570
571 #[test]
572 fn matching_entries_are_pruned_from_the_walk() {
573 let root = tempfile::tempdir().unwrap();
574 for dir in ["keep", "build", "keep/node_modules"] {
575 std::fs::create_dir(root.path().join(dir)).unwrap();
576 }
577 for file in [
578 "keep/main.rs",
579 "keep/debug.log",
580 "build/artifact",
581 "keep/node_modules/dep",
582 ] {
583 std::fs::write(root.path().join(file), b"x").unwrap();
584 }
585
586 assert_eq!(
587 walk_with_patterns(root.path(), "*.log\n**/node_modules/\n"),
588 [
589 PathBuf::new(),
590 PathBuf::from("build"),
591 PathBuf::from("build/artifact"),
592 PathBuf::from("keep"),
593 PathBuf::from("keep/main.rs"),
594 ],
595 "excluded directories are pruned along with everything below them, \
596 and excluded files never show up"
597 );
598 }
599
600 #[test]
601 fn patterns_match_the_path_dua_reports() {
602 let here = tempfile::tempdir().unwrap();
603 let elsewhere = tempfile::tempdir().unwrap();
604 let (cwd, outside) = (here.path(), elsewhere.path());
605
606 for dir in ["target", "nested", "nested/target"] {
607 std::fs::create_dir_all(here.path().join(dir)).unwrap();
608 }
609 assert_eq!(
610 walk_with_patterns(here.path(), "/target/\n"),
611 [
612 PathBuf::new(),
613 PathBuf::from("nested"),
614 PathBuf::from("nested/target"),
615 ],
616 "an anchored pattern excludes only the top-level target"
617 );
618
619 let (entry, root) = (cwd.join("a").join("b"), cwd.join("a"));
621 let reported = Path::new("a").join("b");
622 assert_eq!(
623 pattern_relative_path(&entry, cwd, &root),
624 Some(reported.as_path())
625 );
626
627 let entry = outside.join("syslog");
630 assert_eq!(
631 pattern_relative_path(&entry, cwd, outside),
632 Some(Path::new("syslog"))
633 );
634
635 assert_eq!(
637 pattern_relative_path(outside, cwd, outside),
638 Some(Path::new(""))
639 );
640 assert!(!patterns_from("*\n").is_excluded(Path::new(""), true));
641 }
642
643 #[test]
644 fn subtree_walk_keeps_the_original_pattern_root() {
645 let root = tempfile::tempdir().unwrap();
646 let nested = root.path().join("nested");
647 std::fs::create_dir(&nested).unwrap();
648 std::fs::write(nested.join("secret"), []).unwrap();
649 std::fs::write(nested.join("visible"), []).unwrap();
650 let options = WalkOptions {
651 threads: 1,
652 count_hard_links: false,
653 apparent_size: false,
654 cross_filesystems: true,
655 ignore_dirs: BTreeSet::default(),
656 ignore_patterns: Some(patterns_from("nested/secret\n")),
657 };
658
659 let paths = options
660 .iter_from_paths(
661 vec![WalkRoot {
662 index: 0,
663 path: nested,
664 pattern_root: Some(root.path().to_owned()),
665 device_id: 0,
666 }],
667 false,
668 walk::Order::Completion,
669 )
670 .filter_map(|(_, event)| match event {
671 walk::RootEvent::Entry(entry) => Some(entry.unwrap().file_name),
672 walk::RootEvent::Finished => None,
673 })
674 .collect::<Vec<_>>();
675
676 assert!(!paths.iter().any(|path| path == "secret"));
677 assert!(paths.iter().any(|path| path == "visible"));
678 }
679
680 #[test]
681 fn excluded_input_paths_are_dropped_before_the_walk() {
682 let patterns = patterns_from("src/\n*.toml\n");
684 let cwd = std::env::current_dir().unwrap();
685
686 assert!(
687 patterns.excludes_input_path(Path::new("src"), &cwd),
688 "a directory pattern matches a directory given as input"
689 );
690 assert!(patterns.excludes_input_path(Path::new("Cargo.toml"), &cwd));
691 assert!(!patterns.excludes_input_path(Path::new("README.md"), &cwd));
692 }
693
694 fn patterns_from(contents: &str) -> IgnorePatterns {
695 let file = tempfile::NamedTempFile::new().unwrap();
696 std::fs::write(file.path(), contents).unwrap();
697 IgnorePatterns::from_files(&[file.path().to_owned()])
698 .unwrap()
699 .unwrap()
700 }
701
702 fn walk_with_patterns(root: &Path, contents: &str) -> Vec<PathBuf> {
703 let options = WalkOptions {
704 threads: 2,
705 count_hard_links: false,
706 apparent_size: false,
707 cross_filesystems: true,
708 ignore_dirs: BTreeSet::default(),
709 ignore_patterns: Some(patterns_from(contents)),
710 };
711
712 let mut paths = options
713 .iter_from_paths(
714 vec![WalkRoot {
715 index: 0,
716 pattern_root: Some(root.to_owned()),
717 path: root.to_owned(),
718 device_id: crossdev::init(root).unwrap(),
719 }],
720 false,
721 walk::Order::Completion,
722 )
723 .filter_map(|(_, event)| match event {
724 walk::RootEvent::Entry(entry) => {
725 Some(entry.unwrap().path().strip_prefix(root).unwrap().to_owned())
726 }
727 walk::RootEvent::Finished => None,
728 })
729 .collect::<Vec<_>>();
730 paths.sort();
731 paths
732 }
733}