Skip to main content

weavatrix_scan/
config.rs

1use crate::control::CancellationToken;
2use crate::file_types::{FileTypeMatch, NamedFileTypes};
3use crate::walker::WalkOptions;
4use std::collections::BTreeSet;
5use std::ffi::OsStr;
6use std::path::Path;
7use std::time::Duration;
8
9mod ignore_policy;
10mod limits;
11mod runtime;
12mod walk;
13
14pub use ignore_policy::IgnorePolicy;
15pub use limits::ScanLimits;
16
17const DEFAULT_MAX_FILE_BYTES: u64 = 1_500_000;
18const DEFAULT_IGNORE_FILES: &[&str] = &[".gitignore", ".ignore", ".weavatrixignore"];
19const DEFAULT_SKIP_DIRECTORIES: &[&str] = &[
20    ".git",
21    ".hg",
22    ".svn",
23    ".venv",
24    "__pycache__",
25    "build",
26    "coverage",
27    "dist",
28    "node_modules",
29    "target",
30    "vendor",
31];
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum StandardSkips {
35    Enabled,
36    Disabled,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum EvidenceMode {
41    Complete,
42    SelectedFiles,
43}
44
45/// Controls how persistent content hashes are validated before reuse.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum CacheValidationPolicy {
48    /// Trust stable size, timestamp and available native identity evidence.
49    Fast,
50    /// Read a whole-file 128-bit fingerprint before reusing the prior SHA-256.
51    Strict,
52}
53
54/// Controls post-read snapshot verification for newly opened content.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum ContentValidationPolicy {
57    /// Verify the opened handle against discovery evidence before reading.
58    /// This is appropriate for latency-sensitive local search.
59    Fast,
60    /// Also re-check native file evidence after reading to reject concurrent
61    /// same-size modifications.
62    Strict,
63}
64
65/// Controls how content candidates are discovered before bounded file reads.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum ContentDiscoveryMode {
68    /// Discover candidates serially and overlap discovery with content reads.
69    ///
70    /// This keeps memory bounded independently of the number of files.
71    Streaming,
72    /// Discover candidates with the parallel walker, retain their compact path
73    /// evidence, then dispatch bounded content reads.
74    ///
75    /// This minimizes latency on large, warm repositories at the cost of
76    /// memory proportional to the number of selected files.
77    BufferedParallel,
78}
79
80#[derive(Debug, Clone)]
81#[allow(clippy::struct_excessive_bools)]
82pub struct ScanOptions {
83    pub max_file_bytes: u64,
84    pub extensions: BTreeSet<String>,
85    /// Reusable named file-pattern groups, combined with `extensions`.
86    pub file_types: NamedFileTypes,
87    pub ignore_files: Vec<String>,
88    /// High-precedence include/exclude globs using `ignore::Override` syntax.
89    pub override_rules: Vec<String>,
90    /// Controls parent, repository-exclude and global Git ignore sources.
91    pub ignore_policy: IgnorePolicy,
92    /// Match ignore patterns without ASCII case sensitivity.
93    pub ignore_case_insensitive: bool,
94    /// Skip dot-prefixed and native Windows-hidden entries unless included.
95    pub skip_hidden: bool,
96    pub standard_skips: StandardSkips,
97    pub hash_file_contents: bool,
98    pub detect_binary_files: bool,
99    /// Record typed evidence for entries excluded by policy.
100    pub evidence: EvidenceMode,
101    /// Traversal and content-inspection workers. Zero selects available
102    /// parallelism. Retained as the shared backward-compatible default.
103    pub parallelism: usize,
104    /// Optional traversal-only worker override. `Some(0)` selects available
105    /// parallelism independently of content inspection.
106    pub traversal_parallelism: Option<usize>,
107    /// Optional content-inspection worker override. `Some(0)` selects
108    /// available parallelism independently of traversal.
109    pub content_parallelism: Option<usize>,
110    /// Whole-scan resource bounds. All limits are disabled by default.
111    pub limits: ScanLimits,
112    /// Optional cooperative cancellation signal.
113    pub cancellation: Option<CancellationToken>,
114    /// Persistent hash validation policy.
115    pub cache_validation: CacheValidationPolicy,
116    /// New content-read validation policy.
117    pub content_validation: ContentValidationPolicy,
118    /// Candidate-discovery policy for content visits.
119    pub content_discovery: ContentDiscoveryMode,
120    /// Low-level traversal policy.
121    pub walk: WalkOptions,
122}
123
124impl Default for ScanOptions {
125    fn default() -> Self {
126        Self {
127            max_file_bytes: DEFAULT_MAX_FILE_BYTES,
128            extensions: BTreeSet::new(),
129            file_types: NamedFileTypes::default(),
130            ignore_files: DEFAULT_IGNORE_FILES
131                .iter()
132                .map(ToString::to_string)
133                .collect(),
134            override_rules: Vec::new(),
135            ignore_policy: IgnorePolicy::default(),
136            ignore_case_insensitive: false,
137            skip_hidden: false,
138            standard_skips: StandardSkips::Enabled,
139            hash_file_contents: true,
140            detect_binary_files: true,
141            evidence: EvidenceMode::Complete,
142            parallelism: 0,
143            traversal_parallelism: None,
144            content_parallelism: None,
145            limits: ScanLimits::default(),
146            cancellation: None,
147            cache_validation: CacheValidationPolicy::Fast,
148            content_validation: ContentValidationPolicy::Strict,
149            content_discovery: ContentDiscoveryMode::Streaming,
150            walk: WalkOptions::default().with_metadata(true),
151        }
152    }
153}
154
155impl ScanOptions {
156    #[must_use]
157    pub fn with_extensions<I, S>(mut self, extensions: I) -> Self
158    where
159        I: IntoIterator<Item = S>,
160        S: AsRef<str>,
161    {
162        self.extensions = extensions
163            .into_iter()
164            .map(|item| item.as_ref().trim_start_matches('.').to_ascii_lowercase())
165            .collect();
166        self
167    }
168
169    /// Replaces named file-type definitions and selections.
170    #[must_use]
171    pub fn with_file_types(mut self, file_types: NamedFileTypes) -> Self {
172        self.file_types = file_types;
173        self
174    }
175
176    #[must_use]
177    pub fn with_ignore_files<I, S>(mut self, names: I) -> Self
178    where
179        I: IntoIterator<Item = S>,
180        S: AsRef<str>,
181    {
182        self.ignore_files = names
183            .into_iter()
184            .map(|item| item.as_ref().to_owned())
185            .collect();
186        self
187    }
188
189    /// Replaces request-level override globs.
190    ///
191    /// Like `ignore::Override`, ordinary patterns include matching paths and
192    /// leading `!` patterns exclude them.
193    #[must_use]
194    pub fn with_override_rules<I, S>(mut self, rules: I) -> Self
195    where
196        I: IntoIterator<Item = S>,
197        S: AsRef<str>,
198    {
199        self.override_rules = rules
200            .into_iter()
201            .map(|item| item.as_ref().to_owned())
202            .collect();
203        self
204    }
205
206    #[must_use]
207    pub const fn with_ignore_case_insensitive(mut self, enabled: bool) -> Self {
208        self.ignore_case_insensitive = enabled;
209        self
210    }
211
212    #[must_use]
213    pub fn with_ignore_policy(mut self, policy: IgnorePolicy) -> Self {
214        self.ignore_policy = policy;
215        self
216    }
217
218    #[must_use]
219    pub const fn with_skip_hidden(mut self, enabled: bool) -> Self {
220        self.skip_hidden = enabled;
221        self
222    }
223
224    /// Disables file-content reads for the fastest metadata-only discovery.
225    ///
226    /// The resulting report does not contain content hashes and may include
227    /// binary files whose extension matches the configured filter.
228    #[must_use]
229    pub fn metadata_only(mut self) -> Self {
230        self.hash_file_contents = false;
231        self.detect_binary_files = false;
232        self
233    }
234
235    /// Keeps only the selected manifest and warnings, without skip evidence.
236    #[must_use]
237    pub const fn selected_files_only(mut self) -> Self {
238        self.evidence = EvidenceMode::SelectedFiles;
239        self
240    }
241
242    /// Sets the shared traversal and content worker default.
243    ///
244    /// A later traversal- or content-specific override takes precedence.
245    #[must_use]
246    pub const fn with_parallelism(mut self, parallelism: usize) -> Self {
247        self.parallelism = parallelism;
248        self
249    }
250
251    /// Sets traversal workers without changing content-inspection workers.
252    #[must_use]
253    pub const fn with_traversal_parallelism(mut self, parallelism: usize) -> Self {
254        self.traversal_parallelism = Some(parallelism);
255        self
256    }
257
258    /// Sets content-inspection workers without changing traversal workers.
259    #[must_use]
260    pub const fn with_content_parallelism(mut self, parallelism: usize) -> Self {
261        self.content_parallelism = Some(parallelism);
262        self
263    }
264
265    /// Sets candidate discovery for content visits.
266    #[must_use]
267    pub const fn with_content_discovery(mut self, mode: ContentDiscoveryMode) -> Self {
268        self.content_discovery = mode;
269        self
270    }
271
272    #[must_use]
273    pub const fn with_max_entries(mut self, max_entries: Option<u64>) -> Self {
274        self.limits.max_entries = max_entries;
275        self
276    }
277
278    #[must_use]
279    pub const fn with_max_total_bytes(mut self, max_total_bytes: Option<u64>) -> Self {
280        self.limits.max_total_bytes = max_total_bytes;
281        self
282    }
283
284    #[must_use]
285    pub const fn with_timeout(mut self, timeout: Option<Duration>) -> Self {
286        self.limits.timeout = timeout;
287        self
288    }
289
290    #[must_use]
291    pub fn with_cancellation(mut self, cancellation: CancellationToken) -> Self {
292        self.cancellation = Some(cancellation);
293        self
294    }
295
296    #[must_use]
297    pub const fn with_cache_validation(mut self, policy: CacheValidationPolicy) -> Self {
298        self.cache_validation = policy;
299        self
300    }
301
302    #[must_use]
303    pub const fn with_content_validation(mut self, policy: ContentValidationPolicy) -> Self {
304        self.content_validation = policy;
305        self
306    }
307
308    pub(crate) fn should_skip_directory(&self, name: &OsStr) -> bool {
309        self.standard_skips == StandardSkips::Enabled
310            && DEFAULT_SKIP_DIRECTORIES
311                .iter()
312                .any(|candidate| name == OsStr::new(candidate))
313    }
314
315    pub(crate) fn accepts_extension(&self, path: &Path, relative: &str) -> bool {
316        if self.extensions.is_empty() && !self.file_types.is_active() {
317            return true;
318        }
319        match self.file_types.matched(path, relative) {
320            FileTypeMatch::Include => return true,
321            FileTypeMatch::Exclude => return false,
322            FileTypeMatch::None => {}
323        }
324        if self.extensions.is_empty() && self.file_types.has_includes() {
325            return false;
326        }
327        if self.extensions.is_empty() {
328            return true;
329        }
330        let Some(extension) = path.extension().and_then(|value| value.to_str()) else {
331            return false;
332        };
333        self.contains_extension(extension)
334            || (extension.bytes().any(|byte| byte.is_ascii_uppercase())
335                && self.contains_extension(&extension.to_ascii_lowercase()))
336    }
337
338    fn contains_extension(&self, extension: &str) -> bool {
339        if self.extensions.len() <= 8 {
340            self.extensions
341                .iter()
342                .any(|candidate| candidate == extension)
343        } else {
344            self.extensions.contains(extension)
345        }
346    }
347}