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#[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 pub fn new(root: impl AsRef<Path>) -> Result<Self> {
47 Self::with_options(root, &ScanOptions::default())
48 }
49
50 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 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 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 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 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}