Skip to main content

weavatrix_scan/
walker.rs

1use crate::walk_platform::{
2    DirectoryIdentity, FileSystemId, PlatformDirectoryInfo, directory_info,
3};
4pub use crate::walk_types::{
5    ErrorPolicy, WalkEntry, WalkError, WalkOperation, WalkOptions, WalkSkipReason,
6};
7use std::collections::{HashSet, VecDeque};
8use std::fs::{self, FileType};
9use std::io;
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12
13#[derive(Debug)]
14pub(crate) struct PendingDirectory {
15    pub(crate) path: PathBuf,
16    pub(crate) depth: usize,
17    pub(crate) identity: Option<DirectoryIdentity>,
18}
19
20// Keeping ReadDir inline avoids one heap allocation per directory on the hot
21// traversal path; the stack is depth-bounded and buffered at max_open.
22#[allow(clippy::large_enum_variant)]
23pub(crate) enum DirectoryEntries {
24    Open(fs::ReadDir),
25    Buffered(VecDeque<io::Result<fs::DirEntry>>),
26}
27
28impl DirectoryEntries {
29    pub(crate) fn next(&mut self) -> Option<io::Result<fs::DirEntry>> {
30        match self {
31            Self::Open(entries) => entries.next(),
32            Self::Buffered(entries) => entries.pop_front(),
33        }
34    }
35
36    pub(crate) const fn is_open(&self) -> bool {
37        matches!(self, Self::Open(_))
38    }
39}
40
41pub(crate) struct DirectoryFrame {
42    pub(crate) path: PathBuf,
43    pub(crate) depth: usize,
44    pub(crate) entries: DirectoryEntries,
45    pub(crate) identity: Option<DirectoryIdentity>,
46}
47
48/// Iterative depth-first filesystem walker.
49///
50/// Paths remain native `PathBuf` values; no lossy UTF-8 conversion occurs.
51/// Open directory handles are bounded by `WalkOptions::max_open`; when a deep
52/// tree reaches the limit, the oldest remaining directory entries are buffered
53/// and its handle is closed.
54pub struct Walker {
55    pub(crate) root: Arc<PathBuf>,
56    pub(crate) root_components: usize,
57    pub(crate) root_file_type: Option<FileType>,
58    pub(crate) root_file_system: Option<FileSystemId>,
59    pub(crate) root_directory_info: Option<PlatformDirectoryInfo>,
60    pub(crate) options: WalkOptions,
61    pub(crate) frames: Vec<DirectoryFrame>,
62    pub(crate) open_handles: usize,
63    pub(crate) yield_root: bool,
64    pub(crate) pending_directory: Option<PendingDirectory>,
65    pub(crate) skip_pending_directory: bool,
66    pub(crate) active_directories: HashSet<DirectoryIdentity>,
67    pub(crate) finished: bool,
68}
69
70impl Walker {
71    /// Creates a walker with the default traversal policy.
72    ///
73    /// # Errors
74    ///
75    /// Returns an error when the root cannot be resolved or inspected.
76    pub fn new(root: impl AsRef<Path>) -> Result<Self, WalkError> {
77        Self::with_options(root, WalkOptions::default())
78    }
79
80    /// Creates a walker with an explicit traversal policy.
81    ///
82    /// # Errors
83    ///
84    /// Returns an error when the root cannot be resolved or inspected.
85    pub fn with_options(root: impl AsRef<Path>, options: WalkOptions) -> Result<Self, WalkError> {
86        let requested = root.as_ref();
87        let options = options.normalized();
88        let canonical = if options.follow_links || options.same_file_system {
89            requested.canonicalize().map_err(|source| {
90                WalkError::new(requested, 0, WalkOperation::Canonicalize, source)
91            })?
92        } else if requested.is_absolute() {
93            requested.to_path_buf()
94        } else {
95            std::env::current_dir()
96                .map_err(|source| {
97                    WalkError::new(requested, 0, WalkOperation::Canonicalize, source)
98                })?
99                .join(requested)
100        };
101        let metadata = fs::metadata(&canonical)
102            .map_err(|source| WalkError::new(&canonical, 0, WalkOperation::ReadMetadata, source))?;
103        if !metadata.is_dir() {
104            return Err(WalkError::new(
105                &canonical,
106                0,
107                WalkOperation::ReadMetadata,
108                io::Error::new(io::ErrorKind::InvalidInput, "root is not a directory"),
109            ));
110        }
111        let root_directory_info = if options.follow_links || options.same_file_system {
112            Some(directory_info(&canonical, &metadata).map_err(|source| {
113                WalkError::new(&canonical, 0, WalkOperation::ReadMetadata, source)
114            })?)
115        } else {
116            None
117        };
118        let root_file_system = if options.same_file_system {
119            root_directory_info.map(|info| info.file_system)
120        } else {
121            None
122        };
123        let root = Arc::new(canonical.clone());
124        Ok(Self {
125            root: Arc::clone(&root),
126            root_components: canonical.components().count(),
127            root_file_type: Some(metadata.file_type()),
128            root_file_system,
129            root_directory_info,
130            options,
131            frames: Vec::new(),
132            open_handles: 0,
133            yield_root: true,
134            pending_directory: None,
135            skip_pending_directory: false,
136            active_directories: HashSet::new(),
137            finished: false,
138        })
139    }
140
141    pub(crate) fn from_known_directory(
142        root: &Arc<PathBuf>,
143        directory: PathBuf,
144        depth: usize,
145        options: WalkOptions,
146    ) -> Self {
147        let options = options.normalized();
148        let root_components = root.components().count();
149        Self {
150            root: Arc::clone(root),
151            root_components,
152            root_file_type: None,
153            root_file_system: None,
154            root_directory_info: None,
155            options,
156            frames: Vec::new(),
157            open_handles: 0,
158            yield_root: false,
159            pending_directory: Some(PendingDirectory {
160                path: directory,
161                depth,
162                identity: None,
163            }),
164            skip_pending_directory: false,
165            active_directories: HashSet::new(),
166            finished: false,
167        }
168    }
169
170    #[must_use]
171    pub fn root(&self) -> &Path {
172        self.root.as_path()
173    }
174
175    #[must_use]
176    pub const fn options(&self) -> &WalkOptions {
177        &self.options
178    }
179
180    /// Prevents descent into the directory returned by the previous `next`.
181    pub fn skip_current_dir(&mut self) {
182        if self.pending_directory.is_some() {
183            self.skip_pending_directory = true;
184        }
185    }
186
187    pub(crate) fn schedule_pending_directory(&mut self) -> Option<WalkError> {
188        let pending = self.pending_directory.take()?;
189        if self.skip_pending_directory {
190            self.skip_pending_directory = false;
191            return None;
192        }
193        if self.open_handles >= self.options.max_open {
194            self.buffer_oldest_open_directory();
195        }
196        match fs::read_dir(&pending.path) {
197            Ok(entries) => {
198                if let Some(identity) = pending.identity {
199                    self.active_directories.insert(identity);
200                }
201                self.frames.push(DirectoryFrame {
202                    path: pending.path,
203                    depth: pending.depth,
204                    entries: DirectoryEntries::Open(entries),
205                    identity: pending.identity,
206                });
207                self.open_handles += 1;
208                None
209            }
210            Err(source) => Some(WalkError::new(
211                pending.path,
212                pending.depth,
213                WalkOperation::ReadDirectory,
214                source,
215            )),
216        }
217    }
218
219    fn buffer_oldest_open_directory(&mut self) {
220        let Some(index) = self.frames.iter().position(|frame| frame.entries.is_open()) else {
221            return;
222        };
223        let placeholder = DirectoryEntries::Buffered(VecDeque::new());
224        let entries = std::mem::replace(&mut self.frames[index].entries, placeholder);
225        if let DirectoryEntries::Open(entries) = entries {
226            self.frames[index].entries = DirectoryEntries::Buffered(entries.collect());
227            self.open_handles -= 1;
228        }
229    }
230}