1use 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#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct DiscoveryOptions {
30 pub recursive: bool,
32 pub follow_symlinks: bool,
34 pub max_depth: Option<usize>,
39 pub max_entries: Option<usize>,
41 pub skip_directory_names: Vec<String>,
43 pub skip_relative_prefixes: Vec<String>,
45 #[cfg(feature = "search")]
47 pub skip_globs: Vec<String>,
48 pub error_policy: TraversalErrorPolicy,
50 pub deduplicate: bool,
52 pub identity: PathIdentityOptions,
54 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 pub fn new() -> Self {
80 Self::default()
81 }
82
83 pub fn recursive(mut self, recursive: bool) -> Self {
85 self.recursive = recursive;
86 self
87 }
88
89 pub fn follow_symlinks(mut self, follow: bool) -> Self {
91 self.follow_symlinks = follow;
92 self
93 }
94
95 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 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 pub fn max_depth(mut self, depth: Option<usize>) -> Self {
114 self.max_depth = depth;
115 self
116 }
117
118 pub fn max_entries(mut self, max: Option<usize>) -> Self {
120 self.max_entries = max;
121 self
122 }
123
124 pub fn error_policy(mut self, policy: TraversalErrorPolicy) -> Self {
126 self.error_policy = policy;
127 self
128 }
129
130 pub fn deduplicate(mut self, deduplicate: bool) -> Self {
132 self.deduplicate = deduplicate;
133 self
134 }
135
136 pub fn identity(mut self, identity: PathIdentityOptions) -> Self {
138 self.identity = identity;
139 self
140 }
141
142 pub fn sort(mut self, sort: SortMode) -> Self {
144 self.sort = sort;
145 self
146 }
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151#[non_exhaustive]
152pub enum VisitControl {
153 Continue,
155 SkipChildren,
157 Stop,
159}
160
161pub trait DirectoryVisitor {
163 fn visit_directory(&mut self, path: &Path, inspection: &DirectoryInspection) -> VisitControl;
165
166 fn visit_error(&mut self, path: &Path, error: &PathError) -> VisitControl {
168 let _ = (path, error);
169 VisitControl::Continue
170 }
171}
172
173impl<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
185pub 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
198pub 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 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 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
341pub 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}