1use crate::ignore::{RepositoryMatch, RepositoryMatcher};
2use crate::scan_match::skip_kind_for_match;
3use crate::walk_platform::{FileSystemId, directory_info};
4use crate::{
5 Error, IgnoreSourceEvidence, Result, ScanOptions, ScanWarning, SkipKind, WalkEntry,
6 WalkSkipReason,
7};
8use std::fs;
9use std::path::{Component, Path, PathBuf};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13#[non_exhaustive]
14pub enum SelectionDisposition {
15 SelectedFile,
17 TraverseDirectory,
19 Skipped(SkipKind),
21 Unselected,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub struct SelectionDecision {
29 disposition: SelectionDisposition,
30 repository_match: RepositoryMatch,
31}
32
33impl SelectionDecision {
34 #[must_use]
36 pub const fn disposition(self) -> SelectionDisposition {
37 self.disposition
38 }
39
40 #[must_use]
42 pub const fn repository_match(self) -> RepositoryMatch {
43 self.repository_match
44 }
45
46 #[must_use]
48 pub const fn is_selected(self) -> bool {
49 matches!(self.disposition, SelectionDisposition::SelectedFile)
50 }
51
52 #[must_use]
54 pub const fn should_descend(self) -> bool {
55 matches!(self.disposition, SelectionDisposition::TraverseDirectory)
56 }
57
58 #[must_use]
60 pub const fn skip_kind(self) -> Option<SkipKind> {
61 match self.disposition {
62 SelectionDisposition::Skipped(kind) => Some(kind),
63 SelectionDisposition::SelectedFile
64 | SelectionDisposition::TraverseDirectory
65 | SelectionDisposition::Unselected => None,
66 }
67 }
68
69 const fn selected(repository_match: RepositoryMatch) -> Self {
70 Self {
71 disposition: SelectionDisposition::SelectedFile,
72 repository_match,
73 }
74 }
75
76 const fn directory(repository_match: RepositoryMatch) -> Self {
77 Self {
78 disposition: SelectionDisposition::TraverseDirectory,
79 repository_match,
80 }
81 }
82
83 const fn skipped(kind: SkipKind, repository_match: RepositoryMatch) -> Self {
84 Self {
85 disposition: SelectionDisposition::Skipped(kind),
86 repository_match,
87 }
88 }
89
90 const fn unselected() -> Self {
91 Self {
92 disposition: SelectionDisposition::Unselected,
93 repository_match: RepositoryMatch::None,
94 }
95 }
96}
97
98#[derive(Debug, Clone)]
109pub struct SelectionMatcher {
110 repository: RepositoryMatcher,
111 options: ScanOptions,
112 root_file_system: Option<FileSystemId>,
113}
114
115impl SelectionMatcher {
116 pub fn new(root: impl AsRef<Path>) -> Result<Self> {
123 Self::with_options(root, &ScanOptions::default())
124 }
125
126 pub fn with_options(root: impl AsRef<Path>, options: &ScanOptions) -> Result<Self> {
133 let repository = RepositoryMatcher::with_options(root, options)?;
134 let root_file_system = if options.walk.same_file_system {
135 let metadata = fs::metadata(repository.root())
136 .map_err(|source| Error::io(repository.root(), source))?;
137 Some(
138 directory_info(repository.root(), &metadata)
139 .map_err(|source| Error::io(repository.root(), source))?
140 .file_system,
141 )
142 } else {
143 None
144 };
145 Ok(Self {
146 repository,
147 options: options.clone(),
148 root_file_system,
149 })
150 }
151
152 pub fn matched(&mut self, path: impl AsRef<Path>) -> Result<SelectionDecision> {
163 let relative = self.repository.normalize(path)?;
164 let absolute = self.repository.root().join(&relative);
165 let depth = relative_depth(&relative);
166 if let Some(decision) = self.match_ancestors(&relative)? {
167 return Ok(decision);
168 }
169 let link_metadata =
170 fs::symlink_metadata(&absolute).map_err(|source| Error::io(&absolute, source))?;
171 let is_symlink = link_metadata.file_type().is_symlink();
172 if is_symlink && !self.options.walk.follow_links {
173 return Ok(SelectionDecision::skipped(
174 SkipKind::Symlink,
175 RepositoryMatch::None,
176 ));
177 }
178 let metadata = if is_symlink {
179 let canonical = absolute
180 .canonicalize()
181 .map_err(|source| Error::io(&absolute, source))?;
182 if !canonical.starts_with(self.repository.root()) {
183 return Ok(SelectionDecision::skipped(
184 SkipKind::PathEscape,
185 RepositoryMatch::None,
186 ));
187 }
188 fs::metadata(&absolute).map_err(|source| Error::io(&absolute, source))?
189 } else {
190 link_metadata
191 };
192 if metadata.is_dir()
193 && let Some(decision) = self.file_system_decision(&absolute, &metadata)?
194 {
195 return Ok(decision);
196 }
197 self.classify(
198 &absolute,
199 &relative,
200 depth,
201 metadata.is_file(),
202 metadata.is_dir(),
203 false,
204 metadata.len(),
205 None,
206 )
207 }
208
209 pub fn matched_entry(&mut self, entry: &WalkEntry) -> Result<SelectionDecision> {
216 let relative = self.repository.normalize(entry.relative_path())?;
217 let absolute = self.repository.root().join(&relative);
218 let bytes = if entry.is_file() {
219 match entry.bytes() {
220 Some(bytes) => bytes,
221 None => fs::metadata(&absolute)
222 .map_err(|source| Error::io(&absolute, source))?
223 .len(),
224 }
225 } else {
226 0
227 };
228 self.classify(
229 &absolute,
230 &relative,
231 entry.depth(),
232 entry.is_file(),
233 entry.is_dir(),
234 entry.is_symlink(),
235 bytes,
236 entry.skip_reason(),
237 )
238 }
239
240 pub fn refresh(&mut self) -> Result<bool> {
247 self.repository.refresh()
248 }
249
250 #[must_use]
252 pub fn root(&self) -> &Path {
253 self.repository.root()
254 }
255
256 #[must_use]
258 pub const fn options(&self) -> &ScanOptions {
259 &self.options
260 }
261
262 #[must_use]
264 pub fn sources(&self) -> &[IgnoreSourceEvidence] {
265 self.repository.sources()
266 }
267
268 #[must_use]
270 pub fn warnings(&self) -> &[ScanWarning] {
271 self.repository.warnings()
272 }
273
274 #[must_use]
276 pub fn portable(&self) -> bool {
277 self.repository.portable()
278 }
279
280 #[allow(clippy::too_many_arguments)]
281 fn classify(
282 &mut self,
283 absolute: &Path,
284 relative: &Path,
285 depth: usize,
286 is_file: bool,
287 is_directory: bool,
288 is_symlink: bool,
289 bytes: u64,
290 walk_skip: Option<WalkSkipReason>,
291 ) -> Result<SelectionDecision> {
292 if depth == 0 {
293 if let Some(reason) = walk_skip {
294 return Ok(SelectionDecision::skipped(
295 skip_kind(reason),
296 RepositoryMatch::None,
297 ));
298 }
299 if is_symlink && !self.options.walk.follow_links {
300 return Ok(SelectionDecision::skipped(
301 SkipKind::Symlink,
302 RepositoryMatch::None,
303 ));
304 }
305 if is_directory {
306 self.repository.prepare_directory(absolute)?;
307 return Ok(SelectionDecision::directory(RepositoryMatch::None));
308 }
309 if is_file {
310 return self.classify_file(absolute, relative, bytes);
311 }
312 return Ok(SelectionDecision::unselected());
313 }
314 if depth < self.options.effective_min_depth() && !is_directory {
315 return Ok(SelectionDecision::unselected());
316 }
317 if is_symlink && !self.options.walk.follow_links {
318 return Ok(SelectionDecision::skipped(
319 SkipKind::Symlink,
320 RepositoryMatch::None,
321 ));
322 }
323 if let Some(reason) = walk_skip {
324 return Ok(SelectionDecision::skipped(
325 skip_kind(reason),
326 RepositoryMatch::None,
327 ));
328 }
329 if self
330 .options
331 .walk
332 .max_depth
333 .is_some_and(|maximum| depth > maximum || (is_directory && depth == maximum))
334 {
335 return Ok(SelectionDecision::skipped(
336 SkipKind::MaxDepth,
337 RepositoryMatch::None,
338 ));
339 }
340 if is_directory {
341 let matched = self.repository.matched(absolute, true)?;
342 if let Some(kind) = skip_kind_for_match(matched) {
343 return Ok(SelectionDecision::skipped(kind, matched));
344 }
345 if matched != RepositoryMatch::OverrideInclude
346 && self
347 .options
348 .should_skip_directory(absolute.file_name().unwrap_or(absolute.as_os_str()))
349 {
350 return Ok(SelectionDecision::skipped(
351 SkipKind::StandardDirectory,
352 matched,
353 ));
354 }
355 self.repository.prepare_directory(absolute)?;
356 return Ok(SelectionDecision::directory(matched));
357 }
358 if is_file {
359 return self.classify_file(absolute, relative, bytes);
360 }
361 Ok(SelectionDecision::unselected())
362 }
363
364 fn classify_file(
365 &mut self,
366 absolute: &Path,
367 relative: &Path,
368 bytes: u64,
369 ) -> Result<SelectionDecision> {
370 let matched = self.repository.matched(absolute, false)?;
371 if let Some(kind) = skip_kind_for_match(matched) {
372 return Ok(SelectionDecision::skipped(kind, matched));
373 }
374 let normalized = crate::path::normalized_relative_path(relative);
375 if matched != RepositoryMatch::OverrideInclude
376 && !self.options.accepts_extension(absolute, &normalized)
377 {
378 return Ok(SelectionDecision::skipped(SkipKind::Extension, matched));
379 }
380 if bytes > self.options.max_file_bytes {
381 return Ok(SelectionDecision::skipped(SkipKind::Oversized, matched));
382 }
383 Ok(SelectionDecision::selected(matched))
384 }
385
386 fn match_ancestors(&mut self, relative: &Path) -> Result<Option<SelectionDecision>> {
387 let Some(parent) = relative.parent() else {
388 return Ok(None);
389 };
390 let mut current = PathBuf::new();
391 for component in parent.components() {
392 let Component::Normal(name) = component else {
393 continue;
394 };
395 current.push(name);
396 let depth = relative_depth(¤t);
397 if self
398 .options
399 .walk
400 .max_depth
401 .is_some_and(|maximum| depth >= maximum)
402 {
403 return Ok(Some(SelectionDecision::skipped(
404 SkipKind::MaxDepth,
405 RepositoryMatch::None,
406 )));
407 }
408 let absolute = self.repository.root().join(¤t);
409 let link_metadata =
410 fs::symlink_metadata(&absolute).map_err(|source| Error::io(&absolute, source))?;
411 let is_symlink = link_metadata.file_type().is_symlink();
412 if is_symlink && !self.options.walk.follow_links {
413 return Ok(Some(SelectionDecision::skipped(
414 SkipKind::Symlink,
415 RepositoryMatch::None,
416 )));
417 }
418 let metadata = if is_symlink {
419 let canonical = absolute
420 .canonicalize()
421 .map_err(|source| Error::io(&absolute, source))?;
422 if !canonical.starts_with(self.repository.root()) {
423 return Ok(Some(SelectionDecision::skipped(
424 SkipKind::PathEscape,
425 RepositoryMatch::None,
426 )));
427 }
428 fs::metadata(&absolute).map_err(|source| Error::io(&absolute, source))?
429 } else {
430 link_metadata
431 };
432 if !metadata.is_dir() {
433 return Ok(Some(SelectionDecision::unselected()));
434 }
435 if let Some(decision) = self.file_system_decision(&absolute, &metadata)? {
436 return Ok(Some(decision));
437 }
438 let matched = self.repository.matched(&absolute, true)?;
439 if let Some(kind) = skip_kind_for_match(matched) {
440 return Ok(Some(SelectionDecision::skipped(kind, matched)));
441 }
442 if matched != RepositoryMatch::OverrideInclude
443 && self.options.should_skip_directory(name)
444 {
445 return Ok(Some(SelectionDecision::skipped(
446 SkipKind::StandardDirectory,
447 matched,
448 )));
449 }
450 self.repository.prepare_directory(&absolute)?;
451 }
452 Ok(None)
453 }
454
455 fn file_system_decision(
456 &self,
457 absolute: &Path,
458 metadata: &fs::Metadata,
459 ) -> Result<Option<SelectionDecision>> {
460 let Some(root_file_system) = self.root_file_system else {
461 return Ok(None);
462 };
463 let current = directory_info(absolute, metadata)
464 .map_err(|source| Error::io(absolute, source))?
465 .file_system;
466 Ok((current != root_file_system).then(|| {
467 SelectionDecision::skipped(SkipKind::FileSystemBoundary, RepositoryMatch::None)
468 }))
469 }
470}
471
472fn relative_depth(path: &Path) -> usize {
473 path.components()
474 .filter(|component| matches!(component, Component::Normal(_)))
475 .count()
476}
477
478const fn skip_kind(reason: WalkSkipReason) -> SkipKind {
479 match reason {
480 WalkSkipReason::MaxDepth => SkipKind::MaxDepth,
481 WalkSkipReason::FileSystemBoundary => SkipKind::FileSystemBoundary,
482 WalkSkipReason::PathEscape => SkipKind::PathEscape,
483 WalkSkipReason::SymlinkLoop => SkipKind::SymlinkLoop,
484 }
485}