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