Skip to main content

weavatrix_scan/
config.rs

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    /// High-precedence include/exclude globs using `ignore::Override` syntax.
49    pub override_rules: Vec<String>,
50    /// Controls parent, repository-exclude and global Git ignore sources.
51    pub ignore_policy: IgnorePolicy,
52    /// Match ignore patterns without ASCII case sensitivity.
53    pub ignore_case_insensitive: bool,
54    /// Skip dot-prefixed and native Windows-hidden entries unless included.
55    pub skip_hidden: bool,
56    pub standard_skips: StandardSkips,
57    pub hash_file_contents: bool,
58    pub detect_binary_files: bool,
59    /// Record typed evidence for entries excluded by policy.
60    pub evidence: EvidenceMode,
61    /// Content-inspection workers. Zero selects the available parallelism.
62    pub parallelism: usize,
63    /// Whole-scan resource bounds. All limits are disabled by default.
64    pub limits: ScanLimits,
65    /// Optional cooperative cancellation signal.
66    pub cancellation: Option<CancellationToken>,
67    /// Low-level traversal policy.
68    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    /// Replaces request-level override globs.
124    ///
125    /// Like `ignore::Override`, ordinary patterns include matching paths and
126    /// leading `!` patterns exclude them.
127    #[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    /// Disables file-content reads for the fastest metadata-only discovery.
159    ///
160    /// The resulting report does not contain content hashes and may include
161    /// binary files whose extension matches the configured filter.
162    #[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    /// Keeps only the selected manifest and warnings, without skip evidence.
170    #[must_use]
171    pub const fn selected_files_only(mut self) -> Self {
172        self.evidence = EvidenceMode::SelectedFiles;
173        self
174    }
175
176    /// Sets content-inspection workers. Zero restores automatic selection.
177    #[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}