Skip to main content

path_rs/
discovery.rs

1//! Generic recursive directory discovery (no VCS or product-specific roots).
2//!
3//! Enabled by the `listing` feature. Callers supply predicates for domain rules
4//! (for example, detecting a project marker directory).
5
6use crate::error::PathError;
7use crate::identity::{PathIdentityOptions, path_identity_key};
8use crate::inspect::{DirectoryInspection, inspect_directory};
9use crate::internal::validation::reject_nul_path;
10use crate::metadata::{SortMode, TraversalErrorPolicy};
11use std::collections::HashSet;
12use std::path::{Path, PathBuf};
13use walkdir::WalkDir;
14
15/// Options for recursive directory discovery.
16///
17/// Skip rules are **caller-configured**. This crate does not hardcode
18/// `node_modules`, `.git`, `target`, etc.
19///
20/// # Depth model
21///
22/// Same as [`crate::listing::ListOptions`]:
23///
24/// - **Depth 0** — the walk root
25/// - **Depth 1** — immediate children
26/// - `recursive: false` limits the walk to depth 0 only (the root)
27/// - `recursive: true` with `max_depth: Some(n)` uses walkdir `max_depth = n`
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct DiscoveryOptions {
30    /// Recurse into subdirectories.
31    pub recursive: bool,
32    /// Follow symbolic links (best-effort cycle detection via walkdir).
33    pub follow_symlinks: bool,
34    /// Maximum walk depth relative to the root (`None` = unlimited when recursive).
35    ///
36    /// Depth `0` is the root itself; `1` is immediate children.
37    /// Ignored when `recursive` is false (only the root is considered).
38    pub max_depth: Option<usize>,
39    /// Maximum number of directories returned.
40    pub max_entries: Option<usize>,
41    /// Directory **names** to skip (exact component match). Never applied to the root itself.
42    pub skip_directory_names: Vec<String>,
43    /// Relative path prefixes (using `/` separators) to skip under the root.
44    pub skip_relative_prefixes: Vec<String>,
45    /// Glob patterns matched against relative paths (requires `search` / `globset`).
46    #[cfg(feature = "search")]
47    pub skip_globs: Vec<String>,
48    /// Error handling policy.
49    pub error_policy: TraversalErrorPolicy,
50    /// Deduplicate results by identity key.
51    pub deduplicate: bool,
52    /// Identity options used when `deduplicate` is true.
53    pub identity: PathIdentityOptions,
54    /// Sort mode for the returned list.
55    pub sort: SortMode,
56}
57
58impl Default for DiscoveryOptions {
59    fn default() -> Self {
60        Self {
61            recursive: true,
62            follow_symlinks: false,
63            max_depth: None,
64            max_entries: None,
65            skip_directory_names: Vec::new(),
66            skip_relative_prefixes: Vec::new(),
67            #[cfg(feature = "search")]
68            skip_globs: Vec::new(),
69            error_policy: TraversalErrorPolicy::FailFast,
70            deduplicate: true,
71            identity: PathIdentityOptions::default(),
72            sort: SortMode::Path,
73        }
74    }
75}
76
77impl DiscoveryOptions {
78    /// Create default options (`Self::default()`).
79    pub fn new() -> Self {
80        Self::default()
81    }
82
83    /// Enable or disable recursion.
84    pub fn recursive(mut self, recursive: bool) -> Self {
85        self.recursive = recursive;
86        self
87    }
88
89    /// Follow symlinks when walking.
90    pub fn follow_symlinks(mut self, follow: bool) -> Self {
91        self.follow_symlinks = follow;
92        self
93    }
94
95    /// Skip exact directory names (e.g. `"node_modules"`).
96    pub fn skip_names(mut self, names: impl IntoIterator<Item = impl Into<String>>) -> Self {
97        self.skip_directory_names
98            .extend(names.into_iter().map(Into::into));
99        self
100    }
101
102    /// Skip relative path prefixes under the root.
103    pub fn skip_relative_prefixes(
104        mut self,
105        prefixes: impl IntoIterator<Item = impl Into<String>>,
106    ) -> Self {
107        self.skip_relative_prefixes
108            .extend(prefixes.into_iter().map(Into::into));
109        self
110    }
111
112    /// Set maximum depth (see module / type docs).
113    pub fn max_depth(mut self, depth: Option<usize>) -> Self {
114        self.max_depth = depth;
115        self
116    }
117
118    /// Set maximum number of returned directories.
119    pub fn max_entries(mut self, max: Option<usize>) -> Self {
120        self.max_entries = max;
121        self
122    }
123
124    /// Set error policy.
125    pub fn error_policy(mut self, policy: TraversalErrorPolicy) -> Self {
126        self.error_policy = policy;
127        self
128    }
129
130    /// Enable or disable identity-key deduplication.
131    pub fn deduplicate(mut self, deduplicate: bool) -> Self {
132        self.deduplicate = deduplicate;
133        self
134    }
135
136    /// Identity options used when deduplicating.
137    pub fn identity(mut self, identity: PathIdentityOptions) -> Self {
138        self.identity = identity;
139        self
140    }
141
142    /// Sort mode for the returned list.
143    pub fn sort(mut self, sort: SortMode) -> Self {
144        self.sort = sort;
145        self
146    }
147}
148
149/// Control flow for visitor-based traversal.
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151#[non_exhaustive]
152pub enum VisitControl {
153    /// Continue walking.
154    Continue,
155    /// Do not descend into the current directory's children.
156    SkipChildren,
157    /// Stop the walk entirely.
158    Stop,
159}
160
161/// Callback interface for directory walks.
162pub trait DirectoryVisitor {
163    /// Called for each directory entry.
164    fn visit_directory(&mut self, path: &Path, inspection: &DirectoryInspection) -> VisitControl;
165
166    /// Called when an error is encountered for `path`.
167    fn visit_error(&mut self, path: &Path, error: &PathError) -> VisitControl {
168        let _ = (path, error);
169        VisitControl::Continue
170    }
171}
172
173/// Adapter so closures can be used with [`visit_directories`].
174///
175/// Errors always continue (use a custom type for fail-fast via the visitor).
176impl<F> DirectoryVisitor for F
177where
178    F: FnMut(&Path, &DirectoryInspection) -> VisitControl,
179{
180    fn visit_directory(&mut self, path: &Path, inspection: &DirectoryInspection) -> VisitControl {
181        self(path, inspection)
182    }
183}
184
185/// Discover directories under `root` (all directories, subject to skip rules).
186///
187/// # Filesystem access
188///
189/// **Yes.** Does not follow symlinks by default. Does not use a cache.
190/// Does not spawn threads.
191pub fn discover_directories(
192    root: impl AsRef<Path>,
193    options: &DiscoveryOptions,
194) -> Result<Vec<PathBuf>, PathError> {
195    discover_where(root, options, |_, inspection| inspection.is_directory)
196}
197
198/// Discover directories for which `predicate` returns true.
199///
200/// The predicate receives the directory path and a [`DirectoryInspection`].
201/// Skip rules are applied before the predicate (except the walk root is never
202/// skipped by name rules).
203///
204/// # Filesystem access
205///
206/// **Yes.**
207pub fn discover_where(
208    root: impl AsRef<Path>,
209    options: &DiscoveryOptions,
210    mut predicate: impl FnMut(&Path, &DirectoryInspection) -> bool,
211) -> Result<Vec<PathBuf>, PathError> {
212    let root = root.as_ref();
213    reject_nul_path(root)?;
214    let root_inspection = inspect_directory(root)?;
215    if !root_inspection.exists || !root_inspection.is_directory {
216        return Err(PathError::invalid(format!(
217            "discovery root is not a directory: {}",
218            root.to_string_lossy()
219        )));
220    }
221
222    #[cfg(feature = "search")]
223    let skip_globset = build_skip_globset(&options.skip_globs)?;
224
225    let max_depth = if options.recursive {
226        options.max_depth.unwrap_or(usize::MAX)
227    } else {
228        0
229    };
230
231    let walker = WalkDir::new(root)
232        .min_depth(0)
233        .max_depth(max_depth)
234        .follow_links(options.follow_symlinks);
235
236    let mut out = Vec::new();
237    let mut seen_keys = HashSet::new();
238    let mut skip_prefixes: HashSet<PathBuf> = HashSet::new();
239
240    for item in walker {
241        if let Some(max) = options.max_entries {
242            if out.len() >= max {
243                break;
244            }
245        }
246
247        let entry = match item {
248            Ok(e) => e,
249            Err(err) => {
250                let path = err
251                    .path()
252                    .map(Path::to_path_buf)
253                    .unwrap_or_else(|| root.to_path_buf());
254                let pe = PathError::traversal(format!("{} ({})", err, path.display()));
255                match options.error_policy {
256                    TraversalErrorPolicy::FailFast => return Err(pe),
257                    TraversalErrorPolicy::SkipErrors => continue,
258                }
259            }
260        };
261
262        let path = entry.path();
263        let file_type = entry.file_type();
264        if !(file_type.is_dir() || (options.follow_symlinks && file_type.is_symlink())) {
265            continue;
266        }
267
268        // Skip children of previously skipped trees.
269        if skip_prefixes
270            .iter()
271            .any(|p| path.starts_with(p) && path != p)
272        {
273            continue;
274        }
275
276        let is_root = path == root;
277        let name = entry.file_name().to_string_lossy();
278
279        // Never skip the root merely because its name matches a skip rule.
280        if !is_root {
281            if options
282                .skip_directory_names
283                .iter()
284                .any(|n| n.as_str() == name)
285            {
286                skip_prefixes.insert(path.to_path_buf());
287                continue;
288            }
289
290            if let Ok(rel) = path.strip_prefix(root) {
291                let rel_str = rel.to_string_lossy().replace('\\', "/");
292                if options
293                    .skip_relative_prefixes
294                    .iter()
295                    .any(|p| rel_str == *p || rel_str.starts_with(&format!("{p}/")))
296                {
297                    skip_prefixes.insert(path.to_path_buf());
298                    continue;
299                }
300
301                #[cfg(feature = "search")]
302                if let Some(gs) = &skip_globset {
303                    if gs.is_match(rel_str.as_str()) || gs.is_match(name.as_ref()) {
304                        skip_prefixes.insert(path.to_path_buf());
305                        continue;
306                    }
307                }
308            }
309        }
310
311        let inspection = match inspect_directory(path) {
312            Ok(i) => i,
313            Err(e) => match options.error_policy {
314                TraversalErrorPolicy::FailFast => return Err(e),
315                TraversalErrorPolicy::SkipErrors => continue,
316            },
317        };
318
319        if !inspection.is_directory {
320            continue;
321        }
322
323        if !predicate(path, &inspection) {
324            continue;
325        }
326
327        if options.deduplicate {
328            let key = path_identity_key(path, options.identity)?;
329            if !seen_keys.insert(key) {
330                continue;
331            }
332        }
333
334        out.push(path.to_path_buf());
335    }
336
337    sort_paths(&mut out, options.sort);
338    Ok(out)
339}
340
341/// Walk directories with a [`DirectoryVisitor`].
342///
343/// # Filesystem access
344///
345/// **Yes.**
346pub fn visit_directories(
347    root: impl AsRef<Path>,
348    options: &DiscoveryOptions,
349    visitor: &mut dyn DirectoryVisitor,
350) -> Result<(), PathError> {
351    let root = root.as_ref();
352    reject_nul_path(root)?;
353
354    let max_depth = if options.recursive {
355        options.max_depth.unwrap_or(usize::MAX)
356    } else {
357        0
358    };
359
360    let walker = WalkDir::new(root)
361        .min_depth(0)
362        .max_depth(max_depth)
363        .follow_links(options.follow_symlinks);
364
365    let mut skip_prefixes: HashSet<PathBuf> = HashSet::new();
366
367    for item in walker {
368        let entry = match item {
369            Ok(e) => e,
370            Err(err) => {
371                let path = err
372                    .path()
373                    .map(Path::to_path_buf)
374                    .unwrap_or_else(|| root.to_path_buf());
375                let pe = PathError::traversal(format!("{err}"));
376                match visitor.visit_error(&path, &pe) {
377                    VisitControl::Stop => return Ok(()),
378                    VisitControl::SkipChildren | VisitControl::Continue => {
379                        if matches!(options.error_policy, TraversalErrorPolicy::FailFast) {
380                            return Err(pe);
381                        }
382                        continue;
383                    }
384                }
385            }
386        };
387
388        let path = entry.path();
389        if !entry.file_type().is_dir() {
390            continue;
391        }
392
393        if skip_prefixes
394            .iter()
395            .any(|p| path.starts_with(p) && path != p)
396        {
397            continue;
398        }
399
400        let is_root = path == root;
401        if !is_root {
402            let name = entry.file_name().to_string_lossy();
403            if options
404                .skip_directory_names
405                .iter()
406                .any(|n| n.as_str() == name)
407            {
408                skip_prefixes.insert(path.to_path_buf());
409                continue;
410            }
411        }
412
413        let inspection = match inspect_directory(path) {
414            Ok(i) => i,
415            Err(e) => match visitor.visit_error(path, &e) {
416                VisitControl::Stop => return Ok(()),
417                _ if matches!(options.error_policy, TraversalErrorPolicy::FailFast) => {
418                    return Err(e);
419                }
420                _ => continue,
421            },
422        };
423
424        match visitor.visit_directory(path, &inspection) {
425            VisitControl::Continue => {}
426            VisitControl::SkipChildren => {
427                skip_prefixes.insert(path.to_path_buf());
428            }
429            VisitControl::Stop => return Ok(()),
430        }
431    }
432
433    Ok(())
434}
435
436fn sort_paths(paths: &mut [PathBuf], mode: SortMode) {
437    match mode {
438        SortMode::None => {}
439        SortMode::Path | SortMode::DirsFirst => {
440            paths.sort_by(|a, b| a.to_string_lossy().cmp(&b.to_string_lossy()));
441        }
442        SortMode::Name => {
443            paths.sort_by(|a, b| {
444                let an = a
445                    .file_name()
446                    .map(|n| n.to_string_lossy())
447                    .unwrap_or_default();
448                let bn = b
449                    .file_name()
450                    .map(|n| n.to_string_lossy())
451                    .unwrap_or_default();
452                an.cmp(&bn)
453                    .then_with(|| a.to_string_lossy().cmp(&b.to_string_lossy()))
454            });
455        }
456    }
457}
458
459#[cfg(feature = "search")]
460fn build_skip_globset(patterns: &[String]) -> Result<Option<globset::GlobSet>, PathError> {
461    if patterns.is_empty() {
462        return Ok(None);
463    }
464    let mut builder = globset::GlobSetBuilder::new();
465    for p in patterns {
466        let g = globset::Glob::new(p).map_err(|e| PathError::InvalidGlob {
467            message: e.to_string(),
468        })?;
469        builder.add(g);
470    }
471    let set = builder.build().map_err(|e| PathError::InvalidGlob {
472        message: e.to_string(),
473    })?;
474    Ok(Some(set))
475}