Skip to main content

path_rs/
listing.rs

1//! Directory listing and recursive traversal.
2//!
3//! Enabled by the `listing` feature (default).
4//!
5//! # Depth model
6//!
7//! Depth is measured the same way as [`walkdir`](https://docs.rs/walkdir) and
8//! [`crate::discovery::DiscoveryOptions`]:
9//!
10//! - **Depth 0** — the walk root itself
11//! - **Depth 1** — immediate children of the root
12//! - **Depth *n*** — *n* path components below the root
13//!
14//! | `recursive` | `max_depth` | Effect |
15//! | --- | --- | --- |
16//! | `false` | ignored | Only immediate children (depth 1). Optionally include root via `include_root`. |
17//! | `true` | `None` | Unlimited depth |
18//! | `true` | `Some(n)` | Walkdir `max_depth = n` (root is depth 0) |
19//!
20//! Prefer [`list`] when you need sorted results. Prefer [`walk`] for streaming
21//! (unsorted walk order).
22
23use crate::error::PathError;
24use crate::internal::validation::{is_hidden_name, reject_nul_path};
25use crate::metadata::{EntryKind, FileEntry, SortMode, TraversalErrorPolicy, sort_entries};
26use std::fs;
27use std::path::{Path, PathBuf};
28use walkdir::{DirEntry, WalkDir};
29
30/// Options controlling directory listing and traversal.
31///
32/// # Defaults
33///
34/// - Non-recursive (immediate children only)
35/// - Do not follow symlinks
36/// - Include files and directories
37/// - Include symlinks as entries (not followed)
38/// - Exclude hidden entries
39/// - Fail-fast on errors
40/// - Sort by path
41///
42/// See module docs for the unified depth model shared with discovery.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct ListOptions {
45    /// Recurse into subdirectories.
46    pub recursive: bool,
47    /// Follow symbolic links (cycle detection is best-effort via walkdir).
48    pub follow_symlinks: bool,
49    /// Include regular files.
50    pub include_files: bool,
51    /// Include directories.
52    pub include_directories: bool,
53    /// Include symlink entries (when not followed into).
54    pub include_symlinks: bool,
55    /// Include hidden entries (dotfiles; Windows hidden attribute when available).
56    pub include_hidden: bool,
57    /// Include other entry types (sockets, devices, etc.).
58    pub include_other: bool,
59    /// Maximum walk depth when `recursive` is true (`None` = unlimited).
60    ///
61    /// Depth 0 is the root; depth 1 is immediate children. See module docs.
62    /// Ignored when `recursive` is false (only depth-1 children are listed).
63    pub max_depth: Option<usize>,
64    /// Maximum number of returned entries.
65    pub max_entries: Option<usize>,
66    /// Result sort mode (`list` only; [`walk`] always yields in walk order).
67    pub sort: SortMode,
68    /// Error handling policy.
69    pub error_policy: TraversalErrorPolicy,
70    /// When true, include the root directory itself as an entry.
71    pub include_root: bool,
72}
73
74impl Default for ListOptions {
75    fn default() -> Self {
76        Self {
77            recursive: false,
78            follow_symlinks: false,
79            include_files: true,
80            include_directories: true,
81            include_symlinks: true,
82            include_hidden: false,
83            include_other: false,
84            max_depth: None,
85            max_entries: None,
86            sort: SortMode::Path,
87            error_policy: TraversalErrorPolicy::FailFast,
88            include_root: false,
89        }
90    }
91}
92
93impl ListOptions {
94    /// Create default options (`Self::default()`).
95    pub fn new() -> Self {
96        Self::default()
97    }
98
99    /// Enable or disable recursion.
100    pub fn recursive(mut self, recursive: bool) -> Self {
101        self.recursive = recursive;
102        self
103    }
104
105    /// Follow symlinks when walking.
106    pub fn follow_symlinks(mut self, follow: bool) -> Self {
107        self.follow_symlinks = follow;
108        self
109    }
110
111    /// Include regular files.
112    pub fn include_files(mut self, include: bool) -> Self {
113        self.include_files = include;
114        self
115    }
116
117    /// Include directories.
118    pub fn include_directories(mut self, include: bool) -> Self {
119        self.include_directories = include;
120        self
121    }
122
123    /// Include symlink entries.
124    pub fn include_symlinks(mut self, include: bool) -> Self {
125        self.include_symlinks = include;
126        self
127    }
128
129    /// Include other entry types.
130    pub fn include_other(mut self, include: bool) -> Self {
131        self.include_other = include;
132        self
133    }
134
135    /// Include hidden files and directories.
136    pub fn include_hidden(mut self, include: bool) -> Self {
137        self.include_hidden = include;
138        self
139    }
140
141    /// Set maximum depth (see module depth model).
142    pub fn max_depth(mut self, depth: Option<usize>) -> Self {
143        self.max_depth = depth;
144        self
145    }
146
147    /// Set maximum number of entries.
148    pub fn max_entries(mut self, max: Option<usize>) -> Self {
149        self.max_entries = max;
150        self
151    }
152
153    /// Set sort mode (applies to [`list`], not [`walk`]).
154    pub fn sort(mut self, sort: SortMode) -> Self {
155        self.sort = sort;
156        self
157    }
158
159    /// Set error policy.
160    pub fn error_policy(mut self, policy: TraversalErrorPolicy) -> Self {
161        self.error_policy = policy;
162        self
163    }
164
165    /// Include the root directory itself.
166    pub fn include_root(mut self, include: bool) -> Self {
167        self.include_root = include;
168        self
169    }
170}
171
172/// Walkdir depth bounds for listing.
173///
174/// - Non-recursive: only depth 1 (children). Root is handled separately via `include_root`.
175/// - Recursive: depth 0..=max (or unlimited). `min_depth` is 0 when including root in the
176///   walker, else 1 when root is emitted separately or omitted.
177pub(crate) fn walk_depth_bounds(options: &ListOptions) -> (usize, usize) {
178    if options.recursive {
179        let max = options.max_depth.unwrap_or(usize::MAX);
180        let min = if options.include_root { 0 } else { 1 };
181        // If max is 0 and we only want root, min must be 0.
182        let min = min.min(max);
183        (min, max)
184    } else {
185        // Immediate children only; root is optional via include_root (emitted separately
186        // or at depth 0 when include_root is true).
187        if options.include_root { (0, 1) } else { (1, 1) }
188    }
189}
190
191/// List filesystem entries under `root` according to `options`.
192///
193/// Results are sorted when `options.sort` is not [`SortMode::None`].
194///
195/// # Filesystem access
196///
197/// **Yes.** Requires `root` to exist. Does not follow symlinks by default.
198/// Does not read file contents.
199///
200/// # Security
201///
202/// Always set `max_depth` / `max_entries` for untrusted roots.
203pub fn list(root: impl AsRef<Path>, options: &ListOptions) -> Result<Vec<FileEntry>, PathError> {
204    let mut entries = Vec::new();
205    for item in walk(root, options)? {
206        entries.push(item?);
207        if let Some(max) = options.max_entries {
208            if entries.len() >= max {
209                break;
210            }
211        }
212    }
213    sort_entries(&mut entries, options.sort);
214    // Re-apply max after sort so sorted prefix is stable when capped mid-stream.
215    if let Some(max) = options.max_entries {
216        entries.truncate(max);
217    }
218    Ok(entries)
219}
220
221/// Stream filesystem entries under `root` in walk order.
222///
223/// Unlike [`list`], this is a **lazy** iterator over the directory walk:
224/// entries are produced as the filesystem is traversed.
225///
226/// # Sorting
227///
228/// `options.sort` is **ignored**. Streaming cannot sort without buffering.
229/// Call [`list`] when deterministic ordering is required.
230///
231/// # Max entries
232///
233/// `options.max_entries` is enforced by stopping the iterator after that many
234/// yielded entries.
235///
236/// # Filesystem access
237///
238/// **Yes.** Requires `root` to exist.
239pub fn walk(root: impl AsRef<Path>, options: &ListOptions) -> Result<WalkIter, PathError> {
240    let root = root.as_ref();
241    reject_nul_path(root)?;
242    ensure_listable_root(root)?;
243
244    let (min_depth, max_depth) = walk_depth_bounds(options);
245    let walker = WalkDir::new(root)
246        .min_depth(min_depth)
247        .max_depth(max_depth)
248        .follow_links(options.follow_symlinks)
249        .same_file_system(false);
250
251    Ok(WalkIter {
252        root: root.to_path_buf(),
253        options: options.clone(),
254        inner: walker.into_iter(),
255        yielded: 0,
256    })
257}
258
259/// Streaming iterator produced by [`walk`].
260///
261/// Yields `Ok(FileEntry)` for matching entries, or `Err` according to the
262/// configured error policy (fail-fast stops; skip continues).
263pub struct WalkIter {
264    root: PathBuf,
265    options: ListOptions,
266    inner: walkdir::IntoIter,
267    yielded: usize,
268}
269
270impl Iterator for WalkIter {
271    type Item = Result<FileEntry, PathError>;
272
273    fn next(&mut self) -> Option<Self::Item> {
274        if let Some(max) = self.options.max_entries {
275            if self.yielded >= max {
276                return None;
277            }
278        }
279
280        loop {
281            if let Some(max) = self.options.max_entries {
282                if self.yielded >= max {
283                    return None;
284                }
285            }
286
287            let item = self.inner.next()?;
288            let dir_entry = match item {
289                Ok(e) => e,
290                Err(err) => match self.options.error_policy {
291                    TraversalErrorPolicy::FailFast => {
292                        return Some(Err(PathError::traversal(err.to_string())));
293                    }
294                    TraversalErrorPolicy::SkipErrors => continue,
295                },
296            };
297
298            match filter_and_build(&self.root, &dir_entry, &self.options) {
299                Ok(Some(entry)) => {
300                    self.yielded += 1;
301                    return Some(Ok(entry));
302                }
303                Ok(None) => continue,
304                Err(e) => match self.options.error_policy {
305                    TraversalErrorPolicy::FailFast => return Some(Err(e)),
306                    TraversalErrorPolicy::SkipErrors => continue,
307                },
308            }
309        }
310    }
311}
312
313fn ensure_listable_root(root: &Path) -> Result<(), PathError> {
314    let meta = fs::symlink_metadata(root).map_err(|e| PathError::filesystem(root, e))?;
315    if meta.is_dir() {
316        return Ok(());
317    }
318    // Symlink to directory is listable when metadata follows, but symlink_metadata
319    // alone is not a dir. Try followed metadata.
320    if meta.file_type().is_symlink() {
321        match fs::metadata(root) {
322            Ok(m) if m.is_dir() => return Ok(()),
323            Ok(_) => {}
324            Err(e) => return Err(PathError::filesystem(root, e)),
325        }
326    }
327    Err(PathError::invalid(format!(
328        "list root is not a directory: {}",
329        root.to_string_lossy()
330    )))
331}
332
333fn filter_and_build(
334    root: &Path,
335    dir_entry: &DirEntry,
336    options: &ListOptions,
337) -> Result<Option<FileEntry>, PathError> {
338    let path = dir_entry.path();
339    let name = dir_entry.file_name();
340
341    if name == "." || name == ".." {
342        return Ok(None);
343    }
344
345    // Never treat the walk root itself as "hidden" solely because its basename
346    // starts with `.` (common for tempfile directories like `.tmpXXXX`).
347    let is_root = path == root;
348    if !is_root && !options.include_hidden && (is_hidden_name(name) || is_windows_hidden(path)) {
349        return Ok(None);
350    }
351
352    let relative = path.strip_prefix(root).ok().map(Path::to_path_buf);
353    entry_from_dir_entry(path, relative, dir_entry, options)
354}
355
356fn entry_from_dir_entry(
357    path: &Path,
358    relative: Option<PathBuf>,
359    dir_entry: &DirEntry,
360    options: &ListOptions,
361) -> Result<Option<FileEntry>, PathError> {
362    let file_type = dir_entry.file_type();
363    let kind = if file_type.is_symlink() {
364        EntryKind::Symlink
365    } else if file_type.is_dir() {
366        EntryKind::Directory
367    } else if file_type.is_file() {
368        EntryKind::File
369    } else {
370        EntryKind::Other
371    };
372
373    if !kind_allowed(kind, options) {
374        return Ok(None);
375    }
376
377    let (size, modified, readonly) = match fs::symlink_metadata(path) {
378        Ok(meta) => {
379            let size = if meta.is_file() {
380                Some(meta.len())
381            } else {
382                None
383            };
384            let modified = meta.modified().ok();
385            let readonly = Some(meta.permissions().readonly());
386            (size, modified, readonly)
387        }
388        Err(e) => match options.error_policy {
389            TraversalErrorPolicy::FailFast => {
390                return Err(PathError::filesystem(path, e));
391            }
392            TraversalErrorPolicy::SkipErrors => (None, None, None),
393        },
394    };
395
396    Ok(Some(FileEntry {
397        path: path.to_path_buf(),
398        relative_path: relative,
399        kind,
400        size,
401        modified,
402        readonly,
403    }))
404}
405
406fn kind_allowed(kind: EntryKind, options: &ListOptions) -> bool {
407    match kind {
408        EntryKind::File => options.include_files,
409        EntryKind::Directory => options.include_directories,
410        EntryKind::Symlink => options.include_symlinks,
411        EntryKind::Other => options.include_other,
412    }
413}
414
415#[cfg(windows)]
416fn is_windows_hidden(path: &Path) -> bool {
417    use std::os::windows::fs::MetadataExt;
418    const FILE_ATTRIBUTE_HIDDEN: u32 = 0x2;
419    fs::symlink_metadata(path)
420        .map(|m| m.file_attributes() & FILE_ATTRIBUTE_HIDDEN != 0)
421        .unwrap_or(false)
422}
423
424#[cfg(not(windows))]
425fn is_windows_hidden(_path: &Path) -> bool {
426    false
427}
428
429#[cfg(feature = "async")]
430/// Async wrapper around [`list`] using `spawn_blocking`.
431pub async fn list_async(
432    root: impl AsRef<Path> + Send + 'static,
433    options: ListOptions,
434) -> Result<Vec<FileEntry>, PathError> {
435    let root = root.as_ref().to_path_buf();
436    tokio::task::spawn_blocking(move || list(root, &options))
437        .await
438        .map_err(|e| PathError::traversal(format!("async listing join failed: {e}")))?
439}