Skip to main content

weavatrix_scan/ignore/
repository.rs

1use super::git::{gitconfig_excludes_path, resolve_git_directory};
2use super::overrides::OverrideRules;
3use super::repository_source::find_repository_root;
4use super::{
5    IgnoreRules, RepositoryMatch, RuleAction, SourceRank, match_prepared_rules, match_rules,
6    normalized_evidence_location,
7};
8use crate::config::{IgnorePolicy, ScanOptions};
9use crate::error::{Error, Result};
10use crate::hidden::is_hidden_with_hint;
11use crate::path::normalized_relative_path;
12use crate::report::{IgnoreSourceEvidence, IgnoreSourceKind, ScanWarning};
13use std::borrow::Cow;
14use std::collections::HashMap;
15use std::io;
16use std::path::{Path, PathBuf};
17
18/// A reusable, lazily cached repository ignore matcher.
19///
20/// Paths may be absolute or relative to the configured scan root. Directory
21/// rule files are loaded once and cached, which makes repeated incremental
22/// checks cheap without walking the repository again.
23#[derive(Debug, Clone)]
24pub struct RepositoryMatcher {
25    pub(super) scan_root: PathBuf,
26    pub(super) options: ScanOptions,
27    pub(super) match_root: PathBuf,
28    pub(super) scan_base: String,
29    pub(super) ignore_files: Vec<String>,
30    pub(super) case_insensitive: bool,
31    pub(super) error_policy: crate::walker::ErrorPolicy,
32    pub(super) overrides: OverrideRules,
33    pub(super) skip_hidden: bool,
34    pub(super) base_rules: IgnoreRules,
35    pub(super) directories: HashMap<PathBuf, IgnoreRules>,
36    pub(super) sources: Vec<IgnoreSourceEvidence>,
37    pub(super) warnings: Vec<ScanWarning>,
38    pub(super) portable: bool,
39}
40
41impl RepositoryMatcher {
42    /// Builds a matcher with the scanner's reproducible default policy.
43    ///
44    /// # Errors
45    ///
46    /// Returns an error when the root cannot be resolved or inspected.
47    pub fn new(root: impl AsRef<Path>) -> Result<Self> {
48        Self::with_options(root, &ScanOptions::default())
49    }
50
51    /// Builds a matcher from scanner selection options.
52    ///
53    /// # Errors
54    ///
55    /// Returns an error for an invalid root or, under `ErrorPolicy::Abort`,
56    /// when a configured ignore source cannot be read.
57    pub fn with_options(root: impl AsRef<Path>, options: &ScanOptions) -> Result<Self> {
58        let requested = root.as_ref();
59        let scan_root = requested
60            .canonicalize()
61            .map_err(|source| Error::io(requested, source))?;
62        if !scan_root.is_dir() {
63            return Err(Error::InvalidRoot(scan_root));
64        }
65        let repository_root = find_repository_root(&scan_root);
66        let git_sources_enabled = !options.ignore_policy.require_git || repository_root.is_some();
67        let match_root = if options.ignore_policy.parent_rules {
68            repository_root.clone().unwrap_or_else(|| scan_root.clone())
69        } else {
70            scan_root.clone()
71        };
72        let (overrides, override_errors, override_evidence) =
73            OverrideRules::new(&options.override_rules, options.ignore_case_insensitive);
74        let mut matcher = Self {
75            scan_base: scan_root
76                .strip_prefix(&match_root)
77                .map_or_else(|_| String::new(), normalized_relative_path),
78            scan_root,
79            options: options.clone(),
80            match_root,
81            ignore_files: options
82                .ignore_files
83                .iter()
84                .filter(|name| source_enabled(name, &options.ignore_policy, git_sources_enabled))
85                .cloned()
86                .collect(),
87            case_insensitive: options.ignore_case_insensitive,
88            error_policy: options.walk.error_policy,
89            overrides,
90            skip_hidden: options.skip_hidden,
91            base_rules: IgnoreRules::default(),
92            directories: HashMap::new(),
93            sources: override_evidence.into_iter().collect(),
94            warnings: Vec::new(),
95            portable: true,
96        };
97        matcher.handle_errors(override_errors)?;
98        matcher.load_configured_sources(
99            options,
100            repository_root.as_deref(),
101            git_sources_enabled,
102        )?;
103        let root = matcher.match_root.clone();
104        matcher.prepare_directory_inner(&root)?;
105        if matcher.scan_root != matcher.match_root {
106            let scan_root = matcher.scan_root.clone();
107            matcher.prepare_directory_inner(&scan_root)?;
108        }
109        Ok(matcher)
110    }
111
112    fn load_configured_sources(
113        &mut self,
114        options: &ScanOptions,
115        repository_root: Option<&Path>,
116        git_sources_enabled: bool,
117    ) -> Result<()> {
118        if git_sources_enabled
119            && options.ignore_policy.git_global
120            && let Some(path) = gitconfig_excludes_path(repository_root)
121        {
122            self.load_static_source(
123                &path,
124                "",
125                SourceRank::GitGlobal,
126                IgnoreSourceKind::GitGlobal,
127                "<git-global>",
128            )?;
129            self.portable &= !path.is_file();
130        }
131        if git_sources_enabled
132            && options.ignore_policy.git_exclude
133            && let Some(git_directory) = repository_root.and_then(resolve_git_directory)
134        {
135            let path = git_directory.join("info").join("exclude");
136            self.load_static_source(
137                &path,
138                "",
139                SourceRank::GitExclude,
140                IgnoreSourceKind::GitExclude,
141                ".git/info/exclude",
142            )?;
143            self.portable &= !path.is_file();
144        }
145        for path in &options.ignore_policy.explicit_files {
146            self.load_explicit_source(path)?;
147        }
148        Ok(())
149    }
150
151    fn load_explicit_source(&mut self, path: &Path) -> Result<()> {
152        let configured = if path.is_absolute() {
153            path.to_path_buf()
154        } else {
155            self.scan_root.join(path)
156        };
157        let absolute = configured.canonicalize().unwrap_or(configured);
158        let location = normalized_evidence_location(&absolute, &self.match_root);
159        let base = self
160            .scan_root
161            .strip_prefix(&self.match_root)
162            .map_or_else(|_| String::new(), normalized_relative_path);
163        self.load_static_source(
164            &absolute,
165            &base,
166            SourceRank::Explicit,
167            IgnoreSourceKind::Explicit,
168            &location,
169        )?;
170        if absolute.is_file() && !absolute.starts_with(&self.match_root) {
171            self.portable = false;
172        }
173        Ok(())
174    }
175
176    /// Returns the winning typed decision from overrides, ignore rules, and
177    /// the optional hidden-file filter.
178    ///
179    /// # Errors
180    ///
181    /// Returns an error when the path escapes the matcher root or a required
182    /// rule file cannot be read under `ErrorPolicy::Abort`.
183    pub fn matched(
184        &mut self,
185        path: impl AsRef<Path>,
186        is_directory: bool,
187    ) -> Result<RepositoryMatch> {
188        let absolute = self.absolute_path(path.as_ref())?;
189        self.ensure_scan_scope(&absolute)?;
190        let parent = absolute.parent().unwrap_or(&self.match_root).to_path_buf();
191        self.prepare_directory_inner(&parent)?;
192        let scan_relative = absolute.strip_prefix(&self.scan_root).map_err(|_| {
193            Error::io(
194                &absolute,
195                io::Error::new(io::ErrorKind::InvalidInput, "path escapes matcher root"),
196            )
197        })?;
198        Ok(self.matched_prepared(
199            &normalized_relative_path(scan_relative),
200            &parent,
201            &absolute,
202            is_directory,
203            None,
204            false,
205        ))
206    }
207
208    /// Returns whether a path is excluded by the effective selection policy.
209    ///
210    /// # Errors
211    ///
212    /// Returns an error under the same conditions as [`Self::matched`].
213    pub fn is_ignored(&mut self, path: impl AsRef<Path>, is_directory: bool) -> Result<bool> {
214        self.matched(path, is_directory)
215            .map(RepositoryMatch::is_ignored)
216    }
217
218    /// Normalizes a path and returns its lossless root-relative representation.
219    ///
220    /// Relative inputs are interpreted from this matcher's root. Absolute
221    /// inputs outside the root and paths containing parent traversal are
222    /// rejected.
223    ///
224    /// # Errors
225    ///
226    /// Returns an error when the path escapes the configured scan root.
227    pub fn normalize(&self, path: impl AsRef<Path>) -> Result<PathBuf> {
228        let absolute = self.absolute_path(path.as_ref())?;
229        self.ensure_scan_scope(&absolute)?;
230        absolute
231            .strip_prefix(&self.scan_root)
232            .map(Path::to_path_buf)
233            .map_err(|_| {
234                Error::io(
235                    &absolute,
236                    io::Error::new(io::ErrorKind::InvalidInput, "path escapes matcher root"),
237                )
238            })
239    }
240
241    /// Preloads a directory's rules for subsequent child checks.
242    ///
243    /// # Errors
244    ///
245    /// Returns an error under the same conditions as [`Self::is_ignored`].
246    pub fn prepare_directory(&mut self, directory: impl AsRef<Path>) -> Result<()> {
247        let absolute = self.absolute_path(directory.as_ref())?;
248        self.ensure_scan_scope(&absolute)?;
249        self.prepare_directory_inner(&absolute)
250    }
251
252    /// Reloads ignore inputs while preserving the configured matcher policy.
253    ///
254    /// Returns true when effective selection inputs changed.
255    ///
256    /// # Errors
257    ///
258    /// Returns an error under the same conditions as [`Self::with_options`].
259    pub fn refresh(&mut self) -> Result<bool> {
260        let refreshed = Self::with_options(&self.scan_root, &self.options)?;
261        let changed = self.sources != refreshed.sources
262            || self.portable != refreshed.portable
263            || self.warnings != refreshed.warnings;
264        *self = refreshed;
265        Ok(changed)
266    }
267
268    #[must_use]
269    pub fn root(&self) -> &Path {
270        &self.scan_root
271    }
272
273    #[must_use]
274    pub fn sources(&self) -> &[IgnoreSourceEvidence] {
275        &self.sources
276    }
277
278    #[must_use]
279    pub fn warnings(&self) -> &[ScanWarning] {
280        &self.warnings
281    }
282
283    #[must_use]
284    pub const fn portable(&self) -> bool {
285        self.portable
286    }
287
288    pub(crate) fn matched_prepared(
289        &self,
290        scan_relative: &str,
291        parent: &Path,
292        absolute: &Path,
293        is_directory: bool,
294        hidden: Option<bool>,
295        ancestors_prepared: bool,
296    ) -> RepositoryMatch {
297        let rules = self.directories.get(parent).unwrap_or(&self.base_rules);
298        self.matched_with_rules(
299            scan_relative,
300            absolute,
301            is_directory,
302            hidden,
303            rules,
304            ancestors_prepared,
305        )
306    }
307
308    pub(crate) fn prepared_rules(&self, parent: &Path) -> &IgnoreRules {
309        self.directories.get(parent).unwrap_or(&self.base_rules)
310    }
311
312    pub(crate) fn matched_with_rules(
313        &self,
314        scan_relative: &str,
315        absolute: &Path,
316        is_directory: bool,
317        hidden: Option<bool>,
318        rules: &IgnoreRules,
319        ancestors_prepared: bool,
320    ) -> RepositoryMatch {
321        let override_match = self.overrides.matched(scan_relative, is_directory);
322        if override_match != RepositoryMatch::None {
323            return override_match;
324        }
325        let candidate = if self.scan_base.is_empty() {
326            Cow::Borrowed(scan_relative)
327        } else {
328            Cow::Owned(format!("{}/{scan_relative}", self.scan_base))
329        };
330        let matched = if ancestors_prepared {
331            match_prepared_rules(&candidate, is_directory, rules)
332        } else {
333            match_rules(&candidate, is_directory, rules)
334        };
335        match matched {
336            Some(RuleAction::Ignore) => RepositoryMatch::Ignore,
337            Some(RuleAction::Include) => RepositoryMatch::Include,
338            None if self.skip_hidden && is_hidden_with_hint(absolute, hidden) => {
339                RepositoryMatch::Hidden
340            }
341            None => RepositoryMatch::None,
342        }
343    }
344}
345
346fn source_enabled(name: &str, policy: &IgnorePolicy, git_sources_enabled: bool) -> bool {
347    match name {
348        ".gitignore" => policy.git_ignore && git_sources_enabled,
349        ".ignore" => policy.dot_ignore,
350        _ => policy.custom_ignore,
351    }
352}