1use crate::control::CancellationToken;
2use crate::walker::{ErrorPolicy, WalkOptions};
3use std::collections::BTreeSet;
4use std::ffi::OsStr;
5use std::path::Path;
6use std::time::Duration;
7
8mod ignore_policy;
9mod limits;
10
11pub use ignore_policy::IgnorePolicy;
12pub use limits::ScanLimits;
13
14const DEFAULT_MAX_FILE_BYTES: u64 = 1_500_000;
15const DEFAULT_IGNORE_FILES: &[&str] = &[".gitignore", ".ignore", ".weavatrixignore"];
16const DEFAULT_SKIP_DIRECTORIES: &[&str] = &[
17 ".git",
18 ".hg",
19 ".svn",
20 ".venv",
21 "__pycache__",
22 "build",
23 "coverage",
24 "dist",
25 "node_modules",
26 "target",
27 "vendor",
28];
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum StandardSkips {
32 Enabled,
33 Disabled,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum EvidenceMode {
38 Complete,
39 SelectedFiles,
40}
41
42#[derive(Debug, Clone)]
43#[allow(clippy::struct_excessive_bools)]
44pub struct ScanOptions {
45 pub max_file_bytes: u64,
46 pub extensions: BTreeSet<String>,
47 pub ignore_files: Vec<String>,
48 pub override_rules: Vec<String>,
50 pub ignore_policy: IgnorePolicy,
52 pub ignore_case_insensitive: bool,
54 pub skip_hidden: bool,
56 pub standard_skips: StandardSkips,
57 pub hash_file_contents: bool,
58 pub detect_binary_files: bool,
59 pub evidence: EvidenceMode,
61 pub parallelism: usize,
63 pub limits: ScanLimits,
65 pub cancellation: Option<CancellationToken>,
67 pub walk: WalkOptions,
69}
70
71impl Default for ScanOptions {
72 fn default() -> Self {
73 Self {
74 max_file_bytes: DEFAULT_MAX_FILE_BYTES,
75 extensions: BTreeSet::new(),
76 ignore_files: DEFAULT_IGNORE_FILES
77 .iter()
78 .map(ToString::to_string)
79 .collect(),
80 override_rules: Vec::new(),
81 ignore_policy: IgnorePolicy::default(),
82 ignore_case_insensitive: false,
83 skip_hidden: false,
84 standard_skips: StandardSkips::Enabled,
85 hash_file_contents: true,
86 detect_binary_files: true,
87 evidence: EvidenceMode::Complete,
88 parallelism: 0,
89 limits: ScanLimits::default(),
90 cancellation: None,
91 walk: WalkOptions::default().with_metadata(true),
92 }
93 }
94}
95
96impl ScanOptions {
97 #[must_use]
98 pub fn with_extensions<I, S>(mut self, extensions: I) -> Self
99 where
100 I: IntoIterator<Item = S>,
101 S: AsRef<str>,
102 {
103 self.extensions = extensions
104 .into_iter()
105 .map(|item| item.as_ref().trim_start_matches('.').to_ascii_lowercase())
106 .collect();
107 self
108 }
109
110 #[must_use]
111 pub fn with_ignore_files<I, S>(mut self, names: I) -> Self
112 where
113 I: IntoIterator<Item = S>,
114 S: AsRef<str>,
115 {
116 self.ignore_files = names
117 .into_iter()
118 .map(|item| item.as_ref().to_owned())
119 .collect();
120 self
121 }
122
123 #[must_use]
128 pub fn with_override_rules<I, S>(mut self, rules: I) -> Self
129 where
130 I: IntoIterator<Item = S>,
131 S: AsRef<str>,
132 {
133 self.override_rules = rules
134 .into_iter()
135 .map(|item| item.as_ref().to_owned())
136 .collect();
137 self
138 }
139
140 #[must_use]
141 pub const fn with_ignore_case_insensitive(mut self, enabled: bool) -> Self {
142 self.ignore_case_insensitive = enabled;
143 self
144 }
145
146 #[must_use]
147 pub fn with_ignore_policy(mut self, policy: IgnorePolicy) -> Self {
148 self.ignore_policy = policy;
149 self
150 }
151
152 #[must_use]
153 pub const fn with_skip_hidden(mut self, enabled: bool) -> Self {
154 self.skip_hidden = enabled;
155 self
156 }
157
158 #[must_use]
163 pub fn metadata_only(mut self) -> Self {
164 self.hash_file_contents = false;
165 self.detect_binary_files = false;
166 self
167 }
168
169 #[must_use]
171 pub const fn selected_files_only(mut self) -> Self {
172 self.evidence = EvidenceMode::SelectedFiles;
173 self
174 }
175
176 #[must_use]
178 pub const fn with_parallelism(mut self, parallelism: usize) -> Self {
179 self.parallelism = parallelism;
180 self
181 }
182
183 #[must_use]
184 pub const fn with_max_entries(mut self, max_entries: Option<u64>) -> Self {
185 self.limits.max_entries = max_entries;
186 self
187 }
188
189 #[must_use]
190 pub const fn with_max_total_bytes(mut self, max_total_bytes: Option<u64>) -> Self {
191 self.limits.max_total_bytes = max_total_bytes;
192 self
193 }
194
195 #[must_use]
196 pub const fn with_timeout(mut self, timeout: Option<Duration>) -> Self {
197 self.limits.timeout = timeout;
198 self
199 }
200
201 #[must_use]
202 pub fn with_cancellation(mut self, cancellation: CancellationToken) -> Self {
203 self.cancellation = Some(cancellation);
204 self
205 }
206
207 #[must_use]
208 pub const fn with_max_depth(mut self, max_depth: Option<usize>) -> Self {
209 self.walk.max_depth = max_depth;
210 self
211 }
212
213 #[must_use]
214 pub const fn with_min_depth(mut self, min_depth: usize) -> Self {
215 self.walk.min_depth = min_depth;
216 self
217 }
218
219 #[must_use]
220 pub const fn with_max_open(mut self, max_open: usize) -> Self {
221 self.walk.max_open = if max_open == 0 { 1 } else { max_open };
222 self
223 }
224
225 #[must_use]
226 pub const fn with_same_file_system(mut self, enabled: bool) -> Self {
227 self.walk.same_file_system = enabled;
228 self
229 }
230
231 #[must_use]
232 pub const fn with_follow_links(mut self, enabled: bool) -> Self {
233 self.walk.follow_links = enabled;
234 self
235 }
236
237 #[must_use]
238 pub const fn with_error_policy(mut self, policy: ErrorPolicy) -> Self {
239 self.walk.error_policy = policy;
240 self
241 }
242
243 pub(crate) fn should_skip_directory(&self, name: &OsStr) -> bool {
244 self.standard_skips == StandardSkips::Enabled
245 && DEFAULT_SKIP_DIRECTORIES
246 .iter()
247 .any(|candidate| name == OsStr::new(candidate))
248 }
249
250 pub(crate) fn accepts_extension(&self, path: &Path) -> bool {
251 if self.extensions.is_empty() {
252 return true;
253 }
254 let Some(extension) = path.extension().and_then(|value| value.to_str()) else {
255 return false;
256 };
257 self.contains_extension(extension)
258 || (extension.bytes().any(|byte| byte.is_ascii_uppercase())
259 && self.contains_extension(&extension.to_ascii_lowercase()))
260 }
261
262 fn contains_extension(&self, extension: &str) -> bool {
263 if self.extensions.len() <= 8 {
264 self.extensions
265 .iter()
266 .any(|candidate| candidate == extension)
267 } else {
268 self.extensions.contains(extension)
269 }
270 }
271
272 pub(crate) fn worker_count(&self, file_count: usize) -> usize {
273 if file_count == 0 {
274 return 1;
275 }
276 let requested = if self.parallelism == 0 {
277 std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get)
278 } else {
279 self.parallelism
280 };
281 requested.min(file_count.div_ceil(128)).max(1)
282 }
283
284 pub(crate) const fn walk_options(&self) -> WalkOptions {
285 let mut options = self.walk;
286 options.min_depth = 0;
287 options
288 }
289
290 pub(crate) fn effective_min_depth(&self) -> usize {
291 self.walk.max_depth.map_or(self.walk.min_depth, |maximum| {
292 self.walk.min_depth.min(maximum)
293 })
294 }
295}