Skip to main content

weavatrix_scan/
selection.rs

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/// Final selection outcome for one filesystem entry.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13#[non_exhaustive]
14pub enum SelectionDisposition {
15    /// A regular file accepted by every configured selection filter.
16    SelectedFile,
17    /// A directory accepted for traversal.
18    TraverseDirectory,
19    /// An entry excluded by a typed scanner policy.
20    Skipped(SkipKind),
21    /// An entry suppressed without a typed skip, such as a file below
22    /// `min_depth` or a non-file filesystem object.
23    Unselected,
24}
25
26/// A complete, typed selection result.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub struct SelectionDecision {
29    disposition: SelectionDisposition,
30    repository_match: RepositoryMatch,
31}
32
33impl SelectionDecision {
34    /// Returns the final entry disposition.
35    #[must_use]
36    pub const fn disposition(self) -> SelectionDisposition {
37        self.disposition
38    }
39
40    /// Returns the winning repository/override decision, when it was reached.
41    #[must_use]
42    pub const fn repository_match(self) -> RepositoryMatch {
43        self.repository_match
44    }
45
46    /// Returns whether this entry is a selected regular file.
47    #[must_use]
48    pub const fn is_selected(self) -> bool {
49        matches!(self.disposition, SelectionDisposition::SelectedFile)
50    }
51
52    /// Returns whether traversal should descend into this directory.
53    #[must_use]
54    pub const fn should_descend(self) -> bool {
55        matches!(self.disposition, SelectionDisposition::TraverseDirectory)
56    }
57
58    /// Returns the typed exclusion reason, if the entry was skipped.
59    #[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/// Reusable matcher for the complete scanner selection policy.
99///
100/// Unlike [`RepositoryMatcher`], this applies depth, symlink, standard
101/// directory, named file-type, extension, and maximum-size policies in
102/// addition to hierarchical ignore and override rules. [`Self::matched`]
103/// performs the metadata read needed to classify a standalone path;
104/// [`Self::matched_entry`] reuses metadata already captured by a [`WalkEntry`].
105///
106/// Stateful traversal-only conditions such as symlink-cycle ancestry are
107/// reported by [`WalkEntry::skip_reason`] when `matched_entry` is used.
108#[derive(Debug, Clone)]
109pub struct SelectionMatcher {
110    repository: RepositoryMatcher,
111    options: ScanOptions,
112    root_file_system: Option<FileSystemId>,
113}
114
115impl SelectionMatcher {
116    /// Builds a matcher with [`ScanOptions::default`].
117    ///
118    /// # Errors
119    ///
120    /// Returns an error when the root or required ignore configuration cannot
121    /// be read.
122    pub fn new(root: impl AsRef<Path>) -> Result<Self> {
123        Self::with_options(root, &ScanOptions::default())
124    }
125
126    /// Builds a matcher from the same options used by [`crate::Scanner`].
127    ///
128    /// # Errors
129    ///
130    /// Returns an error when the root or required ignore configuration cannot
131    /// be read.
132    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    /// Applies the complete selection policy to one existing path.
153    ///
154    /// This convenience method performs `symlink_metadata` and, when links are
155    /// followed, target metadata. Use [`Self::matched_entry`] inside an existing
156    /// Weavatrix walk to avoid those extra reads.
157    ///
158    /// # Errors
159    ///
160    /// Returns an error for escaped paths, missing entries, metadata failures,
161    /// or required ignore-source failures under [`crate::ErrorPolicy::Abort`].
162    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    /// Applies the complete selection policy to a walker entry.
210    ///
211    /// # Errors
212    ///
213    /// Returns an error for escaped paths, metadata failures when the walker
214    /// did not collect file metadata, or required ignore-source failures.
215    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    /// Atomically reloads ignore inputs while retaining the full selection
241    /// options.
242    ///
243    /// # Errors
244    ///
245    /// Returns an error when replacement matcher construction fails.
246    pub fn refresh(&mut self) -> Result<bool> {
247        self.repository.refresh()
248    }
249
250    /// Returns the canonical matcher root.
251    #[must_use]
252    pub fn root(&self) -> &Path {
253        self.repository.root()
254    }
255
256    /// Returns the immutable selection options snapshot.
257    #[must_use]
258    pub const fn options(&self) -> &ScanOptions {
259        &self.options
260    }
261
262    /// Returns evidence for every loaded ignore source.
263    #[must_use]
264    pub fn sources(&self) -> &[IgnoreSourceEvidence] {
265        self.repository.sources()
266    }
267
268    /// Returns non-fatal matcher diagnostics.
269    #[must_use]
270    pub fn warnings(&self) -> &[ScanWarning] {
271        self.repository.warnings()
272    }
273
274    /// Returns whether all matcher inputs are portable.
275    #[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(&current);
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(&current);
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}