//! Reusable platform directory traversal primitives. //! //! # Overview //! `pi-walker` owns the native directory-read fast path that higher-level tools //! use for globbing, grep candidate discovery, AST scans, and shell builtins. //! The crate exposes plain Rust types, visitor interfaces, cache policy, and a //! caller-supplied heartbeat so consumers do not inherit N-API dependencies. mod cache; #[cfg(not(unix))] use std::ffi::OsString; #[cfg(unix)] use std::os::unix::ffi::OsStrExt; use std::{ borrow::Cow, cell::{Cell, RefCell}, cmp::Ordering, convert::Infallible, ffi::OsStr, fmt, hash::{Hash, Hasher}, io::{self, BufRead}, path::{Path, PathBuf}, sync::{ Arc, Mutex, atomic::{AtomicBool, Ordering as AtomicOrdering}, }, }; pub use cache::{ cache_ttl_ms, classify_file_type, contains_component, empty_recheck_ms, invalidate_all, invalidate_path, invalidate_path_string, max_cache_entries, normalize_relative_path, parallel_for_each, parallel_for_each_init, resolve_search_path, should_parallelize, should_skip_path, walk_workers, }; use globset::{GlobBuilder, GlobSet, GlobSetBuilder}; const HEARTBEAT_INTERVAL: usize = 128; /// Filesystem entry kind reported by the walker. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum FileType { /// Regular file. File, /// Directory. Dir, /// Symbolic link. Symlink, } /// Amount of metadata to collect while reading directories. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum WalkDetail { /// Collect only the entry name and file kind. Minimal, /// Also collect mtime and byte size for regular files. Full, } /// Traversal order for entries within each directory. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum WalkOrder { /// Visit entries in the order returned by the platform API. Unordered, /// Sort entries by filename before visiting them. Path, } /// How directory-open errors are handled during traversal. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum DirectoryErrorMode { /// Preserve the native glob fast-path contract: silently skip common race or /// permission failures and fail on other directory errors. SkipSkippable, /// Deliver directory errors to [`EntryVisitor::visit_directory_error`] so /// GNU-style consumers can report them and continue. Visit, } /// Symbolic-link traversal policy. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum FollowLinks { /// Never follow symbolic links. Never, /// Follow root operands when they are symbolic links, but not descendants. Roots, /// Follow symbolic links at every depth. Always, } impl From for FollowLinks { fn from(follow: bool) -> Self { if follow { Self::Always } else { Self::Never } } } impl FollowLinks { const fn follow_at_depth(self, depth: usize) -> bool { match self { Self::Never => false, Self::Roots => depth == 0, Self::Always => true, } } } /// Shared cache use policy for high-level walk requests. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum CachePolicy { /// Collect without using or updating the shared scan cache. Disabled, /// Use the shared scan cache for owned-entry collection. Enabled, } /// Empty cached-result revalidation policy for [`WalkRequest::collect`]. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum EmptyRecheck { /// Never re-scan an empty cached result. Never, /// Re-scan empty cached results at or above the configured /// [`empty_recheck_ms`] threshold. Configured, /// Re-scan empty cached results at or above this cache age. AfterMillis(u64), } /// Size metadata policy for high-level requests. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum SizeHintPolicy { /// Preserve the request's [`WalkDetail`] setting. FromDetail, /// Request minimal metadata even on platforms with cheap size hints. Never, /// Request full metadata only when the platform exposes cheap file sizes. WhenCheap, /// Request full metadata for every yielded entry. Always, } /// Directory visit order for high-level requests. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum VisitOrder { /// Yield a directory before its children. PreOrder, /// Yield a directory after its children when supported by the backend. ContentsFirst, } /// Concrete compiled glob filter for normalized walk-relative paths. /// /// Patterns are expected to already use the walker's normalized `/` separator /// form. Each pattern is compiled with [`GlobBuilder::literal_separator`] so /// wildcard matches never cross path separators. Equality and hashing use only /// the normalized pattern list, not the compiled matcher internals, which keeps /// [`WalkFilter`] suitable for static cacheable traversal policy. #[derive(Clone)] pub struct CompiledWalkGlob { patterns: Arc<[String]>, matcher: Arc, } impl CompiledWalkGlob { /// Compile normalized glob patterns for walk-relative paths. pub fn new(patterns: I) -> Result where P: Into, I: IntoIterator, { let mut normalized_patterns = Vec::new(); let mut builder = GlobSetBuilder::new(); for pattern in patterns { let pattern = pattern.into(); let glob = GlobBuilder::new(&pattern).literal_separator(true).build()?; builder.add(glob); normalized_patterns.push(pattern); } Ok(Self { patterns: normalized_patterns.into(), matcher: Arc::new(builder.build()?) }) } /// Return whether `relative` matches any compiled pattern. pub fn is_match(&self, relative: &str) -> bool { self.matcher.is_match(relative) } /// Return the normalized patterns backing this compiled filter. pub fn patterns(&self) -> &[String] { &self.patterns } } impl fmt::Debug for CompiledWalkGlob { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("CompiledWalkGlob") .field("patterns", &self.patterns) .finish() } } impl PartialEq for CompiledWalkGlob { fn eq(&self, other: &Self) -> bool { self.patterns == other.patterns } } impl Eq for CompiledWalkGlob {} impl Hash for CompiledWalkGlob { fn hash(&self, state: &mut H) { self.patterns.hash(state); } } /// High-level entry filter applied by collection and streaming APIs. #[derive(Clone)] pub struct WalkFilter { kind: WalkFilterKind, max_file_size: Option, skip_node_modules_unless_seen: bool, mentions_node_modules: bool, glob: Option, } #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] enum WalkFilterKind { All, Files, Dirs, } impl Default for WalkFilter { fn default() -> Self { Self::all() } } impl fmt::Debug for WalkFilter { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("WalkFilter") .field("kind", &self.kind) .field("max_file_size", &self.max_file_size) .field("skip_node_modules_unless_seen", &self.skip_node_modules_unless_seen) .field("mentions_node_modules", &self.mentions_node_modules) .field("glob", &self.glob) .finish() } } impl PartialEq for WalkFilter { fn eq(&self, other: &Self) -> bool { self.kind == other.kind && self.max_file_size == other.max_file_size && self.skip_node_modules_unless_seen == other.skip_node_modules_unless_seen && self.mentions_node_modules == other.mentions_node_modules && self.glob == other.glob } } impl Eq for WalkFilter {} impl Hash for WalkFilter { fn hash(&self, state: &mut H) { self.kind.hash(state); self.max_file_size.hash(state); self.skip_node_modules_unless_seen.hash(state); self.mentions_node_modules.hash(state); self.glob.hash(state); } } impl WalkFilter { /// Return a filter that accepts files and directories. pub const fn all() -> Self { Self { kind: WalkFilterKind::All, max_file_size: None, skip_node_modules_unless_seen: false, mentions_node_modules: false, glob: None, } } /// Return a filter that emits only regular files. pub const fn files_only() -> Self { Self { kind: WalkFilterKind::Files, max_file_size: None, skip_node_modules_unless_seen: false, mentions_node_modules: false, glob: None, } } /// Return a filter that emits only directories. pub const fn dirs_only() -> Self { Self { kind: WalkFilterKind::Dirs, max_file_size: None, skip_node_modules_unless_seen: false, mentions_node_modules: false, glob: None, } } /// Limit emitted regular files to `max_file_size` bytes. pub const fn max_file_size(mut self, max_file_size: u64) -> Self { self.max_file_size = Some(max_file_size); self } /// Skip `node_modules` entries unless the caller's query mentioned them. pub const fn node_modules_unless_mentioned(mut self, mentions_node_modules: bool) -> Self { self.skip_node_modules_unless_seen = true; self.mentions_node_modules = mentions_node_modules; self } /// Accept only entries whose normalized relative path matches `glob`. pub fn glob(mut self, glob: CompiledWalkGlob) -> Self { self.glob = Some(glob); self } fn accepts_path(&self, relative_path: &str) -> bool { self .glob .as_ref() .is_none_or(|glob| glob.is_match(relative_path)) } fn accepts_collected(&self, entry: &CollectedEntry) -> bool { if self.skip_node_modules_unless_seen && !self.mentions_node_modules && entry .path .split('/') .any(|component| component == "node_modules") { return false; } if self.max_file_size.is_some_and(|max| { entry.file_type == FileType::File && entry.size.is_some_and(|size| size > max as f64) }) { return false; } let accepts_kind = match self.kind { WalkFilterKind::All => true, WalkFilterKind::Files => entry.file_type == FileType::File, WalkFilterKind::Dirs => entry.file_type == FileType::Dir, }; accepts_kind && self.accepts_path(&entry.path) } fn stream_decision(&self, meta: &EntryMeta<'_>) -> WalkDecision { if self.skip_node_modules_unless_seen && !self.mentions_node_modules && meta .relative_path .split('/') .any(|component| component == "node_modules") { return if meta.file_type == FileType::Dir { WalkDecision::SkipDescend } else { WalkDecision::Skip }; } if self.max_file_size.is_some_and(|max| { meta.file_type == FileType::File && meta.size.is_some_and(|size| size > max as f64) }) { return WalkDecision::Skip; } let accepts_kind = match self.kind { WalkFilterKind::All => true, WalkFilterKind::Files => meta.file_type == FileType::File, WalkFilterKind::Dirs => meta.file_type == FileType::Dir, }; if !accepts_kind { return WalkDecision::Skip; } if self.accepts_path(meta.relative_path) { WalkDecision::Include } else { WalkDecision::Skip } } } /// Traversal decision returned by [`WalkPredicate`] and closure streaming APIs. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum WalkDecision { /// Emit this entry and continue traversal. Include, /// Do not emit this entry, but continue traversal. Skip, /// Do not emit this directory and do not descend into it. SkipDescend, /// Stop traversal immediately. Stop, } /// Predicate hook for dynamic walk consumers. pub trait WalkPredicate { /// Decide how the high-level walker should handle `entry`. fn decide(&mut self, entry: &EntryMeta<'_>) -> WalkDecision; } impl WalkPredicate for F where F: for<'entry, 'meta> FnMut(&'entry EntryMeta<'meta>) -> WalkDecision, { fn decide(&mut self, entry: &EntryMeta<'_>) -> WalkDecision { self(entry) } } #[derive(Clone, Copy, Debug, Default)] struct IncludeAllPredicate; impl WalkPredicate for IncludeAllPredicate { fn decide(&mut self, _entry: &EntryMeta<'_>) -> WalkDecision { WalkDecision::Include } } /// Borrowed metadata view shared by owned and streaming entries. pub struct EntryMeta<'a> { /// Traversal root used to resolve relative paths. pub root: &'a Path, /// Absolute filesystem path. pub absolute_path: Cow<'a, Path>, /// Relative path from the root, using `/` separators. pub relative_path: &'a str, /// Filesystem entry kind. pub file_type: FileType, /// Modification time in milliseconds since the Unix epoch, when requested. pub mtime: Option, /// File size in bytes for regular files, when requested. pub size: Option, /// Depth below the traversal root. Root depth is 0. pub depth: usize, } impl<'a> EntryMeta<'a> { /// Build metadata for an owned collected entry. pub fn from_collected(root: &'a Path, entry: &'a CollectedEntry) -> Self { Self { root, absolute_path: Cow::Owned(entry.absolute_path(root)), relative_path: &entry.path, file_type: entry.file_type, mtime: entry.mtime, size: entry.size, depth: entry.depth(), } } /// Build metadata for a borrowed streaming entry. pub const fn from_entry(root: &'a Path, entry: &Entry<'a>) -> Self { Self { root, absolute_path: Cow::Borrowed(entry.path), relative_path: entry.relative, file_type: entry.file_type, mtime: entry.mtime, size: entry.size, depth: entry.depth, } } } /// Owned regular-file candidate returned by high-level file collection. #[derive(Clone, Debug, PartialEq)] pub struct FileCandidate { /// Absolute filesystem path to the regular file. pub path: PathBuf, /// Relative path from the walk root, using `/` separators. pub relative: String, /// Modification time in milliseconds since the Unix epoch, when requested. pub mtime: Option, /// File size in bytes, when requested. pub size: Option, } impl FileCandidate { fn from_entry(root: &Path, entry: CollectedEntry) -> Self { Self { path: entry.absolute_path(root), relative: entry.path, mtime: entry.mtime, size: entry.size, } } } /// Backend path used by a high-level collection. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum WalkBackend { /// The request returned entries from a fresh backend scan. Fresh, /// The request returned entries from the shared cache. Cached, } /// Ranking applied to high-level collected entries after filtering. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum WalkRank { /// Sort by normalized relative path in ascending byte order. PathAsc, /// Sort by modification time descending, then normalized relative path /// ascending. /// /// Entries without modification times sort after entries with modification /// times. [`WalkRequest::collect_ranked_with_heartbeat`] requests full /// metadata for this rank so fresh scans can populate the mtime field. MtimeDescPathAsc, } /// Summary statistics for a high-level collection. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub struct WalkStats { /// Age of the cache entry in milliseconds; zero means freshly scanned. pub cache_age_ms: u64, /// Entries before high-level filtering. pub scanned_entries: usize, /// Entries removed by high-level filtering. pub filtered_entries: usize, /// Entries removed by the high-level limit. pub limited_entries: usize, } /// Owned entries and metadata returned by [`WalkRequest::collect`]. #[derive(Clone, Debug, PartialEq)] pub struct WalkOutcome { /// Entries after high-level filtering and limits. pub entries: Vec, /// Collection backend classification. pub backend: WalkBackend, /// Collection statistics. pub stats: WalkStats, } /// Options shared by native traversal consumers. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub struct WalkOptions { /// Include dot-prefixed entries. pub include_hidden: bool, /// Honor `.ignore`, `.gitignore`, repository excludes, and global gitignore. pub use_gitignore: bool, /// Prune `.git` directories during traversal. pub skip_git: bool, /// Prune `node_modules` directories during traversal. pub skip_node_modules: bool, /// Symbolic-link traversal policy. pub follow_links: FollowLinks, /// Metadata detail requested for each yielded entry. pub detail: WalkDetail, /// Per-directory visit order. pub order: WalkOrder, /// Yield the traversal root as a depth-0 entry before its children. pub emit_root: bool, /// Minimum depth yielded to the visitor. Root depth is 0. pub min_depth: usize, /// Maximum depth traversed and yielded. Root depth is 0. pub max_depth: usize, /// Yield directory entries after their children. pub contents_first: bool, /// Directory-open error handling policy. pub directory_errors: DirectoryErrorMode, /// Stay on the root filesystem when supported by the platform. pub same_file_system: bool, /// Use the shared scan cache when collecting owned entries. pub cache: bool, } impl Default for WalkOptions { fn default() -> Self { Self { include_hidden: true, use_gitignore: false, skip_git: false, skip_node_modules: false, follow_links: FollowLinks::Never, detail: WalkDetail::Minimal, order: WalkOrder::Path, emit_root: false, min_depth: 1, max_depth: usize::MAX, contents_first: false, directory_errors: DirectoryErrorMode::Visit, same_file_system: false, cache: false, } } } /// High-level traversal request that owns a root and wraps [`WalkOptions`]. #[derive(Clone, Debug, Eq, PartialEq)] pub struct WalkRequest { root: PathBuf, options: WalkOptions, cache_policy: CachePolicy, filter: WalkFilter, limit: Option, empty_recheck: EmptyRecheck, visit_order: VisitOrder, size_hint_policy: SizeHintPolicy, } impl WalkRequest { /// Create a request rooted at `root` with default [`WalkOptions`]. pub fn new(root: impl Into) -> Self { Self::from_options(root, WalkOptions::default()) } /// Create a request from existing low-level options. pub fn from_options(root: impl Into, options: WalkOptions) -> Self { let cache_policy = if options.cache { CachePolicy::Enabled } else { CachePolicy::Disabled }; let visit_order = if options.contents_first { VisitOrder::ContentsFirst } else { VisitOrder::PreOrder }; Self { root: root.into(), options, cache_policy, filter: WalkFilter::default(), limit: None, empty_recheck: EmptyRecheck::Configured, visit_order, size_hint_policy: SizeHintPolicy::FromDetail, } } /// Return the traversal root. pub fn root(&self) -> &Path { &self.root } /// Return low-level options after applying high-level policies. pub const fn options(&self) -> WalkOptions { self.effective_options() } /// Include or exclude dot-prefixed entries. pub const fn hidden(mut self, include_hidden: bool) -> Self { self.options.include_hidden = include_hidden; self } /// Enable or disable `.ignore`/gitignore matching. pub const fn gitignore(mut self, use_gitignore: bool) -> Self { self.options.use_gitignore = use_gitignore; self } /// Enable or disable pruning `.git` directories. pub const fn skip_git(mut self, skip_git: bool) -> Self { self.options.skip_git = skip_git; self } /// Enable or disable pruning `node_modules` directories during traversal. pub const fn skip_node_modules(mut self, skip_node_modules: bool) -> Self { self.options.skip_node_modules = skip_node_modules; self } /// Set symbolic-link traversal policy. pub const fn follow_links(mut self, follow_links: FollowLinks) -> Self { self.options.follow_links = follow_links; self } /// Set metadata detail collected for entries. pub const fn detail(mut self, detail: WalkDetail) -> Self { self.options.detail = detail; self } /// Set per-directory entry order. pub const fn order(mut self, order: WalkOrder) -> Self { self.options.order = order; self } /// Enable or disable emitting the root entry. pub const fn emit_root(mut self, emit_root: bool) -> Self { self.options.emit_root = emit_root; self } /// Set minimum and maximum traversal depth. pub const fn depth(mut self, min_depth: usize, max_depth: usize) -> Self { self.options.min_depth = min_depth; self.options.max_depth = max_depth; self } /// Set directory-open error handling. pub const fn directory_errors(mut self, directory_errors: DirectoryErrorMode) -> Self { self.options.directory_errors = directory_errors; self } /// Enable or disable staying on the root filesystem. pub const fn same_file_system(mut self, same_file_system: bool) -> Self { self.options.same_file_system = same_file_system; self } /// Enable or disable the shared scan cache for owned collection. pub const fn cache(mut self, cache: bool) -> Self { self.cache_policy = if cache { CachePolicy::Enabled } else { CachePolicy::Disabled }; self.options.cache = cache; self } /// Set the static high-level filter. pub fn filter(mut self, filter: WalkFilter) -> Self { self.filter = filter; self } /// Limit the number of emitted entries after filtering. pub const fn limit(mut self, limit: usize) -> Self { self.limit = Some(limit); self } /// Remove any high-level entry limit. pub const fn no_limit(mut self) -> Self { self.limit = None; self } /// Set empty cached-result revalidation policy. pub const fn empty_recheck(mut self, empty_recheck: EmptyRecheck) -> Self { self.empty_recheck = empty_recheck; self } /// Set high-level directory visit order. pub const fn visit_order(mut self, visit_order: VisitOrder) -> Self { self.visit_order = visit_order; self.options.contents_first = matches!(visit_order, VisitOrder::ContentsFirst); self } /// Set size metadata policy. pub const fn size_hints(mut self, size_hint_policy: SizeHintPolicy) -> Self { self.size_hint_policy = size_hint_policy; self } /// Collect owned entries, then apply high-level filters, limits, and /// empty-cache rechecks. pub fn collect(&self) -> std::result::Result> { self.collect_with_heartbeat(|| Ok::<(), Infallible>(())) } /// Collect owned entries with a caller-supplied heartbeat. pub fn collect_with_heartbeat( &self, heartbeat: H, ) -> std::result::Result> where H: Fn() -> std::result::Result<(), E> + Sync, E: fmt::Display, { self.collect_with_rank_and_limit(None, self.limit, heartbeat) } /// Collect owned entries, apply high-level filters, rank, then truncate to /// `limit`. /// /// The request's stored [`WalkRequest::limit`] is intentionally not applied /// before ranking; `limit` is the top-N bound for this ranked collection. pub fn collect_ranked( &self, rank: WalkRank, limit: usize, ) -> std::result::Result> { self.collect_ranked_with_heartbeat(rank, limit, || Ok::<(), Infallible>(())) } /// Collect owned entries with a caller-supplied heartbeat, apply high-level /// filters, rank, then truncate to `limit`. /// /// Ranking happens after all high-level filters and before truncation so /// top-N callers observe the best matching entries, not the first traversed /// entries. pub fn collect_ranked_with_heartbeat( &self, rank: WalkRank, limit: usize, heartbeat: H, ) -> std::result::Result> where H: Fn() -> std::result::Result<(), E> + Sync, E: fmt::Display, { self.collect_with_rank_and_limit(Some(rank), Some(limit), heartbeat) } /// Collect regular files accepted by this request. pub fn collect_files(&self) -> std::result::Result, WalkError> { self.collect_files_with_heartbeat(|| Ok::<(), Infallible>(())) } /// Collect regular files accepted by this request with a caller-supplied /// heartbeat. pub fn collect_files_with_heartbeat( &self, heartbeat: H, ) -> std::result::Result, WalkError> where H: Fn() -> std::result::Result<(), E> + Sync, E: fmt::Display, { let outcome = self.collect_with_heartbeat(heartbeat)?; Ok(outcome .entries .into_iter() .filter(CollectedEntry::is_file) .collect()) } /// Collect directories accepted by this request. pub fn collect_dirs(&self) -> std::result::Result, WalkError> { self.collect_dirs_with_heartbeat(|| Ok::<(), Infallible>(())) } /// Collect directories accepted by this request with a caller-supplied /// heartbeat. pub fn collect_dirs_with_heartbeat( &self, heartbeat: H, ) -> std::result::Result, WalkError> where H: Fn() -> std::result::Result<(), E> + Sync, E: fmt::Display, { let outcome = self.collect_with_heartbeat(heartbeat)?; Ok(outcome .entries .into_iter() .filter(CollectedEntry::is_dir) .collect()) } /// Collect regular-file candidates accepted by this request. pub fn collect_file_candidates( &self, ) -> std::result::Result, WalkError> { self.collect_file_candidates_with_heartbeat(|| Ok::<(), Infallible>(())) } /// Collect regular-file candidates accepted by this request with a /// caller-supplied heartbeat. pub fn collect_file_candidates_with_heartbeat( &self, heartbeat: H, ) -> std::result::Result, WalkError> where H: Fn() -> std::result::Result<(), E> + Sync, E: fmt::Display, { Ok(self .collect_file_candidates_with_stats_with_heartbeat(heartbeat)? .0) } /// Stream entries through `visitor` after applying high-level filters and /// limits. pub fn stream(&self, visitor: &mut V) -> std::result::Result> where V: EntryVisitor, { self.stream_with_heartbeat(visitor, || Ok::<(), V::Error>(())) } /// Stream entries through `visitor` with a caller-supplied heartbeat. pub fn stream_with_heartbeat( &self, visitor: &mut V, heartbeat: H, ) -> std::result::Result> where V: EntryVisitor, H: FnMut() -> std::result::Result<(), V::Error>, { self.stream_with_predicate_and_heartbeat(visitor, IncludeAllPredicate, heartbeat) } /// Stream accepted entries through a closure after applying high-level /// filters and limits. /// /// This is the closure-based counterpart to [`WalkRequest::stream`]. The /// closure receives borrowed [`EntryMeta`] for each entry that passes this /// request's static [`WalkFilter`] and dynamic request limit, then returns a /// [`WalkDecision`] to control traversal. [`WalkDecision::Include`] and /// [`WalkDecision::Skip`] both continue because the entry has already been /// delivered to the closure; [`WalkDecision::SkipDescend`] prunes the /// current directory's descendants, and [`WalkDecision::Stop`] stops /// traversal. /// /// Directory-open errors are ignored and traversal continues when /// [`WalkOptions::directory_errors`] is [`DirectoryErrorMode::Visit`]. Use /// [`WalkRequest::for_each_entry_with_heartbeat`] to observe those errors or /// provide a heartbeat. pub fn for_each_entry(&self, visit: V) -> std::result::Result> where V: for<'entry> FnMut(EntryMeta<'entry>) -> std::result::Result, { self.for_each_entry_with_heartbeat( || Ok::<(), E>(()), visit, |_| Ok::(WalkDecision::Include), ) } /// Stream accepted entries through closures with a caller-supplied /// heartbeat. /// /// `heartbeat` is invoked by the same traversal machinery used by /// [`WalkRequest::stream_with_heartbeat`]. `visit` receives each accepted /// [`EntryMeta`] and returns a [`WalkDecision`]: [`WalkDecision::Include`] and /// [`WalkDecision::Skip`] continue, [`WalkDecision::SkipDescend`] skips /// descendants of the current directory, and [`WalkDecision::Stop`] stops /// the walk. `directory_error` receives [`DirectoryError`] values when the /// request's [`WalkOptions::directory_errors`] is /// [`DirectoryErrorMode::Visit`] and returns the same traversal decision, /// letting GNU-style consumers report an error and continue or stop without /// implementing [`EntryVisitor`]. pub fn for_each_entry_with_heartbeat( &self, heartbeat: H, visit: V, directory_error: D, ) -> std::result::Result> where H: FnMut() -> std::result::Result<(), E>, V: for<'entry> FnMut(EntryMeta<'entry>) -> std::result::Result, D: for<'error> FnMut(DirectoryError<'error>) -> std::result::Result, { let mut visitor = ClosureEntryVisitor { root: &self.root, visit, directory_error }; self.stream_with_heartbeat(&mut visitor, heartbeat) } /// Stream entries through `visitor` with an additional dynamic predicate. pub fn stream_with_predicate( &self, visitor: &mut V, predicate: P, ) -> std::result::Result> where V: EntryVisitor, P: WalkPredicate, { self.stream_with_predicate_and_heartbeat(visitor, predicate, || Ok::<(), V::Error>(())) } /// Stream entries through `visitor` with a dynamic predicate and /// caller-supplied heartbeat. pub fn stream_with_predicate_and_heartbeat( &self, visitor: &mut V, predicate: P, mut heartbeat: H, ) -> std::result::Result> where V: EntryVisitor, P: WalkPredicate, H: FnMut() -> std::result::Result<(), V::Error>, { let options = self.effective_options(); let mut adapter = RequestVisitor { root: &self.root, filter: &self.filter, limit: self.limit, emitted: 0, visitor, predicate, }; walk_entries(&self.root, options, &mut adapter, &mut heartbeat) } /// Run `operation` for each accepted regular file. pub fn for_each_file( &self, operation: impl Fn(&Path) -> std::result::Result<(), E> + Send + Sync, ) -> std::result::Result> where E: fmt::Display + Send, { self.for_each_file_with_heartbeat(operation, || Ok::<(), Infallible>(())) } /// Run `operation` for each accepted regular file with a caller-supplied /// heartbeat. pub fn for_each_file_with_heartbeat( &self, operation: impl Fn(&Path) -> std::result::Result<(), E> + Send + Sync, heartbeat: H, ) -> std::result::Result> where E: fmt::Display + Send, H: Fn() -> std::result::Result<(), HE> + Sync, HE: fmt::Display, { self.for_each_file_candidate_with_heartbeat(|candidate| operation(&candidate.path), heartbeat) } /// Run `operation` for each accepted regular-file candidate. pub fn for_each_file_candidate( &self, operation: impl Fn(&FileCandidate) -> std::result::Result<(), E> + Send + Sync, ) -> std::result::Result> where E: fmt::Display + Send, { self.for_each_file_candidate_with_heartbeat(operation, || Ok::<(), Infallible>(())) } /// Run `operation` for each accepted regular-file candidate with a /// caller-supplied heartbeat. pub fn for_each_file_candidate_with_heartbeat( &self, operation: impl Fn(&FileCandidate) -> std::result::Result<(), E> + Send + Sync, heartbeat: H, ) -> std::result::Result> where E: fmt::Display + Send, H: Fn() -> std::result::Result<(), HE> + Sync, HE: fmt::Display, { let (candidates, stats) = self.collect_file_candidates_with_stats_with_heartbeat(heartbeat)?; execute_candidates(&candidates, operation) .map_err(|err| WalkError::Interrupted(err.to_string()))?; Ok(stats) } /// Visit accepted regular-file candidates using an unordered parallel walk. /// /// This is a files-only API for consumers that own their output ordering. /// Candidates may be delivered in any order. [`WalkOptions::order`], /// [`WalkRequest::visit_order`], [`WalkOptions::emit_root`], and /// [`WalkRequest::limit`] are ignored. Directory-open errors are skipped /// with grep-style semantics instead of being delivered to visitors. /// /// [`ParallelWalkControl::Stop`] sets a shared stop flag; workers check that /// flag before reading each directory and while processing directory /// entries, then the method returns [`WalkStatus::Stopped`] after in-flight /// work winds down. If a sink or heartbeat returns an error, the first /// error wins and is returned as [`WalkError::Interrupted`]. pub fn for_each_file_candidate_parallel( &self, sink: impl Fn(&FileCandidate) -> std::result::Result + Send + Sync, heartbeat: impl Fn() -> std::result::Result<(), E> + Send + Sync, ) -> std::result::Result> where E: Send, { run_file_candidate_parallel(self, &sink, &heartbeat) } fn collect_with_rank_and_limit( &self, rank: Option, limit: Option, heartbeat: H, ) -> std::result::Result> where H: Fn() -> std::result::Result<(), E> + Sync, E: fmt::Display, { let mut options = self.effective_options(); if matches!(rank, Some(WalkRank::MtimeDescPathAsc)) { options.detail = WalkDetail::Full; } let mut scan = self.collect_entries_with_options(options, &heartbeat)?; let mut backend = if scan.cache_age_ms == 0 { WalkBackend::Fresh } else { WalkBackend::Cached }; let filter_entries = |entries: &mut Vec| { let scanned_entries = entries.len(); entries.retain(|entry| self.filter.accepts_collected(entry)); (scanned_entries, scanned_entries - entries.len()) }; let (mut scanned_entries, mut filtered_entries) = filter_entries(&mut scan.entries); if scan.entries.is_empty() && self.should_recheck_empty(scan.cache_age_ms) { options.cache = false; scan = self.collect_entries_with_options(options, &heartbeat)?; backend = WalkBackend::Fresh; (scanned_entries, filtered_entries) = filter_entries(&mut scan.entries); } if let Some(rank) = rank { Self::rank_entries(&mut scan.entries, rank); } let limited_entries = if let Some(limit) = limit { let limited_entries = scan.entries.len().saturating_sub(limit); scan.entries.truncate(limit); limited_entries } else { 0 }; let stats = WalkStats { cache_age_ms: scan.cache_age_ms, scanned_entries, filtered_entries, limited_entries, }; Ok(WalkOutcome { entries: scan.entries, backend, stats }) } fn rank_entries(entries: &mut [CollectedEntry], rank: WalkRank) { match rank { WalkRank::PathAsc => entries.sort_by(|left, right| left.path.cmp(&right.path)), WalkRank::MtimeDescPathAsc => entries.sort_by(Self::compare_mtime_desc_path_asc), } } fn compare_mtime_desc_path_asc(left: &CollectedEntry, right: &CollectedEntry) -> Ordering { let mtime_order = match (left.mtime, right.mtime) { (Some(left_mtime), Some(right_mtime)) => right_mtime.total_cmp(&left_mtime), (Some(_), None) => Ordering::Less, (None, Some(_)) => Ordering::Greater, (None, None) => Ordering::Equal, }; mtime_order.then_with(|| left.path.cmp(&right.path)) } const fn effective_options(&self) -> WalkOptions { let mut options = self.options; options.cache = matches!(self.cache_policy, CachePolicy::Enabled); options.contents_first = matches!(self.visit_order, VisitOrder::ContentsFirst); match self.size_hint_policy { SizeHintPolicy::FromDetail => {}, SizeHintPolicy::Never => options.detail = WalkDetail::Minimal, SizeHintPolicy::WhenCheap => { options.detail = if supports_cheap_size_hints() { WalkDetail::Full } else { WalkDetail::Minimal }; }, SizeHintPolicy::Always => options.detail = WalkDetail::Full, } if self.filter.max_file_size.is_some() { options.detail = WalkDetail::Full; } options } fn collect_entries_with_options( &self, options: WalkOptions, heartbeat: &H, ) -> std::result::Result> where H: Fn() -> std::result::Result<(), E> + Sync, E: fmt::Display, { collect_entries(&self.root, options, heartbeat) } fn should_recheck_empty(&self, cache_age_ms: u64) -> bool { if cache_age_ms == 0 { return false; } match self.empty_recheck { EmptyRecheck::Never => false, EmptyRecheck::Configured => { let threshold = empty_recheck_ms(); threshold > 0 && cache_age_ms >= threshold }, EmptyRecheck::AfterMillis(threshold) => cache_age_ms >= threshold, } } fn collect_file_candidates_with_stats_with_heartbeat( &self, heartbeat: H, ) -> std::result::Result<(Vec, WalkStats), WalkError> where H: Fn() -> std::result::Result<(), E> + Sync, E: fmt::Display, { let outcome = self.collect_with_heartbeat(heartbeat)?; let candidates = outcome .entries .into_iter() .filter(CollectedEntry::is_file) .map(|entry| FileCandidate::from_entry(&self.root, entry)) .collect(); Ok((candidates, outcome.stats)) } } /// Execute work for regular-file candidates using the centralized walker pool. pub fn execute_candidates( candidates: &[FileCandidate], operation: impl Fn(&FileCandidate) -> std::result::Result<(), E> + Send + Sync, ) -> std::result::Result<(), E> where E: Send, { parallel_for_each(candidates, operation) } /// Execute work for regular-file candidates with per-worker state. pub fn execute_candidates_init( candidates: &[FileCandidate], init: impl Fn() -> S + Send + Sync, operation: impl Fn(&mut S, &FileCandidate) -> std::result::Result<(), E> + Send + Sync, ) -> std::result::Result<(), E> where S: Send, E: Send, { parallel_for_each_init(candidates, init, operation) } struct SerialCandidateVisitor<'a, S> { filter: &'a WalkFilter, sink: &'a S, } impl EntryVisitor for SerialCandidateVisitor<'_, S> where S: Fn(&FileCandidate) -> std::result::Result + Sync, { type Error = E; fn visit(&mut self, _entry: Entry<'_>) -> std::result::Result { Ok(WalkControl::Continue) } fn visit_pre_decided( &mut self, entry: Entry<'_>, ) -> std::result::Result { if entry.file_type != FileType::File { return Ok(WalkControl::Continue); } let candidate = FileCandidate { path: entry.path.to_path_buf(), relative: entry.relative.to_string(), mtime: entry.mtime, size: entry.size, }; match (self.sink)(&candidate)? { ParallelWalkControl::Continue => Ok(WalkControl::Continue), ParallelWalkControl::Stop => Ok(WalkControl::Quit), } } fn decide_pre_descend( &mut self, meta: &EntryMeta<'_>, ) -> std::result::Result { let is_dir = meta.file_type == FileType::Dir; Ok(match self.filter.stream_decision(meta) { WalkDecision::Include => { PreDescendDecision { emit: true, descend: is_dir, stop: false } }, WalkDecision::Skip => { PreDescendDecision { emit: false, descend: is_dir, stop: false } }, WalkDecision::SkipDescend => { PreDescendDecision { emit: false, descend: false, stop: false } }, WalkDecision::Stop => PreDescendDecision { emit: false, descend: false, stop: true }, }) } } struct ParallelWalkContext { root: PathBuf, options: WalkOptions, filter: WalkFilter, matcher: FastIgnore, } struct ParallelWalkShared<'a, E, S, H> { stop: AtomicBool, error: Mutex>, sink: &'a S, heartbeat: &'a H, } impl<'a, E, S, H> ParallelWalkShared<'a, E, S, H> { const fn new(sink: &'a S, heartbeat: &'a H) -> Self { Self { stop: AtomicBool::new(false), error: Mutex::new(None), sink, heartbeat } } fn request_stop(&self) { self.stop.store(true, AtomicOrdering::Release); } fn should_stop(&self) -> bool { self.stop.load(AtomicOrdering::Acquire) } fn record_error(&self, error: E) { let mut slot = match self.error.lock() { Ok(slot) => slot, Err(poisoned) => poisoned.into_inner(), }; if slot.is_none() { *slot = Some(error); } self.request_stop(); } fn take_error(&self) -> Option { match self.error.lock() { Ok(mut slot) => slot.take(), Err(poisoned) => poisoned.into_inner().take(), } } } thread_local! { static PARALLEL_HEARTBEAT_COUNTER: Cell = const { Cell::new(0) }; static PARALLEL_SCRATCH_POOL: RefCell> = const { RefCell::new(Vec::new()) }; } fn should_use_parallel_file_candidate_walk(options: WalkOptions) -> bool { walk_workers() > 1 && options.follow_links == FollowLinks::Never && !options.same_file_system } fn run_file_candidate_parallel( request: &WalkRequest, sink: &S, heartbeat: &H, ) -> std::result::Result> where E: Send, S: Fn(&FileCandidate) -> std::result::Result + Sync, H: Fn() -> std::result::Result<(), E> + Sync, { let mut options = request.effective_options(); if options.min_depth > options.max_depth { return Ok(WalkStatus::Complete); } heartbeat().map_err(WalkError::Interrupted)?; if !should_use_parallel_file_candidate_walk(options) { return run_file_candidate_serial(request, options, sink, heartbeat); } options.cache = false; let Some(root_entry) = root_entry(&request.root, options.detail, options.follow_links)? else { return Ok(WalkStatus::Complete); }; let context = ParallelWalkContext { root: request.root.clone(), options, filter: request.filter.clone(), matcher: FastIgnore::new(options.use_gitignore), }; let root_ignore = context.matcher.root_state(&context.root); let shared = ParallelWalkShared::new(sink, heartbeat); if root_entry.file_type == FileType::File && options.min_depth == 0 { emit_parallel_root_file(&context, &shared, &root_entry); } if root_entry.file_type == FileType::Dir && options.max_depth > 0 && !shared.should_stop() { let root_dir = context.root.clone(); cache::with_walk_pool(|| { rayon::scope(|scope| { scope.spawn(|scope| { walk_parallel_dir( scope, &context, &shared, root_dir, String::new(), 0, root_ignore, false, ); }); }); }); } if let Some(error) = shared.take_error() { Err(WalkError::Interrupted(error)) } else if shared.should_stop() { Ok(WalkStatus::Stopped) } else { Ok(WalkStatus::Complete) } } fn run_file_candidate_serial( request: &WalkRequest, mut options: WalkOptions, sink: &S, heartbeat: &H, ) -> std::result::Result> where S: Fn(&FileCandidate) -> std::result::Result + Sync, H: Fn() -> std::result::Result<(), E> + Sync, { options.cache = false; options.order = WalkOrder::Unordered; options.contents_first = false; options.emit_root = true; options.directory_errors = DirectoryErrorMode::Visit; let mut visitor = SerialCandidateVisitor { filter: &request.filter, sink }; walk_entries(&request.root, options, &mut visitor, heartbeat) } fn emit_parallel_root_file( context: &ParallelWalkContext, shared: &ParallelWalkShared<'_, E, S, H>, root_entry: &RootEntry, ) where E: Send, S: Fn(&FileCandidate) -> std::result::Result + Sync, H: Fn() -> std::result::Result<(), E> + Sync, { let meta = EntryMeta { root: &context.root, absolute_path: Cow::Borrowed(context.root.as_path()), relative_path: "", file_type: FileType::File, mtime: root_entry.mtime, size: root_entry.size, depth: 0, }; match context.filter.stream_decision(&meta) { WalkDecision::Include => { let candidate = FileCandidate { path: context.root.clone(), relative: String::new(), mtime: root_entry.mtime, size: root_entry.size, }; let _ = emit_parallel_candidate(shared, &candidate); }, WalkDecision::Stop => shared.request_stop(), WalkDecision::Skip | WalkDecision::SkipDescend => {}, } } fn take_parallel_scratch() -> DirScratch { let mut scratch = PARALLEL_SCRATCH_POOL .with(|pool| pool.borrow_mut().pop()) .unwrap_or_default(); scratch.clear_listing(); scratch } fn recycle_parallel_scratch(mut scratch: DirScratch) { scratch.clear_listing(); PARALLEL_SCRATCH_POOL.with(|pool| pool.borrow_mut().push(scratch)); } fn parallel_heartbeat(shared: &ParallelWalkShared<'_, E, S, H>) -> bool where E: Send, H: Fn() -> std::result::Result<(), E> + Sync, { if shared.should_stop() { return false; } let should_call = PARALLEL_HEARTBEAT_COUNTER.with(|counter| { let visited = counter.get(); if visited == 0 || visited >= HEARTBEAT_INTERVAL { counter.set(1); true } else { counter.set(visited + 1); false } }); if !should_call { return true; } match (shared.heartbeat)() { Ok(()) => true, Err(error) => { shared.record_error(error); false }, } } fn emit_parallel_candidate( shared: &ParallelWalkShared<'_, E, S, H>, candidate: &FileCandidate, ) -> bool where E: Send, S: Fn(&FileCandidate) -> std::result::Result + Sync, { match (shared.sink)(candidate) { Ok(ParallelWalkControl::Continue) => true, Ok(ParallelWalkControl::Stop) => { shared.request_stop(); false }, Err(error) => { shared.record_error(error); false }, } } fn walk_parallel_dir<'scope, E, S, H>( scope: &rayon::Scope<'scope>, context: &'scope ParallelWalkContext, shared: &'scope ParallelWalkShared<'_, E, S, H>, dir: PathBuf, relative_dir: String, depth: usize, ignore_state: Arc, derive_ignore_from_entries: bool, ) where E: Send + 'scope, S: Fn(&FileCandidate) -> std::result::Result + Sync + 'scope, H: Fn() -> std::result::Result<(), E> + Sync + 'scope, { if shared.should_stop() { return; } let mut scratch = take_parallel_scratch(); let ignore_entries = match collect_directory_entries( &dir, context.options.detail, &mut scratch, &context.matcher, derive_ignore_from_entries, ) { Ok(ignore_entries) => ignore_entries, Err(ReadDirError::Io(_) | ReadDirError::Walk(WalkError::InvalidData { .. })) => { recycle_parallel_scratch(scratch); return; }, Err(ReadDirError::Walk(WalkError::Interrupted(error))) => { shared.record_error(error); recycle_parallel_scratch(scratch); return; }, }; let dir_ignore = context.matcher.state_from_entries( &ignore_state, &dir, ignore_entries, derive_ignore_from_entries, ); let mut absolute = dir; let mut relative = relative_dir; for index in 0..scratch.entries.len() { if !parallel_heartbeat(shared) { break; } let entry = &scratch.entries[index]; let name = scratch.name(entry); if is_dot_entry(name) { continue; } if !context.options.include_hidden && is_hidden_name(name) { continue; } if (context.options.skip_git && is_git_name(name)) || (context.options.skip_node_modules && is_node_modules_name(name)) { continue; } let name_str = entry_name(name); if name_str.is_empty() { continue; } let next_depth = depth + 1; if next_depth > context.options.max_depth { continue; } let relative_len = relative.len(); absolute.push(name); push_relative_name(&mut relative, &name_str); let is_dir = entry.file_type == FileType::Dir; if !context.matcher.is_ignored(&dir_ignore, &absolute, is_dir) { let decision = { let meta = EntryMeta { root: &context.root, absolute_path: Cow::Borrowed(absolute.as_path()), relative_path: &relative, file_type: entry.file_type, mtime: entry.mtime, size: entry.size, depth: next_depth, }; context.filter.stream_decision(&meta) }; match decision { WalkDecision::Include => { if entry.file_type == FileType::File && next_depth >= context.options.min_depth { let candidate = FileCandidate { path: absolute.clone(), relative: relative.clone(), mtime: entry.mtime, size: entry.size, }; if !emit_parallel_candidate(shared, &candidate) { absolute.pop(); relative.truncate(relative_len); break; } } if is_dir && next_depth < context.options.max_depth && !shared.should_stop() { let child_dir = absolute.clone(); let child_relative = relative.clone(); let child_ignore = Arc::clone(&dir_ignore); scope.spawn(move |scope| { walk_parallel_dir( scope, context, shared, child_dir, child_relative, next_depth, child_ignore, true, ); }); } }, WalkDecision::Skip => { if is_dir && next_depth < context.options.max_depth && !shared.should_stop() { let child_dir = absolute.clone(); let child_relative = relative.clone(); let child_ignore = Arc::clone(&dir_ignore); scope.spawn(move |scope| { walk_parallel_dir( scope, context, shared, child_dir, child_relative, next_depth, child_ignore, true, ); }); } }, WalkDecision::SkipDescend => {}, WalkDecision::Stop => { shared.request_stop(); absolute.pop(); relative.truncate(relative_len); break; }, } } absolute.pop(); relative.truncate(relative_len); } recycle_parallel_scratch(scratch); } /// Visitor decision for streaming traversal. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum WalkControl { /// Continue traversing remaining entries. Continue, /// Skip descending into this directory entry. SkipDescend, /// Stop traversal immediately. Quit, } /// Status returned by native traversal. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum WalkStatus { /// Traversal visited every reachable entry. Complete, /// The visitor stopped traversal early. Stopped, } /// Control returned by unordered parallel file-candidate sinks. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ParallelWalkControl { /// Continue walking and delivering candidates. Continue, /// Stop all workers as promptly as possible. Stop, } /// Owned entry returned by [`collect_entries`]. #[derive(Clone, Debug, PartialEq)] pub struct CollectedEntry { /// Relative path from the root, using `/` separators. pub path: String, /// Filesystem entry kind. pub file_type: FileType, /// Modification time in milliseconds since the Unix epoch, when requested. pub mtime: Option, /// File size in bytes for regular files, when requested. pub size: Option, } impl CollectedEntry { /// Return this entry's absolute path under `root`. pub fn absolute_path(&self, root: &Path) -> PathBuf { if self.path.is_empty() { root.to_path_buf() } else { root.join(&self.path) } } /// Return this entry's depth below the traversal root. pub fn depth(&self) -> usize { if self.path.is_empty() { 0 } else { self .path .split('/') .filter(|component| !component.is_empty()) .count() } } /// Return whether this entry is a regular file. pub const fn is_file(&self) -> bool { matches!(self.file_type, FileType::File) } /// Return whether this entry is a directory. pub const fn is_dir(&self) -> bool { matches!(self.file_type, FileType::Dir) } } /// Return whether `parent` is a walk-relative ancestor of `child`. /// /// Both paths use the walker's normalized `/` separator. The root-relative /// empty path is an ancestor of every non-root entry. pub fn is_relative_ancestor(parent: &str, child: &str) -> bool { if parent == child { return false; } if parent.is_empty() { return !child.is_empty(); } child .strip_prefix(parent) .is_some_and(|suffix| suffix.starts_with('/')) } /// Compare normalized walk-relative paths in depth-first, /// contents-before-parent order. pub fn compare_depth_first_paths(left: &str, right: &str) -> Ordering { if left == right { return Ordering::Equal; } if is_relative_ancestor(left, right) { return Ordering::Greater; } if is_relative_ancestor(right, left) { return Ordering::Less; } left.cmp(right) } /// Sort collected entries in depth-first, contents-before-parent path order. pub fn sort_collected_depth_first(entries: &mut [CollectedEntry]) { entries.sort_unstable_by(|left, right| compare_depth_first_paths(&left.path, &right.path)); } /// Return whether `relative` is below any pruned normalized directory path. pub fn is_under_pruned_relative_dir(relative: &str, pruned_dirs: &[String]) -> bool { pruned_dirs .iter() .any(|dir| is_relative_ancestor(dir, relative)) } /// Return the root device id used by same-filesystem traversal filters. /// /// Non-Unix platforms return `None`, making same-filesystem filtering a no-op. #[cfg(unix)] pub fn root_device_id(path: &Path, follow_links: FollowLinks) -> Option { use std::os::unix::fs::MetadataExt; metadata_for_follow_policy(path, follow_links.follow_at_depth(0)) .ok() .map(|metadata| metadata.dev()) } /// Return the root device id used by same-filesystem traversal filters. /// /// Non-Unix platforms return `None`, making same-filesystem filtering a no-op. #[cfg(not(unix))] pub fn root_device_id(_path: &Path, _follow_links: FollowLinks) -> Option { None } /// Return whether `path` is on the root filesystem represented by /// `root_device`. /// /// When `root_device` is `None`, this returns true. On non-Unix platforms this /// is always true, matching the existing no-op same-filesystem behavior there. #[cfg(unix)] pub fn is_path_on_root_file_system( path: &Path, depth: usize, follow_links: FollowLinks, root_device: Option, ) -> bool { use std::os::unix::fs::MetadataExt; let Some(root_device) = root_device else { return true; }; metadata_for_follow_policy(path, follow_links.follow_at_depth(depth)) .is_ok_and(|metadata| metadata.dev() == root_device) } /// Return whether `path` is on the root filesystem represented by /// `root_device`. /// /// When `root_device` is `None`, this returns true. On non-Unix platforms this /// is always true, matching the existing no-op same-filesystem behavior there. #[cfg(not(unix))] pub fn is_path_on_root_file_system( _path: &Path, _depth: usize, _follow_links: FollowLinks, _root_device: Option, ) -> bool { true } #[cfg(unix)] fn is_effective_path_on_root_file_system( path: &Path, depth: usize, follow_links: FollowLinks, root_device: Option, followed_metadata: Option<&std::fs::Metadata>, ) -> bool { use std::os::unix::fs::MetadataExt; let Some(root_device) = root_device else { return true; }; if let Some(metadata) = followed_metadata { return metadata.dev() == root_device; } metadata_for_follow_policy(path, follow_links.follow_at_depth(depth)) .is_ok_and(|metadata| metadata.dev() == root_device) } #[cfg(not(unix))] fn is_effective_path_on_root_file_system( _path: &Path, _depth: usize, _follow_links: FollowLinks, _root_device: Option, _followed_metadata: Option<&std::fs::Metadata>, ) -> bool { true } #[cfg(unix)] fn metadata_for_follow_policy(path: &Path, follow: bool) -> io::Result { if follow { std::fs::metadata(path) } else { std::fs::symlink_metadata(path) } } /// Owned entries returned by [`collect_entries`] plus cache metadata. #[derive(Clone, Debug, PartialEq)] pub struct CollectedEntries { /// Entries collected with the requested traversal contract. pub entries: Vec, /// Age of the cache entry in milliseconds; zero means freshly scanned. pub cache_age_ms: u64, } /// Borrowed entry passed to [`EntryVisitor`]. pub struct Entry<'a> { /// Absolute filesystem path for this entry. pub path: &'a Path, /// Relative path from the root, using `/` separators. pub relative: &'a str, /// Basename as returned by the platform directory API. pub name: &'a OsStr, /// Filesystem entry kind. pub file_type: FileType, /// Modification time in milliseconds since the Unix epoch, when requested. pub mtime: Option, /// File size in bytes for regular files, when requested. pub size: Option, /// Depth below the traversal root. Direct children have depth 1. pub depth: usize, } /// Directory-open error delivered to visitors when configured by /// [`WalkOptions::directory_errors`]. pub struct DirectoryError<'a> { /// Directory path that could not be read. pub path: &'a Path, /// Underlying platform I/O error. pub error: &'a io::Error, } /// Pre-descend decision made before a directory's children are traversed. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct PreDescendDecision { /// Whether this entry should be emitted in the requested visit order. pub emit: bool, /// Whether this directory's descendants should be traversed. pub descend: bool, /// Whether traversal should stop immediately. pub stop: bool, } #[derive(Clone, Debug, PartialEq, Eq)] enum DirectoryIdentity { #[cfg(unix)] Unix { dev: u64, ino: u64 }, #[cfg(not(unix))] Generic(std::path::PathBuf), } #[derive(Default)] struct SymlinkAncestorStack { stack: Vec, } impl SymlinkAncestorStack { fn contains(&self, identity: &DirectoryIdentity) -> bool { self.stack.contains(identity) } fn push(&mut self, identity: DirectoryIdentity) { self.stack.push(identity); } fn pop(&mut self) { self.stack.pop(); } } fn directory_identity(path: &Path) -> io::Result { #[cfg(unix)] { use std::os::unix::fs::MetadataExt; let metadata = std::fs::metadata(path)?; Ok(DirectoryIdentity::Unix { dev: metadata.dev(), ino: metadata.ino() }) } #[cfg(not(unix))] { let canonical = std::fs::canonicalize(path)?; Ok(DirectoryIdentity::Generic(canonical)) } } /// Consumer hook invoked for every accepted entry. pub trait EntryVisitor { /// Error type used by visitor and heartbeat callbacks. type Error; /// Visit one filesystem entry and choose how traversal continues. fn visit(&mut self, entry: Entry<'_>) -> std::result::Result; /// Handle a directory-open error and choose whether traversal continues. fn visit_directory_error( &mut self, _error: DirectoryError<'_>, ) -> std::result::Result { Ok(WalkControl::Continue) } /// Optional hook for making pre-descend traversal decisions. fn decide_pre_descend( &mut self, _meta: &EntryMeta<'_>, ) -> std::result::Result { Ok(PreDescendDecision { emit: true, descend: true, stop: false }) } /// Visit an entry whose filter/predicate decisions have already been made. fn visit_pre_decided( &mut self, entry: Entry<'_>, ) -> std::result::Result { self.visit(entry) } } struct RequestVisitor<'a, V, P> { root: &'a Path, filter: &'a WalkFilter, limit: Option, emitted: usize, visitor: &'a mut V, predicate: P, } impl EntryVisitor for RequestVisitor<'_, V, P> where V: EntryVisitor, P: WalkPredicate, { type Error = V::Error; fn visit(&mut self, entry: Entry<'_>) -> std::result::Result { if self.limit.is_some_and(|limit| self.emitted >= limit) { return Ok(WalkControl::Quit); } let meta = EntryMeta { root: self.root, absolute_path: Cow::Borrowed(entry.path), relative_path: entry.relative, file_type: entry.file_type, mtime: entry.mtime, size: entry.size, depth: entry.depth, }; match self.filter.stream_decision(&meta) { WalkDecision::Include => {}, WalkDecision::Skip => return Ok(WalkControl::Continue), WalkDecision::SkipDescend => return Ok(WalkControl::SkipDescend), WalkDecision::Stop => return Ok(WalkControl::Quit), } match self.predicate.decide(&meta) { WalkDecision::Include => {}, WalkDecision::Skip => return Ok(WalkControl::Continue), WalkDecision::SkipDescend => return Ok(WalkControl::SkipDescend), WalkDecision::Stop => return Ok(WalkControl::Quit), } self.emitted += 1; self.visitor.visit(entry) } fn visit_directory_error( &mut self, error: DirectoryError<'_>, ) -> std::result::Result { self.visitor.visit_directory_error(error) } fn visit_pre_decided( &mut self, entry: Entry<'_>, ) -> std::result::Result { if self.limit.is_some_and(|limit| self.emitted >= limit) { return Ok(WalkControl::Quit); } self.emitted += 1; self.visitor.visit(entry) } fn decide_pre_descend( &mut self, meta: &EntryMeta<'_>, ) -> std::result::Result { let is_dir = meta.file_type == FileType::Dir; match self.filter.stream_decision(meta) { WalkDecision::Include => {}, WalkDecision::Skip => { return Ok(PreDescendDecision { emit: false, descend: is_dir, stop: false }); }, WalkDecision::SkipDescend => { return Ok(PreDescendDecision { emit: false, descend: false, stop: false }); }, WalkDecision::Stop => { return Ok(PreDescendDecision { emit: false, descend: false, stop: true }); }, } match self.predicate.decide(meta) { WalkDecision::Include => { Ok(PreDescendDecision { emit: true, descend: is_dir, stop: false }) }, WalkDecision::Skip => { Ok(PreDescendDecision { emit: false, descend: is_dir, stop: false }) }, WalkDecision::SkipDescend => { Ok(PreDescendDecision { emit: false, descend: false, stop: false }) }, WalkDecision::Stop => { Ok(PreDescendDecision { emit: false, descend: false, stop: true }) }, } } } struct ClosureEntryVisitor<'a, V, D> { root: &'a Path, visit: V, directory_error: D, } impl EntryVisitor for ClosureEntryVisitor<'_, V, D> where V: for<'entry> FnMut(EntryMeta<'entry>) -> std::result::Result, D: for<'error> FnMut(DirectoryError<'error>) -> std::result::Result, { type Error = E; fn visit(&mut self, entry: Entry<'_>) -> std::result::Result { let meta = EntryMeta { root: self.root, absolute_path: Cow::Borrowed(entry.path), relative_path: entry.relative, file_type: entry.file_type, mtime: entry.mtime, size: entry.size, depth: entry.depth, }; (self.visit)(meta).map(walk_decision_to_control) } fn visit_directory_error( &mut self, error: DirectoryError<'_>, ) -> std::result::Result { (self.directory_error)(error).map(walk_decision_to_control) } } const fn walk_decision_to_control(decision: WalkDecision) -> WalkControl { match decision { WalkDecision::Include | WalkDecision::Skip => WalkControl::Continue, WalkDecision::SkipDescend => WalkControl::SkipDescend, WalkDecision::Stop => WalkControl::Quit, } } /// Error returned by native traversal. #[derive(Debug)] pub enum WalkError { /// A caller-supplied heartbeat or visitor returned an error. Interrupted(E), /// A platform directory API returned malformed data or an unskippable error. InvalidData { /// Directory whose scan failed. path: PathBuf, /// Human-readable failure detail. message: String, }, } impl fmt::Display for WalkError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Interrupted(err) => write!(f, "native directory scan interrupted: {err}"), Self::InvalidData { path, message } => { write!(f, "native directory scan failed for {}: {message}", path.display()) }, } } } impl std::error::Error for WalkError where E: std::error::Error + 'static, { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::Interrupted(err) => Some(err), Self::InvalidData { .. } => None, } } } struct CollectVisitor { entries: Vec, _error: std::marker::PhantomData E>, } impl CollectVisitor { const fn new() -> Self { Self { entries: Vec::new(), _error: std::marker::PhantomData } } } impl EntryVisitor for CollectVisitor { type Error = E; fn visit(&mut self, entry: Entry<'_>) -> std::result::Result { self.entries.push(CollectedEntry { path: entry.relative.to_string(), file_type: entry.file_type, mtime: entry.mtime, size: entry.size, }); Ok(WalkControl::Continue) } } fn root_device_for_options(root: &Path, options: WalkOptions) -> Option { if options.same_file_system { root_device_id(root, options.follow_links) } else { None } } struct RawDirEntry<'a> { name: Cow<'a, OsStr>, file_type: FileType, mtime: Option, size: Option, } #[cfg(unix)] struct DirEntryRecord { name_start: usize, name_len: usize, file_type: FileType, mtime: Option, size: Option, } #[cfg(not(unix))] struct DirEntryRecord { name: OsString, file_type: FileType, mtime: Option, size: Option, } #[derive(Default)] struct DirScratch { entries: Vec, #[cfg(unix)] name_bytes: Vec, read_buffer: Vec, } impl DirScratch { fn clear_listing(&mut self) { self.entries.clear(); #[cfg(unix)] self.name_bytes.clear(); } #[cfg(unix)] fn push(&mut self, entry: RawDirEntry<'_>) { let name_os: &OsStr = entry.name.as_ref(); let name = name_os.as_bytes(); let name_start = self.name_bytes.len(); self.name_bytes.extend_from_slice(name); self.entries.push(DirEntryRecord { name_start, name_len: name.len(), file_type: entry.file_type, mtime: entry.mtime, size: entry.size, }); } #[cfg(not(unix))] fn push(&mut self, entry: RawDirEntry<'_>) { self.entries.push(DirEntryRecord { name: entry.name.into_owned(), file_type: entry.file_type, mtime: entry.mtime, size: entry.size, }); } #[cfg(unix)] fn sort_by_name(&mut self) { let names = &self.name_bytes; self.entries.sort_unstable_by(|left, right| { let left_name = &names[left.name_start..left.name_start + left.name_len]; let right_name = &names[right.name_start..right.name_start + right.name_len]; left_name.cmp(right_name) }); } #[cfg(not(unix))] fn sort_by_name(&mut self) { self .entries .sort_unstable_by(|left, right| left.name.cmp(&right.name)); } #[cfg(unix)] fn name<'a>(&'a self, entry: &DirEntryRecord) -> &'a OsStr { OsStr::from_bytes(&self.name_bytes[entry.name_start..entry.name_start + entry.name_len]) } #[cfg(not(unix))] fn name<'a>(&'a self, entry: &'a DirEntryRecord) -> &'a OsStr { &entry.name } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum ReadDirControl { Continue, Stop, } enum ReadDirError { Io(io::Error), Walk(WalkError), } impl From for ReadDirError { fn from(err: io::Error) -> Self { Self::Io(err) } } fn file_type_from_metadata(metadata: &std::fs::Metadata) -> Option { let file_type = metadata.file_type(); if file_type.is_symlink() { Some(FileType::Symlink) } else if file_type.is_dir() { Some(FileType::Dir) } else if file_type.is_file() { Some(FileType::File) } else { None } } struct RootEntry { file_type: FileType, mtime: Option, size: Option, } fn root_entry( root: &Path, detail: WalkDetail, follow_links: FollowLinks, ) -> std::result::Result, WalkError> { let metadata = if follow_links.follow_at_depth(0) { match std::fs::metadata(root) { Ok(metadata) => metadata, Err(err) if is_missing_metadata_error(&err) => { std::fs::symlink_metadata(root).map_err(|err| WalkError::InvalidData { path: root.to_path_buf(), message: err.to_string(), })? }, Err(err) => { return Err(WalkError::InvalidData { path: root.to_path_buf(), message: err.to_string(), }); }, } } else { std::fs::symlink_metadata(root).map_err(|err| WalkError::InvalidData { path: root.to_path_buf(), message: err.to_string(), })? }; Ok(entry_from_metadata(&metadata, detail)) } fn entry_from_metadata(metadata: &std::fs::Metadata, detail: WalkDetail) -> Option { let file_type = file_type_from_metadata(metadata)?; let size = if detail == WalkDetail::Full && file_type == FileType::File { Some(metadata.len() as f64) } else { None }; let mtime = if detail == WalkDetail::Full { metadata .modified() .ok() .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) .map(|duration| duration.as_millis() as f64) } else { None }; Some(RootEntry { file_type, mtime, size }) } fn is_missing_metadata_error(err: &io::Error) -> bool { matches!(err.kind(), io::ErrorKind::NotFound | io::ErrorKind::NotADirectory) } /// Scans entries using the shared cache when [`WalkOptions::cache`] is true. /// /// The native scanner implements every [`WalkOptions`] traversal contract. pub fn collect_entries( root: &Path, options: WalkOptions, heartbeat: H, ) -> std::result::Result> where H: Fn() -> std::result::Result<(), E> + Sync, E: fmt::Display, { cache::collect_entries(root, options, heartbeat) } fn collect_entries_native( root: &Path, options: WalkOptions, heartbeat: H, ) -> std::result::Result> where H: FnMut() -> std::result::Result<(), E>, { let mut collector = CollectVisitor::new(); let _status = walk_entries(root, options, &mut collector, heartbeat)?; if options.contents_first { sort_collected_depth_first(&mut collector.entries); } else { collector .entries .sort_unstable_by(|a, b| a.path.cmp(&b.path)); } Ok(CollectedEntries { entries: collector.entries, cache_age_ms: 0 }) } /// Scans entries without cancellation using platform syscalls when supported. pub fn collect_entries_without_heartbeat( root: &Path, options: WalkOptions, ) -> std::result::Result> { collect_entries(root, options, || Ok::<(), Infallible>(())) } /// Streams entries using the native scanner for every [`WalkOptions`] /// traversal contract. pub fn walk_entries( root: &Path, options: WalkOptions, visitor: &mut V, heartbeat: H, ) -> std::result::Result> where V: EntryVisitor, H: FnMut() -> std::result::Result<(), V::Error>, { if options.min_depth > options.max_depth { return Ok(WalkStatus::Complete); } let root_device = root_device_for_options(root, options); let matcher = FastIgnore::new(options.use_gitignore); let root_ignore = matcher.root_state(root); let mut context = WalkContext { root_path: root, options, root_device, symlink_ancestors: SymlinkAncestorStack::default(), matcher, absolute_path: root.to_path_buf(), relative_path: String::new(), scratch_pool: Vec::new(), visited: 0, heartbeat, }; context.walk_root(root, &root_ignore, visitor) } struct WalkContext<'a, H> { root_path: &'a Path, options: WalkOptions, root_device: Option, symlink_ancestors: SymlinkAncestorStack, matcher: FastIgnore, absolute_path: PathBuf, relative_path: String, scratch_pool: Vec, visited: usize, heartbeat: H, } impl WalkContext<'_, H> { fn walk_root( &mut self, root: &Path, root_ignore: &Arc, visitor: &mut V, ) -> std::result::Result> where V: EntryVisitor, H: FnMut() -> std::result::Result<(), V::Error>, { let root_entry = root_entry(root, self.options.detail, self.options.follow_links)?; let Some(root_entry) = root_entry else { return Ok(WalkStatus::Complete); }; // Seed the ancestor stack only when descendant symlink traversal can loop. if self.options.follow_links == FollowLinks::Always && let Ok(id) = directory_identity(root) { self.symlink_ancestors.push(id); } let meta = EntryMeta { root, absolute_path: Cow::Borrowed(root), relative_path: "", file_type: root_entry.file_type, mtime: root_entry.mtime, size: root_entry.size, depth: 0, }; let decision = visitor .decide_pre_descend(&meta) .map_err(WalkError::Interrupted)?; if decision.stop { return Ok(WalkStatus::Stopped); } if !self.options.contents_first && self.options.emit_root && self.options.min_depth == 0 && decision.emit { let name = root.file_name().unwrap_or(root.as_os_str()); match visitor .visit_pre_decided(Entry { path: root, relative: "", name, file_type: root_entry.file_type, mtime: root_entry.mtime, size: root_entry.size, depth: 0, }) .map_err(WalkError::Interrupted)? { WalkControl::Quit => return Ok(WalkStatus::Stopped), WalkControl::SkipDescend => return Ok(WalkStatus::Complete), WalkControl::Continue => {}, } } let stopped = if root_entry.file_type == FileType::Dir && self.options.max_depth > 0 && decision.descend { self.walk_dir(0, root_ignore, false, visitor)? } else { false }; if stopped { return Ok(WalkStatus::Stopped); } if self.options.contents_first && self.options.emit_root && self.options.min_depth == 0 && decision.emit { let name = root.file_name().unwrap_or(root.as_os_str()); match visitor .visit_pre_decided(Entry { path: root, relative: "", name, file_type: root_entry.file_type, mtime: root_entry.mtime, size: root_entry.size, depth: 0, }) .map_err(WalkError::Interrupted)? { WalkControl::Quit => return Ok(WalkStatus::Stopped), WalkControl::SkipDescend | WalkControl::Continue => {}, } } Ok(WalkStatus::Complete) } fn take_scratch(&mut self) -> DirScratch { let mut scratch = self.scratch_pool.pop().unwrap_or_default(); scratch.clear_listing(); scratch } fn recycle_scratch(&mut self, mut scratch: DirScratch) { scratch.clear_listing(); self.scratch_pool.push(scratch); } fn push_entry_path(&mut self, name: &OsStr, name_str: &str) -> usize { let relative_len = self.relative_path.len(); self.absolute_path.push(name); push_relative_name(&mut self.relative_path, name_str); relative_len } fn pop_entry_path(&mut self, relative_len: usize) { self.relative_path.truncate(relative_len); self.absolute_path.pop(); } fn walk_dir( &mut self, depth: usize, ignore_state: &Arc, derive_ignore_from_entries: bool, visitor: &mut V, ) -> std::result::Result> where V: EntryVisitor, H: FnMut() -> std::result::Result<(), V::Error>, { if self.options.follow_links == FollowLinks::Always && let Ok(identity) = directory_identity(&self.absolute_path) { self.symlink_ancestors.push(identity); let result = self.walk_dir_inner(depth, ignore_state, derive_ignore_from_entries, visitor); self.symlink_ancestors.pop(); return result; } self.walk_dir_inner(depth, ignore_state, derive_ignore_from_entries, visitor) } fn walk_dir_inner( &mut self, depth: usize, ignore_state: &Arc, derive_ignore_from_entries: bool, visitor: &mut V, ) -> std::result::Result> where V: EntryVisitor, H: FnMut() -> std::result::Result<(), V::Error>, { let mut scratch = self.take_scratch(); let ignore_entries = match collect_directory_entries( &self.absolute_path, self.options.detail, &mut scratch, &self.matcher, derive_ignore_from_entries, ) { Ok(ignore_entries) => ignore_entries, Err(err) => { let dir = self.absolute_path.clone(); self.recycle_scratch(scratch); return handle_read_dir_error(&dir, err, self.options, visitor); }, }; if self.options.order == WalkOrder::Path { scratch.sort_by_name(); } let dir_ignore = self.matcher.state_from_entries( ignore_state, &self.absolute_path, ignore_entries, derive_ignore_from_entries, ); for index in 0..scratch.entries.len() { if self.visited == 0 || self.visited >= HEARTBEAT_INTERVAL { self.visited = 0; (self.heartbeat)().map_err(WalkError::Interrupted)?; } self.visited += 1; let entry = &scratch.entries[index]; let name = scratch.name(entry); if is_dot_entry(name) { continue; } if !self.options.include_hidden && is_hidden_name(name) { continue; } if (self.options.skip_git && is_git_name(name)) || (self.options.skip_node_modules && is_node_modules_name(name)) { continue; } let name_str = entry_name(name); if name_str.is_empty() { continue; } let next_depth = depth + 1; if next_depth > self.options.max_depth { continue; } let relative_len = self.push_entry_path(name, &name_str); let entry_result = self.walk_current_entry( name, entry.file_type, entry.mtime, entry.size, next_depth, &dir_ignore, visitor, ); self.pop_entry_path(relative_len); if entry_result? { self.recycle_scratch(scratch); return Ok(true); } } self.recycle_scratch(scratch); Ok(false) } fn walk_current_entry( &mut self, name: &OsStr, entry_file_type: FileType, entry_mtime: Option, entry_size: Option, next_depth: usize, dir_ignore: &Arc, visitor: &mut V, ) -> std::result::Result> where V: EntryVisitor, H: FnMut() -> std::result::Result<(), V::Error>, { let mut file_type = entry_file_type; let mut mtime = entry_mtime; let mut size = entry_size; let mut is_dir = entry_file_type == FileType::Dir; let mut descend = is_dir; let mut followed_symlink_dir = false; let followed_metadata = if entry_file_type == FileType::Symlink && self.options.follow_links == FollowLinks::Always { match std::fs::metadata(&self.absolute_path) { Ok(metadata) => Some(metadata), Err(err) => { if self.options.directory_errors == DirectoryErrorMode::SkipSkippable && is_skippable_directory_error(&err) { return Ok(false); } if self.options.directory_errors == DirectoryErrorMode::Visit { match visitor .visit_directory_error(DirectoryError { path: &self.absolute_path, error: &err, }) .map_err(WalkError::Interrupted)? { WalkControl::Quit => return Ok(true), WalkControl::SkipDescend | WalkControl::Continue => { return Ok(false); }, } } return Err(WalkError::InvalidData { path: self.absolute_path.clone(), message: err.to_string(), }); }, } } else { None }; if let Some(target_metadata) = followed_metadata.as_ref() { let Some(target_file_type) = file_type_from_metadata(target_metadata) else { return Ok(false); }; file_type = target_file_type; if target_file_type == FileType::Dir { is_dir = true; descend = true; followed_symlink_dir = true; } else { is_dir = false; descend = false; } if self.options.detail == WalkDetail::Full { if target_file_type == FileType::File { size = Some(target_metadata.len() as f64); } else { size = None; } mtime = target_metadata .modified() .ok() .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) .map(|duration| duration.as_millis() as f64); } } if self .matcher .is_ignored(dir_ignore, &self.absolute_path, is_dir) { return Ok(false); } if !is_effective_path_on_root_file_system( &self.absolute_path, next_depth, self.options.follow_links, self.root_device, followed_metadata.as_ref(), ) { return Ok(false); } if followed_symlink_dir && descend { match directory_identity(&self.absolute_path) { Ok(target_id) => { if self.symlink_ancestors.contains(&target_id) { let loop_err = io::Error::other("filesystem loop detected"); if self.options.directory_errors == DirectoryErrorMode::Visit { match visitor .visit_directory_error(DirectoryError { path: &self.absolute_path, error: &loop_err, }) .map_err(WalkError::Interrupted)? { WalkControl::Quit => return Ok(true), WalkControl::SkipDescend | WalkControl::Continue => { return Ok(false); }, } } else if self.options.directory_errors == DirectoryErrorMode::SkipSkippable { return Ok(false); } return Err(WalkError::InvalidData { path: self.absolute_path.clone(), message: "filesystem loop detected".to_string(), }); } }, Err(err) => { if self.options.directory_errors == DirectoryErrorMode::SkipSkippable && is_skippable_directory_error(&err) { return Ok(false); } if self.options.directory_errors == DirectoryErrorMode::Visit { match visitor .visit_directory_error(DirectoryError { path: &self.absolute_path, error: &err, }) .map_err(WalkError::Interrupted)? { WalkControl::Quit => return Ok(true), WalkControl::SkipDescend | WalkControl::Continue => { return Ok(false); }, } } return Err(WalkError::InvalidData { path: self.absolute_path.clone(), message: err.to_string(), }); }, } } let decision = { let meta = EntryMeta { root: self.root_path, absolute_path: Cow::Borrowed(self.absolute_path.as_path()), relative_path: &self.relative_path, file_type, mtime, size, depth: next_depth, }; visitor .decide_pre_descend(&meta) .map_err(WalkError::Interrupted)? }; if decision.stop { return Ok(true); } if !self.options.contents_first && decision.emit && next_depth >= self.options.min_depth { match visitor .visit_pre_decided(Entry { path: self.absolute_path.as_path(), relative: &self.relative_path, name, file_type, mtime, size, depth: next_depth, }) .map_err(WalkError::Interrupted)? { WalkControl::Quit => return Ok(true), WalkControl::SkipDescend => return Ok(false), WalkControl::Continue => {}, } } let child_stopped = if descend && next_depth < self.options.max_depth && decision.descend { self.walk_dir(next_depth, dir_ignore, true, visitor)? } else { false }; if child_stopped { return Ok(true); } if self.options.contents_first && decision.emit && next_depth >= self.options.min_depth { match visitor .visit_pre_decided(Entry { path: self.absolute_path.as_path(), relative: &self.relative_path, name, file_type, mtime, size, depth: next_depth, }) .map_err(WalkError::Interrupted)? { WalkControl::Quit => return Ok(true), WalkControl::SkipDescend | WalkControl::Continue => {}, } } Ok(false) } } fn collect_directory_entries( dir: &Path, detail: WalkDetail, scratch: &mut DirScratch, matcher: &FastIgnore, derive_ignore_from_entries: bool, ) -> std::result::Result> { scratch.clear_listing(); let mut ignore_entries = IgnoreEntryNames::default(); let track_ignore_entries = derive_ignore_from_entries && matcher.use_gitignore; let mut read_buffer = std::mem::take(&mut scratch.read_buffer); let result = platform::read_dir_entries(dir, detail, &mut read_buffer, |entry| { if track_ignore_entries { ignore_entries.record(entry.name.as_ref(), entry.file_type); } scratch.push(entry); Ok(ReadDirControl::Continue) }); scratch.read_buffer = read_buffer; result?; Ok(ignore_entries) } /// Return whether [`WalkDetail::Full`] provides file sizes without per-entry /// metadata syscalls on this platform. pub const fn supports_cheap_size_hints() -> bool { platform::CHEAP_SIZE_HINTS } struct IgnoreState { parent: Option>, ignore_matcher: Option, gitignore_matcher: Option, git_exclude_matcher: Option, has_git: bool, chain_has_matchers: bool, any_git: bool, } struct FastIgnore { global: Option, use_gitignore: bool, } #[derive(Clone, Copy, Default)] struct IgnoreEntryNames { ignore_file: bool, gitignore_file: bool, git_dir: bool, repo_marker: bool, } impl IgnoreEntryNames { fn record(&mut self, name: &OsStr, file_type: FileType) { if matches!(file_type, FileType::File | FileType::Symlink) { if name == OsStr::new(".ignore") { self.ignore_file = true; } else if name == OsStr::new(".gitignore") { self.gitignore_file = true; } } if name == OsStr::new(".git") { self.git_dir = true; self.repo_marker = true; } else if name == OsStr::new(".jj") { self.repo_marker = true; } } const fn has_relevant(self) -> bool { self.ignore_file || self.gitignore_file || self.git_dir || self.repo_marker } } fn has_repo_marker(dir: &Path) -> bool { dir.join(".git").exists() || dir.join(".jj").exists() } fn ignore_line_covers_root( matcher_root: &Path, source: &Path, line: &str, explicit_root: &Path, ) -> bool { let mut builder = ignore::gitignore::GitignoreBuilder::new(matcher_root); builder.add_line(Some(source.to_path_buf()), line).is_ok() && builder.build().is_ok_and(|matcher| { matcher .matched_path_or_any_parents(explicit_root, true) .is_ignore() }) } /// Load an ignore source, removing ancestor rules that cover an explicit walk /// root. /// /// Unrelated parent rules remain active, while ignore files discovered at or /// below the root are loaded without filtering. fn load_gitignore( matcher_root: &Path, file: &Path, explicit_root: Option<&Path>, ) -> Option { if !file.is_file() { return None; } let mut builder = ignore::gitignore::GitignoreBuilder::new(matcher_root); let _ = builder.add(file); let matcher = builder.build().ok().filter(|matcher| !matcher.is_empty())?; let Some(explicit_root) = explicit_root else { return Some(matcher); }; if !matcher .matched_path_or_any_parents(explicit_root, true) .is_ignore() { return Some(matcher); } let handle = std::fs::File::open(file).ok()?; let mut filtered = ignore::gitignore::GitignoreBuilder::new(matcher_root); let source = Some(file.to_path_buf()); for (index, line) in io::BufReader::new(handle).lines().enumerate() { let Ok(line) = line else { break; }; let line = if index == 0 { line.trim_start_matches('\u{feff}') } else { line.as_str() }; if ignore_line_covers_root(matcher_root, file, line, explicit_root) { continue; } let _ = filtered.add_line(source.clone(), line); } filtered.build().ok().filter(|matcher| !matcher.is_empty()) } impl IgnoreState { fn build(dir: &Path, parent: Option>) -> Arc { let has_git = has_repo_marker(dir); let git_exclude = dir.join(".git/info/exclude"); Self::new( parent, load_gitignore(dir, &dir.join(".ignore"), None), load_gitignore(dir, &dir.join(".gitignore"), None), if has_git { load_gitignore(dir, &git_exclude, None) } else { None }, has_git, ) } fn build_parent(dir: &Path, parent: Option>, explicit_root: &Path) -> Arc { let has_git = has_repo_marker(dir); let git_exclude = dir.join(".git/info/exclude"); Self::new( parent, load_gitignore(dir, &dir.join(".ignore"), Some(explicit_root)), load_gitignore(dir, &dir.join(".gitignore"), Some(explicit_root)), if has_git { load_gitignore(dir, &git_exclude, Some(explicit_root)) } else { None }, has_git, ) } fn build_from_entry_names(dir: &Path, parent: &Arc, names: IgnoreEntryNames) -> Arc { if !names.has_relevant() { return Arc::clone(parent); } let git_exclude = dir.join(".git/info/exclude"); Self::new( Some(Arc::clone(parent)), if names.ignore_file { load_gitignore(dir, &dir.join(".ignore"), None) } else { None }, if names.gitignore_file { load_gitignore(dir, &dir.join(".gitignore"), None) } else { None }, if names.git_dir { load_gitignore(dir, &git_exclude, None) } else { None }, names.repo_marker, ) } fn new( parent: Option>, ignore_matcher: Option, gitignore_matcher: Option, git_exclude_matcher: Option, has_git: bool, ) -> Arc { let parent_has_matchers = parent .as_ref() .is_some_and(|parent| parent.chain_has_matchers); let parent_has_git = parent.as_ref().is_some_and(|parent| parent.any_git); let has_matchers = ignore_matcher.is_some() || gitignore_matcher.is_some() || git_exclude_matcher.is_some(); Arc::new(Self { parent, ignore_matcher, gitignore_matcher, git_exclude_matcher, has_git, chain_has_matchers: has_matchers || parent_has_matchers, any_git: has_git || parent_has_git, }) } fn build_parents(root: &Path, use_gitignore: bool) -> Option> { if !use_gitignore { return None; } let mut ancestors = Vec::new(); let mut current = root.parent(); let mut repo_start = None; while let Some(path) = current { ancestors.push(path); if repo_start.is_none() && has_repo_marker(path) { repo_start = Some(ancestors.len() - 1); } current = path.parent(); } let repo_start = repo_start?; let mut parent = None; for ancestor in ancestors[..=repo_start].iter().rev() { parent = Some(Self::build_parent(ancestor, parent, root)); } parent } } impl FastIgnore { fn new(use_gitignore: bool) -> Self { let global = if use_gitignore { let (matcher, _err) = ignore::gitignore::Gitignore::global(); if matcher.is_empty() { None } else { Some(matcher) } } else { None }; Self { global, use_gitignore } } fn root_state(&self, root: &Path) -> Arc { IgnoreState::build(root, IgnoreState::build_parents(root, self.use_gitignore)) } fn state_from_entries( &self, parent: &Arc, dir: &Path, names: IgnoreEntryNames, derive_ignore_from_entries: bool, ) -> Arc { if self.use_gitignore && derive_ignore_from_entries { IgnoreState::build_from_entry_names(dir, parent, names) } else { Arc::clone(parent) } } fn is_ignored(&self, state: &Arc, path: &Path, is_dir: bool) -> bool { if !self.use_gitignore { return false; } let any_git = state.any_git; let global_matcher_applies = any_git && self.global.is_some(); if !state.chain_has_matchers && !global_matcher_applies { return false; } let mut saw_git = false; let mut ignore_match = ignore::Match::None; let mut gitignore_match = ignore::Match::None; let mut git_exclude_match = ignore::Match::None; if state.chain_has_matchers { let mut current = Some(state.as_ref()); while let Some(frame) = current { if ignore_match.is_none() && let Some(matcher) = &frame.ignore_matcher { ignore_match = matcher.matched(path, is_dir); } if gitignore_match.is_none() && let Some(matcher) = &frame.gitignore_matcher { gitignore_match = matcher.matched(path, is_dir); } if any_git && !saw_git && git_exclude_match.is_none() && let Some(matcher) = &frame.git_exclude_matcher { git_exclude_match = matcher.matched(path, is_dir); } saw_git = saw_git || frame.has_git; current = frame.parent.as_deref(); } } match ignore_match { ignore::Match::Ignore(_) => return true, ignore::Match::Whitelist(_) => return false, ignore::Match::None => {}, } match gitignore_match { ignore::Match::Ignore(_) => return true, ignore::Match::Whitelist(_) => return false, ignore::Match::None => {}, } match git_exclude_match { ignore::Match::Ignore(_) => return true, ignore::Match::Whitelist(_) => return false, ignore::Match::None => {}, } if any_git && let Some(global) = &self.global { match global.matched(path, is_dir) { ignore::Match::Ignore(_) => return true, ignore::Match::Whitelist(_) => return false, ignore::Match::None => {}, } } false } } fn handle_read_dir_error( dir: &Path, err: ReadDirError, options: WalkOptions, visitor: &mut V, ) -> std::result::Result> where V: EntryVisitor, { match err { ReadDirError::Walk(err) => Err(err), ReadDirError::Io(err) if options.directory_errors == DirectoryErrorMode::SkipSkippable && is_skippable_directory_error(&err) => { Ok(false) }, ReadDirError::Io(err) if options.directory_errors == DirectoryErrorMode::Visit => { match visitor .visit_directory_error(DirectoryError { path: dir, error: &err }) .map_err(WalkError::Interrupted)? { WalkControl::Quit => Ok(true), WalkControl::SkipDescend | WalkControl::Continue => Ok(false), } }, ReadDirError::Io(err) => { Err(WalkError::InvalidData { path: dir.to_path_buf(), message: err.to_string() }) }, } } fn is_skippable_directory_error(err: &io::Error) -> bool { matches!( err.kind(), io::ErrorKind::NotFound | io::ErrorKind::NotADirectory | io::ErrorKind::PermissionDenied ) } fn is_dot_entry(name: &OsStr) -> bool { name == OsStr::new(".") || name == OsStr::new("..") } fn is_git_name(name: &OsStr) -> bool { name == OsStr::new(".git") } fn is_node_modules_name(name: &OsStr) -> bool { name == OsStr::new("node_modules") } fn entry_name(name: &OsStr) -> Cow<'_, str> { name .to_str() .map_or_else(|| name.to_string_lossy(), Cow::Borrowed) } #[cfg(unix)] fn is_hidden_name(name: &OsStr) -> bool { use std::os::unix::ffi::OsStrExt; name.as_bytes().first() == Some(&b'.') } #[cfg(windows)] fn is_hidden_name(name: &OsStr) -> bool { use std::os::windows::ffi::OsStrExt; name.encode_wide().next() == Some(b'.' as u16) } #[cfg(not(any(unix, windows)))] fn is_hidden_name(name: &OsStr) -> bool { name .to_str() .is_some_and(|value| value.as_bytes().first() == Some(&b'.')) } fn push_relative_name(relative: &mut String, name: &str) { if !relative.is_empty() { relative.push('/'); } relative.push_str(name); } fn mtime_millis(seconds: i64, nanos: i64) -> Option { if seconds < 0 { return None; } Some((seconds as f64).mul_add(1000.0, nanos.max(0) as f64 / 1_000_000.0)) } #[cfg(target_os = "macos")] mod platform { use std::{ borrow::Cow, ffi::{CString, OsStr}, io, mem::size_of, os::{fd::RawFd, unix::ffi::OsStrExt}, path::Path, }; use super::{ FileType, RawDirEntry, ReadDirControl, ReadDirError, WalkDetail, WalkError, mtime_millis, }; /// `getattrlistbulk` can return data length in the same batch, but /// requesting full-detail attributes (size + mtime) measurably slows the /// bulk scan (~+50% walk time on APFS), which outweighs saving one fstat /// per opened file. Benchmarked via `perf_walk_collect_full_detail` vs /// minimal detail. pub const CHEAP_SIZE_HINTS: bool = false; const BUFFER_SIZE: usize = 256 * 1024; const VREG: u32 = 1; const VDIR: u32 = 2; const VLNK: u32 = 5; struct FdGuard(RawFd); impl Drop for FdGuard { fn drop(&mut self) { // SAFETY: `FdGuard` owns this file descriptor and closes it exactly once. unsafe { libc::close(self.0) }; } } pub fn read_dir_entries( path: &Path, detail: WalkDetail, buffer: &mut Vec, mut emit: F, ) -> std::result::Result> where F: FnMut(RawDirEntry<'_>) -> std::result::Result>, { let fd = open_dir(path)?; let mut attrs = libc::attrlist { bitmapcount: libc::ATTR_BIT_MAP_COUNT, reserved: 0, commonattr: libc::ATTR_CMN_NAME | libc::ATTR_CMN_OBJTYPE, volattr: 0, dirattr: 0, fileattr: 0, forkattr: 0, }; if detail == WalkDetail::Full { attrs.commonattr |= libc::ATTR_CMN_MODTIME; attrs.fileattr |= libc::ATTR_FILE_DATALENGTH; } if buffer.len() != BUFFER_SIZE { buffer.resize(BUFFER_SIZE, 0); } loop { // SAFETY: `fd` is an open directory descriptor, `attrs` points to a valid // attrlist for the duration of the call, and `buffer` is writable. let count = unsafe { libc::getattrlistbulk( fd.0, std::ptr::addr_of_mut!(attrs).cast(), buffer.as_mut_ptr().cast(), buffer.len(), libc::FSOPT_NOFOLLOW as u64, ) }; if count == 0 { break; } if count < 0 { let err = io::Error::last_os_error(); if err.kind() == io::ErrorKind::Interrupted { continue; } if is_unsupported_dir_scan(&err) { return read_dir_entries_std(path, detail, emit); } return Err(ReadDirError::Io(err)); } let mut offset = 0usize; for _ in 0..count { if offset + size_of::() > buffer.len() { return Err(invalid_data("truncated getattrlistbulk record length").into()); } let record_len = u32::from_ne_bytes( buffer[offset..offset + size_of::()] .try_into() .expect("slice length checked"), ) as usize; if record_len < size_of::() || offset + record_len > buffer.len() { return Err(invalid_data("invalid getattrlistbulk record length").into()); } let record = &buffer[offset..offset + record_len]; if let Some(entry) = parse_record(record, detail)? && emit(entry).map_err(ReadDirError::Walk)? == ReadDirControl::Stop { return Ok(ReadDirControl::Stop); } offset += record_len; } } Ok(ReadDirControl::Continue) } fn read_dir_entries_std( path: &Path, detail: WalkDetail, mut emit: F, ) -> std::result::Result> where F: FnMut(RawDirEntry<'_>) -> std::result::Result>, { let read_dir = std::fs::read_dir(path)?; for entry in read_dir { let entry = entry?; let file_type = match entry.file_type() { Ok(file_type) => file_type, Err(err) if is_skippable_entry_error(&err) => continue, Err(err) => return Err(err.into()), }; let file_type = if file_type.is_symlink() { Some(FileType::Symlink) } else if file_type.is_dir() { Some(FileType::Dir) } else if file_type.is_file() { Some(FileType::File) } else { None }; let Some(file_type) = file_type else { continue; }; let mut mtime = None; let mut size = None; if detail == WalkDetail::Full { match std::fs::symlink_metadata(entry.path()) { Ok(metadata) => { if file_type == FileType::File { size = Some(metadata.len() as f64); } mtime = metadata .modified() .ok() .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) .map(|duration| duration.as_millis() as f64); }, Err(err) if is_skippable_entry_error(&err) => continue, Err(err) => return Err(err.into()), } } let raw_entry = RawDirEntry { name: Cow::Owned(entry.file_name()), file_type, mtime, size }; if emit(raw_entry).map_err(ReadDirError::Walk)? == ReadDirControl::Stop { return Ok(ReadDirControl::Stop); } } Ok(ReadDirControl::Continue) } fn open_dir(path: &Path) -> io::Result { let path = CString::new(path.as_os_str().as_bytes()) .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains NUL"))?; // SAFETY: `path` is a NUL-terminated C string; flags open the directory for // metadata traversal only and do not transfer ownership of the string. let fd = unsafe { libc::open(path.as_ptr(), libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC) }; if fd < 0 { Err(io::Error::last_os_error()) } else { Ok(FdGuard(fd)) } } fn parse_record(record: &[u8], detail: WalkDetail) -> io::Result>> { let mut cursor = size_of::(); let name_ref_start = cursor; let name_ref = read_value::(record, &mut cursor)?; let obj_type = read_value::(record, &mut cursor)?; let (mtime, data_length) = if detail == WalkDetail::Full { let modified = read_value::(record, &mut cursor)?; let data_length = read_value::(record, &mut cursor)?; (mtime_millis(modified.tv_sec as i64, modified.tv_nsec as i64), Some(data_length)) } else { (None, None) }; let name_start = checked_attr_offset(name_ref_start, name_ref.attr_dataoffset)?; let name_len = name_ref.attr_length as usize; if name_len == 0 || name_start + name_len > record.len() { return Err(invalid_data("invalid getattrlistbulk name reference")); } let name_bytes = trim_nul(&record[name_start..name_start + name_len]); if name_bytes.is_empty() { return Ok(None); } let Some(file_type) = file_type_from_vtype(obj_type) else { return Ok(None); }; let size = if file_type == FileType::File { data_length.map(|value| value as f64) } else { None }; Ok(Some(RawDirEntry { name: OsStr::from_bytes(name_bytes).into(), file_type, mtime, size })) } fn read_value(record: &[u8], cursor: &mut usize) -> io::Result { let end = cursor.saturating_add(size_of::()); if end > record.len() { return Err(invalid_data("truncated getattrlistbulk attribute")); } let ptr = record[*cursor..end].as_ptr(); *cursor = end; // SAFETY: Bounds were checked above; `getattrlistbulk` records are byte // packed, so unaligned reads are required and do not outlive `record`. Ok(unsafe { std::ptr::read_unaligned(ptr.cast::()) }) } fn checked_attr_offset(base: usize, offset: i32) -> io::Result { if offset < 0 { return Err(invalid_data("negative getattrlistbulk attribute offset")); } base .checked_add(offset as usize) .ok_or_else(|| invalid_data("overflowing getattrlistbulk attribute offset")) } fn trim_nul(bytes: &[u8]) -> &[u8] { let end = bytes.iter().position(|b| *b == 0).unwrap_or(bytes.len()); &bytes[..end] } const fn file_type_from_vtype(value: u32) -> Option { match value { VREG => Some(FileType::File), VDIR => Some(FileType::Dir), VLNK => Some(FileType::Symlink), _ => None, } } fn is_unsupported_dir_scan(err: &io::Error) -> bool { matches!(err.raw_os_error(), Some(libc::ENOTSUP | libc::EINVAL)) } fn is_skippable_entry_error(err: &io::Error) -> bool { matches!(err.kind(), io::ErrorKind::NotFound | io::ErrorKind::PermissionDenied) } fn invalid_data(message: &'static str) -> io::Error { io::Error::new(io::ErrorKind::InvalidData, message) } } #[cfg(target_os = "linux")] mod platform { use std::{ ffi::{CString, OsStr}, io, mem::{size_of, zeroed}, os::unix::ffi::OsStrExt, path::Path, }; use super::{ FileType, RawDirEntry, ReadDirControl, ReadDirError, WalkDetail, WalkError, mtime_millis, }; pub const CHEAP_SIZE_HINTS: bool = false; const BUFFER_SIZE: usize = 256 * 1024; const LINUX_DIRENT64_NAME_OFFSET: usize = 19; const STATX_TYPE: u32 = 0x0001; const STATX_SIZE: u32 = 0x0200; const STATX_MTIME: u32 = 0x0040; const STATX_BASIC_STATS: u32 = 0x07ff; #[repr(C)] #[derive(Clone, Copy)] struct StatxTimestamp { tv_sec: i64, tv_nsec: u32, __reserved: i32, } #[repr(C)] #[derive(Clone, Copy)] struct Statx { stx_mask: u32, stx_blksize: u32, stx_attributes: u64, stx_nlink: u32, stx_uid: u32, stx_gid: u32, stx_mode: u16, __spare0: [u16; 1], stx_ino: u64, stx_size: u64, stx_blocks: u64, stx_attributes_mask: u64, stx_atime: StatxTimestamp, stx_btime: StatxTimestamp, stx_ctime: StatxTimestamp, stx_mtime: StatxTimestamp, stx_rdev_major: u32, stx_rdev_minor: u32, stx_dev_major: u32, stx_dev_minor: u32, stx_mnt_id: u64, stx_dio_mem_align: u32, stx_dio_offset_align: u32, __spare3: [u64; 12], } struct FdGuard(libc::c_int); impl Drop for FdGuard { fn drop(&mut self) { // SAFETY: `FdGuard` owns this file descriptor and closes it exactly once. unsafe { libc::close(self.0) }; } } struct EntryStat { file_type: FileType, mtime: Option, size: Option, } pub fn read_dir_entries( path: &Path, detail: WalkDetail, buffer: &mut Vec, mut emit: F, ) -> std::result::Result> where F: FnMut(RawDirEntry<'_>) -> std::result::Result>, { let fd = open_dir(path)?; if buffer.len() != BUFFER_SIZE { buffer.resize(BUFFER_SIZE, 0); } loop { // SAFETY: `fd` is an open directory descriptor and `buffer` is writable. let read = unsafe { libc::syscall( libc::SYS_getdents64, fd.0, buffer.as_mut_ptr().cast::(), buffer.len(), ) }; if read == 0 { break; } if read < 0 { let err = io::Error::last_os_error(); if err.kind() == io::ErrorKind::Interrupted { continue; } return Err(err.into()); } let mut offset = 0usize; let read_len = read as usize; while offset < read_len { if offset + LINUX_DIRENT64_NAME_OFFSET > read_len { return Err(invalid_data("truncated getdents64 record").into()); } let reclen = read_u16(&buffer[offset + 16..read_len])? as usize; if reclen < LINUX_DIRENT64_NAME_OFFSET || offset + reclen > read_len { return Err(invalid_data("invalid getdents64 record length").into()); } let d_type = buffer[offset + 18]; let name_bytes = trim_nul(&buffer[offset + LINUX_DIRENT64_NAME_OFFSET..offset + reclen]); offset += reclen; if name_bytes.is_empty() { continue; } let dtype_file_type = file_type_from_dtype(d_type); let stat = if detail == WalkDetail::Full || dtype_file_type.is_none() { match stat_entry(fd.0, name_bytes, detail) { Ok(Some(stat)) => Some(stat), Ok(None) => continue, Err(err) if is_skippable_entry_error(&err) => continue, Err(err) => return Err(err.into()), } } else { None }; let file_type = stat .as_ref() .map_or(dtype_file_type, |stat| Some(stat.file_type)); let Some(file_type) = file_type else { continue; }; let entry = RawDirEntry { name: OsStr::from_bytes(name_bytes).into(), file_type, mtime: stat.as_ref().and_then(|stat| stat.mtime), size: stat.as_ref().and_then(|stat| stat.size), }; if emit(entry).map_err(ReadDirError::Walk)? == ReadDirControl::Stop { return Ok(ReadDirControl::Stop); } } } Ok(ReadDirControl::Continue) } fn open_dir(path: &Path) -> io::Result { let path = CString::new(path.as_os_str().as_bytes()) .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains NUL"))?; // SAFETY: `path` is a NUL-terminated C string; flags request a directory // descriptor used only with getdents/statx and do not retain the pointer. let fd = unsafe { libc::open(path.as_ptr(), libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC) }; if fd < 0 { Err(io::Error::last_os_error()) } else { Ok(FdGuard(fd)) } } fn stat_entry( dirfd: libc::c_int, name: &[u8], detail: WalkDetail, ) -> io::Result> { let name = CString::new(name) .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "entry name contains NUL"))?; match statx_entry(dirfd, &name, detail) { Ok(value) => Ok(value), Err(err) if matches!(err.raw_os_error(), Some(libc::ENOSYS | libc::EINVAL)) => { fstatat_entry(dirfd, &name, detail) }, Err(err) => Err(err), } } fn statx_entry( dirfd: libc::c_int, name: &CString, detail: WalkDetail, ) -> io::Result> { // SAFETY: `Statx` is a plain-old-data buffer whose all-zero value is a // valid initialization before the kernel fills it. let mut statx = unsafe { zeroed::() }; let mask = if detail == WalkDetail::Full { STATX_BASIC_STATS } else { STATX_TYPE }; // SAFETY: `name` is NUL-terminated, `statx` is writable, and `dirfd` is an // open directory descriptor for an AT_* relative metadata query. let rc = unsafe { libc::syscall( libc::SYS_statx, dirfd, name.as_ptr(), libc::AT_SYMLINK_NOFOLLOW | libc::AT_NO_AUTOMOUNT, mask, std::ptr::addr_of_mut!(statx), ) }; if rc != 0 { return Err(io::Error::last_os_error()); } let Some(file_type) = file_type_from_mode(statx.stx_mode as libc::mode_t) else { return Ok(None); }; let mtime = if detail == WalkDetail::Full && statx.stx_mask & STATX_MTIME != 0 { mtime_millis(statx.stx_mtime.tv_sec, i64::from(statx.stx_mtime.tv_nsec)) } else { None }; let size = if detail == WalkDetail::Full && file_type == FileType::File && statx.stx_mask & STATX_SIZE != 0 { Some(statx.stx_size as f64) } else { None }; Ok(Some(EntryStat { file_type, mtime, size })) } fn fstatat_entry( dirfd: libc::c_int, name: &CString, detail: WalkDetail, ) -> io::Result> { // SAFETY: `libc::stat` is a POD buffer filled by fstatat. let mut stat = unsafe { zeroed::() }; // SAFETY: `name` is NUL-terminated, `stat` is writable, and `dirfd` is an // open directory descriptor for an AT_* relative metadata query. let rc = unsafe { libc::fstatat( dirfd, name.as_ptr(), std::ptr::addr_of_mut!(stat), libc::AT_SYMLINK_NOFOLLOW, ) }; if rc != 0 { return Err(io::Error::last_os_error()); } let Some(file_type) = file_type_from_mode(stat.st_mode) else { return Ok(None); }; let mtime = if detail == WalkDetail::Full { mtime_millis(stat.st_mtime, stat.st_mtime_nsec as i64) } else { None }; let size = if detail == WalkDetail::Full && file_type == FileType::File { Some(stat.st_size as f64) } else { None }; Ok(Some(EntryStat { file_type, mtime, size })) } fn read_u16(bytes: &[u8]) -> io::Result { if bytes.len() < size_of::() { return Err(invalid_data("truncated u16")); } Ok(u16::from_ne_bytes( bytes[..size_of::()] .try_into() .expect("slice length checked"), )) } fn trim_nul(bytes: &[u8]) -> &[u8] { let end = bytes.iter().position(|b| *b == 0).unwrap_or(bytes.len()); &bytes[..end] } const fn file_type_from_dtype(value: u8) -> Option { match value { libc::DT_REG => Some(FileType::File), libc::DT_DIR => Some(FileType::Dir), libc::DT_LNK => Some(FileType::Symlink), _ => None, } } const fn file_type_from_mode(mode: libc::mode_t) -> Option { match mode & libc::S_IFMT { libc::S_IFREG => Some(FileType::File), libc::S_IFDIR => Some(FileType::Dir), libc::S_IFLNK => Some(FileType::Symlink), _ => None, } } fn is_skippable_entry_error(err: &io::Error) -> bool { matches!( err.kind(), io::ErrorKind::NotFound | io::ErrorKind::PermissionDenied | io::ErrorKind::NotADirectory ) } fn invalid_data(message: &'static str) -> io::Error { io::Error::new(io::ErrorKind::InvalidData, message) } } #[cfg(target_os = "windows")] mod platform { use std::{ ffi::OsString, io, os::windows::ffi::{OsStrExt, OsStringExt}, path::Path, }; use windows_sys::{ Wdk::Storage::FileSystem::{ FILE_ID_FULL_DIR_INFORMATION, FileIdFullDirectoryInformation, NtQueryDirectoryFile, }, Win32::{ Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE, STATUS_NO_MORE_FILES}, Storage::FileSystem::{ CreateFileW, FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_LIST_DIRECTORY, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, }, System::IO::IO_STATUS_BLOCK, }, }; use super::{ FileType, RawDirEntry, ReadDirControl, ReadDirError, WalkDetail, WalkError, mtime_millis, }; pub const CHEAP_SIZE_HINTS: bool = true; const BUFFER_SIZE: usize = 256 * 1024; const WINDOWS_TICK: i64 = 10_000_000; const UNIX_EPOCH_AS_FILETIME: i64 = 116_444_736_000_000_000; struct HandleGuard(HANDLE); impl Drop for HandleGuard { fn drop(&mut self) { // SAFETY: `HandleGuard` owns this handle and closes it exactly once. unsafe { CloseHandle(self.0) }; } } pub fn read_dir_entries( path: &Path, detail: WalkDetail, buffer: &mut Vec, mut emit: F, ) -> std::result::Result> where F: FnMut(RawDirEntry<'_>) -> std::result::Result>, { let handle = open_dir(path)?; if buffer.len() != BUFFER_SIZE { buffer.resize(BUFFER_SIZE, 0); } let mut restart = true; loop { let mut iosb = IO_STATUS_BLOCK::default(); // SAFETY: `handle` is an open directory handle, `buffer` is writable, and // the query class matches the record parser below. let status = unsafe { NtQueryDirectoryFile( handle.0, std::ptr::null_mut(), None, std::ptr::null(), std::ptr::addr_of_mut!(iosb), buffer.as_mut_ptr().cast(), buffer.len() as u32, FileIdFullDirectoryInformation, false, std::ptr::null(), restart, ) }; restart = false; if status == STATUS_NO_MORE_FILES { break; } if status < 0 { return Err(io::Error::from_raw_os_error(status).into()); } let mut offset = 0usize; loop { if offset + std::mem::size_of::() > buffer.len() { return Err(invalid_data("truncated NtQueryDirectoryFile record").into()); } let info = unsafe { // SAFETY: Bounds were checked above; records are byte-packed in the // buffer and may not be aligned for Rust references. std::ptr::read_unaligned( buffer[offset..] .as_ptr() .cast::(), ) }; let name_offset = offset + std::mem::offset_of!(FILE_ID_FULL_DIR_INFORMATION, FileName); let name_len = info.FileNameLength as usize; if name_len % 2 != 0 || name_offset + name_len > buffer.len() { return Err(invalid_data("invalid NtQueryDirectoryFile name length").into()); } let name_units: Vec = buffer[name_offset..name_offset + name_len] .chunks_exact(2) .map(|chunk| u16::from_ne_bytes([chunk[0], chunk[1]])) .collect(); let name = OsString::from_wide(&name_units); if let Some(file_type) = file_type_from_attributes(info.FileAttributes) { let size = if detail == WalkDetail::Full && file_type == FileType::File { Some(info.EndOfFile.max(0) as f64) } else { None }; let mtime = if detail == WalkDetail::Full { mtime_from_filetime(info.LastWriteTime) } else { None }; let entry = RawDirEntry { name: name.into(), file_type, mtime, size }; if emit(entry).map_err(ReadDirError::Walk)? == ReadDirControl::Stop { return Ok(ReadDirControl::Stop); } } if info.NextEntryOffset == 0 { break; } offset = offset.saturating_add(info.NextEntryOffset as usize); } } Ok(ReadDirControl::Continue) } fn open_dir(path: &Path) -> io::Result { let mut path: Vec = path.as_os_str().encode_wide().collect(); path.push(0); // SAFETY: `path` is NUL-terminated; the returned handle is owned by // `HandleGuard` on success. let handle = unsafe { CreateFileW( path.as_ptr(), FILE_LIST_DIRECTORY, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, std::ptr::null(), OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, std::ptr::null_mut(), ) }; if handle == INVALID_HANDLE_VALUE { Err(io::Error::last_os_error()) } else { Ok(HandleGuard(handle)) } } fn file_type_from_attributes(attributes: u32) -> Option { if attributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 { Some(FileType::Symlink) } else if attributes & FILE_ATTRIBUTE_DIRECTORY != 0 { Some(FileType::Dir) } else { Some(FileType::File) } } fn mtime_from_filetime(filetime: i64) -> Option { let ticks = filetime.checked_sub(UNIX_EPOCH_AS_FILETIME)?; let seconds = ticks / WINDOWS_TICK; let nanos = (ticks % WINDOWS_TICK) * 100; mtime_millis(seconds, nanos) } fn invalid_data(message: &'static str) -> io::Error { io::Error::new(io::ErrorKind::InvalidData, message) } } #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] mod platform { use std::{borrow::Cow, io, path::Path}; use super::{FileType, RawDirEntry, ReadDirControl, ReadDirError, WalkDetail, WalkError}; pub const CHEAP_SIZE_HINTS: bool = false; pub fn read_dir_entries( path: &Path, detail: WalkDetail, _buffer: &mut Vec, mut emit: F, ) -> std::result::Result> where F: FnMut(RawDirEntry<'_>) -> std::result::Result>, { let read_dir = std::fs::read_dir(path)?; for entry in read_dir { let entry = entry?; let file_type_res = entry.file_type(); let file_type = match file_type_res { Ok(ft) => ft, Err(err) if is_skippable_entry_error(&err) => continue, Err(err) => return Err(err.into()), }; let custom_file_type = if file_type.is_symlink() { Some(FileType::Symlink) } else if file_type.is_dir() { Some(FileType::Dir) } else if file_type.is_file() { Some(FileType::File) } else { None }; let Some(custom_file_type) = custom_file_type else { continue; // skip unsupported special files }; let mut mtime = None; let mut size = None; if detail == WalkDetail::Full { match std::fs::symlink_metadata(entry.path()) { Ok(metadata) => { if custom_file_type == FileType::File { size = Some(metadata.len() as f64); } mtime = metadata .modified() .ok() .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) .map(|duration| duration.as_millis() as f64); }, Err(err) if is_skippable_entry_error(&err) => continue, Err(err) => return Err(err.into()), } } let raw_entry = RawDirEntry { name: Cow::Owned(entry.file_name()), file_type: custom_file_type, mtime, size, }; if emit(raw_entry).map_err(ReadDirError::Walk)? == ReadDirControl::Stop { return Ok(ReadDirControl::Stop); } } Ok(ReadDirControl::Continue) } fn is_skippable_entry_error(err: &io::Error) -> bool { matches!(err.kind(), io::ErrorKind::NotFound | io::ErrorKind::PermissionDenied) } } #[cfg(test)] mod tests { use std::{ fs, path::PathBuf, time::{Duration, SystemTime, UNIX_EPOCH}, }; use super::*; struct TempTree { root: PathBuf, } impl TempTree { fn path(&self) -> &Path { &self.root } } impl Drop for TempTree { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.root); } } fn temp_tree(name: &str) -> TempTree { let unique = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("system time should be after UNIX_EPOCH") .as_nanos(); let root = std::env::temp_dir().join(format!("pi-walker-{name}-{unique}")); fs::create_dir_all(&root).expect("temp root should be created"); TempTree { root } } struct CachePathGuard { root: PathBuf, } impl CachePathGuard { fn new(root: &Path) -> Self { invalidate_path(root); Self { root: root.to_path_buf() } } } impl Drop for CachePathGuard { fn drop(&mut self) { invalidate_path(&self.root); } } fn wait_for_nonzero_cache_age() { let started = std::time::Instant::now(); while started.elapsed() < Duration::from_millis(1) { std::thread::yield_now(); } } fn test_options() -> WalkOptions { WalkOptions { include_hidden: true, use_gitignore: false, skip_git: true, skip_node_modules: true, follow_links: FollowLinks::Never, detail: WalkDetail::Minimal, order: WalkOrder::Path, emit_root: false, min_depth: 1, max_depth: usize::MAX, contents_first: false, directory_errors: DirectoryErrorMode::SkipSkippable, same_file_system: false, cache: false, } } fn collect_file_paths(root: &Path, use_gitignore: bool) -> Vec { WalkRequest::from_options(root, WalkOptions { use_gitignore, ..test_options() }) .filter(WalkFilter::files_only()) .collect() .expect("walk should collect successfully") .entries .into_iter() .map(|entry| entry.path) .collect() } #[test] fn parent_ignore_outside_repo_does_not_filter_explicit_root() { let tree = temp_tree("parent-ignore-outside-repo"); fs::write(tree.path().join(".gitignore"), "*.nix\n") .expect("parent gitignore should be written"); let project = tree.path().join("projects").join("home-manager"); fs::create_dir_all(project.join("modules").join("common")) .expect("project modules should be created"); fs::write(project.join("flake.nix"), "flake").expect("flake should be written"); fs::write(project.join("modules").join("common").join("zsh.nix"), "zsh") .expect("module should be written"); let paths = collect_file_paths(&project, true); assert_eq!( paths, vec!["flake.nix", "modules/common/zsh.nix"], "an explicit non-repo search root must not inherit ignore files from unrelated parents" ); } #[test] fn repo_parent_ignore_still_filters_subdirectory_root() { let tree = temp_tree("repo-parent-ignore"); fs::create_dir_all(tree.path().join(".git")).expect("repo marker should be created"); fs::write(tree.path().join(".gitignore"), "*.nix\n") .expect("repo gitignore should be written"); let project = tree.path().join("projects").join("home-manager"); fs::create_dir_all(project.join("modules").join("common")) .expect("project modules should be created"); fs::write(project.join("flake.nix"), "flake").expect("flake should be written"); fs::write(project.join("modules").join("common").join("zsh.nix"), "zsh") .expect("module should be written"); let paths = collect_file_paths(&project, true); assert!( paths.is_empty(), "repo-root .gitignore should still apply when walking a subdirectory root, got {paths:?}" ); } #[test] fn explicit_ignored_root_keeps_unrelated_parent_and_nested_ignore_rules() { let tree = temp_tree("explicit-ignored-root"); fs::create_dir_all(tree.path().join(".git")).expect("repo marker should be created"); fs::write(tree.path().join(".gitignore"), "*.log\nignored/**\n") .expect("repo gitignore should be written"); let project = tree.path().join("ignored").join("package"); let nested = project.join("nested"); fs::create_dir_all(&nested).expect("ignored project tree should be created"); fs::write(project.join("keep.ts"), "keep").expect("kept file should be written"); fs::write(project.join("trace.log"), "trace").expect("parent-ignored file should be written"); fs::write(nested.join(".gitignore"), "generated.ts\n") .expect("nested gitignore should be written"); fs::write(nested.join("generated.ts"), "generated") .expect("nested ignored file should be written"); fs::write(nested.join("keep.ts"), "nested keep").expect("nested kept file should be written"); let paths = collect_file_paths(&project, true); assert_eq!(paths, vec!["keep.ts", "nested/.gitignore", "nested/keep.ts"]); } #[test] fn walk_request_files_only_returns_relative_files_and_excludes_directories() { let tree = temp_tree("request-files-only"); fs::write(tree.path().join("alpha.txt"), "alpha").expect("top-level file should be written"); fs::create_dir_all(tree.path().join("nested")).expect("nested dir should be created"); fs::write(tree.path().join("nested").join("beta.txt"), "beta") .expect("nested file should be written"); let outcome = WalkRequest::from_options(tree.path(), test_options()) .filter(WalkFilter::files_only()) .collect() .expect("files-only request should collect successfully"); let paths = outcome .entries .iter() .map(|entry| entry.path.as_str()) .collect::>(); assert_eq!( paths, vec!["alpha.txt", "nested/beta.txt"], "files-only requests should preserve relative file paths and exclude directory entries" ); assert!( outcome.entries.iter().all(CollectedEntry::is_file), "files-only requests should not return directories or other entry kinds: {:?}", outcome.entries ); } #[test] fn compare_depth_first_paths_orders_children_before_parent() { let mut paths = vec!["dir", "alpha", "dir/child", "dir/child/grandchild"]; paths.sort_unstable_by(|left, right| compare_depth_first_paths(left, right)); assert_eq!( paths, vec!["alpha", "dir/child/grandchild", "dir/child", "dir"], "depth-first ordering should keep lexical siblings stable while placing descendants \ before ancestors" ); } #[test] fn walk_request_collect_contents_first_uses_collected_fallback() { let tree = temp_tree("request-contents-first"); fs::create_dir_all(tree.path().join("nested")).expect("nested dir should be created"); fs::write(tree.path().join("nested").join("leaf.txt"), "leaf") .expect("nested file should be written"); let outcome = WalkRequest::from_options(tree.path(), test_options()) .visit_order(VisitOrder::ContentsFirst) .collect() .expect("contents-first request should collect through high-level fallback"); let paths = outcome .entries .iter() .map(|entry| entry.path.as_str()) .collect::>(); assert_eq!( paths, vec!["nested/leaf.txt", "nested"], "contents-first collection should replay children before their directory" ); } #[test] fn walk_request_stream_fallback_preserves_predicate_skip_descend() { struct RecordingVisitor { paths: Vec, } impl EntryVisitor for RecordingVisitor { type Error = Infallible; fn visit(&mut self, entry: Entry<'_>) -> std::result::Result { self.paths.push(entry.relative.to_string()); Ok(WalkControl::Continue) } } let tree = temp_tree("request-stream-skip-descend"); fs::create_dir_all(tree.path().join("keep")).expect("keep dir should be created"); fs::write(tree.path().join("keep").join("leaf.txt"), "leaf") .expect("kept leaf should be written"); fs::create_dir_all(tree.path().join("skip")).expect("skip dir should be created"); fs::write(tree.path().join("skip").join("hidden.txt"), "hidden") .expect("skipped leaf should be written"); let mut visitor = RecordingVisitor { paths: Vec::new() }; let status = WalkRequest::from_options(tree.path(), test_options()) .visit_order(VisitOrder::ContentsFirst) .stream_with_predicate(&mut visitor, |meta: &EntryMeta<'_>| { if meta.relative_path == "skip" { WalkDecision::SkipDescend } else { WalkDecision::Include } }) .expect("contents-first stream fallback should complete"); assert_eq!(status, WalkStatus::Complete); assert_eq!( visitor.paths, vec!["keep/leaf.txt", "keep"], "predicate SkipDescend should prune the skipped directory and its descendants before \ contents-first replay" ); } #[test] fn walk_request_collect_with_heartbeat_propagates_interruptions() { let tree = temp_tree("request-heartbeat"); fs::write(tree.path().join("alpha.txt"), "alpha").expect("file should be written"); let result = WalkRequest::from_options(tree.path(), test_options()) .collect_with_heartbeat(|| Err("stop requested")); let Err(WalkError::Interrupted(message)) = result else { panic!("heartbeat interruption should be surfaced as WalkError::Interrupted"); }; assert_eq!(message, "stop requested"); } #[test] fn walk_request_rechecks_stale_empty_cached_files_only_result() { let tree = temp_tree("request-empty-recheck"); let _cache_guard = CachePathGuard::new(tree.path()); let request = WalkRequest::from_options(tree.path(), test_options()) .cache(true) .filter(WalkFilter::files_only()) .empty_recheck(EmptyRecheck::Never); let primed = request .collect() .expect("empty request should collect successfully"); assert_eq!(primed.backend, WalkBackend::Fresh); assert!( primed.entries.is_empty(), "empty root should prime an empty files-only cache entry, got {:?}", primed.entries ); fs::write(tree.path().join("created.txt"), "created") .expect("file created after cache prime should be written"); wait_for_nonzero_cache_age(); let stale = request .collect() .expect("empty recheck disabled request should read the cached empty result"); assert_eq!(stale.backend, WalkBackend::Cached); assert!( stale.stats.cache_age_ms > 0, "cached empty result should be old enough to exercise empty recheck" ); assert!( stale.entries.is_empty(), "disabled empty recheck should leave the cached empty files-only result untouched" ); let refreshed = request .empty_recheck(EmptyRecheck::AfterMillis(0)) .collect() .expect("stale empty cache should be rechecked without manual invalidation"); let paths = refreshed .entries .iter() .map(|entry| entry.path.as_str()) .collect::>(); assert_eq!( refreshed.backend, WalkBackend::Fresh, "stale empty cache recheck should force a fresh scan" ); assert_eq!( paths, vec!["created.txt"], "empty cached files-only requests should observe files created after the cache was primed" ); assert!( refreshed.entries.iter().all(CollectedEntry::is_file), "files-only empty recheck should still return only files: {:?}", refreshed.entries ); } #[test] fn walk_request_rechecks_stale_cache_empty_after_glob_filter() { let tree = temp_tree("request-filtered-empty-recheck"); let _cache_guard = CachePathGuard::new(tree.path()); fs::write(tree.path().join("old.txt"), "old") .expect("nonmatching file should be written before cache prime"); let request = WalkRequest::from_options(tree.path(), test_options()) .cache(true) .filter( WalkFilter::files_only() .glob(CompiledWalkGlob::new(["*.rs"]).expect("test glob should compile")), ) .empty_recheck(EmptyRecheck::Never); let primed = request .collect() .expect("filtered request should prime the raw nonempty cache successfully"); assert_eq!(primed.backend, WalkBackend::Fresh); assert_eq!( primed.stats.scanned_entries, 1, "cache prime should scan the nonmatching file before filtering" ); assert_eq!( primed.stats.filtered_entries, 1, "glob filter should remove the nonmatching cached file" ); assert!( primed.entries.is_empty(), "nonmatching file should leave the filtered prime result empty: {:?}", primed.entries ); fs::write(tree.path().join("new.rs"), "new") .expect("matching file should be written after cache prime"); wait_for_nonzero_cache_age(); let refreshed = request .empty_recheck(EmptyRecheck::AfterMillis(0)) .collect() .expect("filtered empty cached result should be rechecked without manual invalidation"); let paths = refreshed .entries .iter() .map(|entry| entry.path.as_str()) .collect::>(); assert_eq!( refreshed.backend, WalkBackend::Fresh, "stale cache that is empty only after filtering should force a fresh scan" ); assert_eq!( paths, vec!["new.rs"], "filtered empty-cache recheck should return the file that matches the glob" ); assert!( refreshed.entries.iter().all(CollectedEntry::is_file), "glob-filtered files-only recheck should still return only files: {:?}", refreshed.entries ); } #[test] fn collect_entries_honors_gitignore_without_repo_marker() { let tree = temp_tree("plain-gitignore"); fs::write(tree.path().join(".gitignore"), "ignored.txt\n") .expect(".gitignore should be written"); fs::write(tree.path().join("ignored.txt"), "ignored") .expect("ignored file should be written"); fs::write(tree.path().join("kept.txt"), "keep").expect("kept file should be written"); let scan = collect_entries( tree.path(), WalkOptions { use_gitignore: true, cache: false, ..test_options() }, || Ok::<(), Infallible>(()), ) .expect("collection should not fail"); let paths = scan .entries .into_iter() .map(|entry| entry.path) .collect::>(); assert!( paths.iter().any(|path| path == "kept.txt"), "collect_entries should include kept.txt from a plain directory, got: {paths:?}" ); assert!( !paths.iter().any(|path| path == "ignored.txt"), "collect_entries should exclude .gitignore matches without a .git marker, got: {paths:?}" ); } #[test] fn collect_entries_ignores_parent_gitignore_above_non_repo_root() { let tree = temp_tree("ancestor-gitignore-non-repo-root"); let search_root = tree.path().join("project"); fs::create_dir_all(&search_root).expect("explicit search root should be created"); fs::write(tree.path().join(".gitignore"), "*.nix\n") .expect("ancestor .gitignore should be written"); fs::write(search_root.join("module.nix"), "nix").expect("nix file should be written"); fs::write(search_root.join("kept.txt"), "keep").expect("kept file should be written"); let scan = collect_entries( &search_root, WalkOptions { use_gitignore: true, cache: false, ..test_options() }, || Ok::<(), Infallible>(()), ) .expect("collection should not fail"); let paths = scan .entries .into_iter() .map(|entry| entry.path) .collect::>(); assert!( paths.iter().any(|path| path == "module.nix"), "ancestor .gitignore outside a non-repo explicit root should not hide module.nix, got: \ {paths:?}" ); assert!( paths.iter().any(|path| path == "kept.txt"), "sanity check should include kept.txt from the explicit root, got: {paths:?}" ); } #[test] fn collect_entries_applies_repo_root_gitignore_to_subdirectory_search_root() { let tree = temp_tree("repo-root-gitignore-subdir-root"); let search_root = tree.path().join("src"); fs::create_dir_all(tree.path().join(".git")).expect("repo marker should be created"); fs::create_dir_all(&search_root).expect("explicit search root should be created"); fs::write(tree.path().join(".gitignore"), "*.nix\n") .expect("repo-root .gitignore should be written"); fs::write(search_root.join("module.nix"), "nix").expect("ignored nix file should be written"); fs::write(search_root.join("kept.txt"), "keep").expect("kept file should be written"); let scan = collect_entries( &search_root, WalkOptions { use_gitignore: true, cache: false, ..test_options() }, || Ok::<(), Infallible>(()), ) .expect("collection should not fail"); let paths = scan .entries .into_iter() .map(|entry| entry.path) .collect::>(); assert!( !paths.iter().any(|path| path == "module.nix"), "repo-root .gitignore should hide module.nix when searching inside that repo, got: \ {paths:?}" ); assert!( paths.iter().any(|path| path == "kept.txt"), "repo subdirectory search should still include nonignored files, got: {paths:?}" ); } #[test] fn collect_entries_with_exact_workspace_options() { let tree = temp_tree("exact-options"); fs::write(tree.path().join(".gitignore"), "ignored.txt\n") .expect(".gitignore should be written"); fs::write(tree.path().join("ignored.txt"), "ignored") .expect("ignored file should be written"); fs::write(tree.path().join("kept.txt"), "keep").expect("kept file should be written"); let scan = collect_entries( tree.path(), WalkOptions { include_hidden: false, use_gitignore: true, detail: WalkDetail::Full, max_depth: 4, ..test_options() }, || Ok::<(), Infallible>(()), ) .expect("collection should not fail"); let paths = scan .entries .into_iter() .map(|entry| entry.path) .collect::>(); assert!( !paths.iter().any(|path| path == "ignored.txt"), "should exclude ignored.txt, got: {paths:?}" ); } #[test] fn collect_entries_prunes_hidden_git_and_node_modules() { let tree = temp_tree("filters"); fs::write(tree.path().join("visible.txt"), "ok").expect("visible file should be written"); fs::write(tree.path().join(".hidden"), "hidden").expect("hidden file should be written"); fs::create_dir_all(tree.path().join(".git")).expect(".git should be created"); fs::write(tree.path().join(".git").join("config"), "git") .expect("git file should be written"); fs::create_dir_all(tree.path().join("node_modules")).expect("node_modules should be created"); fs::write(tree.path().join("node_modules").join("pkg.js"), "pkg") .expect("node module file should be written"); let scan = collect_entries( tree.path(), WalkOptions { include_hidden: false, ..test_options() }, || Ok::<(), Infallible>(()), ) .expect("collection should not fail"); let paths = scan .entries .into_iter() .map(|entry| entry.path) .collect::>(); assert_eq!(paths, vec!["visible.txt"]); } struct PruneVisitor { seen: Vec, } impl EntryVisitor for PruneVisitor { type Error = Infallible; fn visit(&mut self, entry: Entry<'_>) -> std::result::Result { self.seen.push(entry.relative.to_string()); if entry.relative == "skip" { Ok(WalkControl::SkipDescend) } else { Ok(WalkControl::Continue) } } } #[cfg(unix)] struct PathsVisitor { seen: Vec, } #[cfg(unix)] impl EntryVisitor for PathsVisitor { type Error = Infallible; fn visit(&mut self, entry: Entry<'_>) -> std::result::Result { self.seen.push(entry.relative.to_string()); Ok(WalkControl::Continue) } } #[cfg(unix)] fn walk_paths(root: &Path, follow_links: FollowLinks) -> Vec { let mut visitor = PathsVisitor { seen: Vec::new() }; let status = walk_entries(root, WalkOptions { follow_links, ..test_options() }, &mut visitor, || { Ok::<(), Infallible>(()) }) .expect("walk should not fail"); assert_eq!(status, WalkStatus::Complete); visitor.seen } #[test] fn skip_descend_prunes_directory_children() { let tree = temp_tree("skip-descend"); fs::create_dir_all(tree.path().join("keep")).expect("keep dir should be created"); fs::write(tree.path().join("keep").join("file.txt"), "ok") .expect("keep file should be written"); fs::create_dir_all(tree.path().join("skip")).expect("skip dir should be created"); fs::write(tree.path().join("skip").join("file.txt"), "no") .expect("skip file should be written"); let mut visitor = PruneVisitor { seen: Vec::new() }; let _status = walk_entries(tree.path(), test_options(), &mut visitor, || Ok::<(), Infallible>(())) .expect("walk should not fail"); assert!(visitor.seen.iter().any(|path| path == "skip")); assert!(visitor.seen.iter().any(|path| path == "keep/file.txt")); assert!(!visitor.seen.iter().any(|path| path == "skip/file.txt")); } #[cfg(unix)] #[test] fn walk_entries_always_follows_descendant_symlink_directories() { let tree = temp_tree("follow-always"); fs::create_dir_all(tree.path().join("target")).expect("target dir should be created"); fs::write(tree.path().join("target").join("child.txt"), "ok") .expect("target child should be written"); std::os::unix::fs::symlink(tree.path().join("target"), tree.path().join("link")) .expect("directory symlink should be created"); let paths = walk_paths(tree.path(), FollowLinks::Always); assert!( paths.iter().any(|path| path == "link/child.txt"), "FollowLinks::Always should yield descendants through symlink paths, got: {paths:?}" ); } #[cfg(unix)] #[test] fn walk_entries_roots_follows_root_symlink_but_not_descendant_symlinks() { let target = temp_tree("follow-roots-target"); fs::write(target.path().join("child.txt"), "ok").expect("root child should be written"); let linked_target = temp_tree("follow-roots-linked-target"); fs::write(linked_target.path().join("linked-child.txt"), "linked") .expect("linked child should be written"); std::os::unix::fs::symlink(linked_target.path(), target.path().join("descendant-link")) .expect("descendant directory symlink should be created"); let link_parent = temp_tree("follow-roots-link-parent"); let root_link = link_parent.path().join("root-link"); std::os::unix::fs::symlink(target.path(), &root_link) .expect("root directory symlink should be created"); let paths = walk_paths(&root_link, FollowLinks::Roots); assert!( paths.iter().any(|path| path == "child.txt"), "FollowLinks::Roots should traverse children of a symlink root, got: {paths:?}" ); assert!( !paths .iter() .any(|path| path == "descendant-link/linked-child.txt"), "FollowLinks::Roots should not traverse descendant symlink directories, got: {paths:?}" ); } #[cfg(unix)] #[test] fn walk_entries_never_does_not_follow_root_symlink_directory() { let target = temp_tree("follow-never-target"); fs::write(target.path().join("child.txt"), "ok").expect("root child should be written"); let link_parent = temp_tree("follow-never-link-parent"); let root_link = link_parent.path().join("root-link"); std::os::unix::fs::symlink(target.path(), &root_link) .expect("root directory symlink should be created"); let paths = walk_paths(&root_link, FollowLinks::Never); assert!( !paths.iter().any(|path| path == "child.txt"), "FollowLinks::Never should not traverse a symlink root, got: {paths:?}" ); } }