Skip to main content

weavatrix_scan/
scanner.rs

1use crate::cache::ScanCache;
2use crate::config::{EvidenceMode, ScanOptions};
3use crate::content::inspect_files;
4use crate::error::{Error, Result};
5use crate::ignore::RepositoryMatcher;
6use crate::parallel::dynamic;
7use crate::report::{ScanReport, ScannedFile};
8use crate::runtime::ParallelRuntime;
9use crate::scan_finalize::finalize_report;
10use crate::scan_limits::{ScanRuntime, apply_total_bytes_limit};
11use crate::walker::{ErrorPolicy, Walker};
12use std::path::{Path, PathBuf};
13use std::sync::{Arc, Mutex};
14
15mod batch;
16mod compact;
17mod content_visit;
18mod entry;
19mod stream;
20mod watch_update;
21
22use entry::{process_entry, record_walk_error, walker_error_into_scan_error};
23
24pub use compact::scan_repository_compact;
25
26pub struct Scanner {
27    root: PathBuf,
28    options: ScanOptions,
29    runtime: ParallelRuntime,
30}
31
32impl Scanner {
33    #[must_use]
34    pub fn new(root: impl Into<PathBuf>) -> Self {
35        Self {
36            root: root.into(),
37            options: ScanOptions::default(),
38            runtime: ParallelRuntime::global(),
39        }
40    }
41
42    #[must_use]
43    pub fn options(mut self, options: ScanOptions) -> Self {
44        self.options = options;
45        self
46    }
47
48    /// Selects the executor used by parallel discovery.
49    #[must_use]
50    pub fn runtime(mut self, runtime: ParallelRuntime) -> Self {
51        self.runtime = runtime;
52        self
53    }
54
55    /// Scans the configured repository root.
56    ///
57    /// # Errors
58    ///
59    /// Returns an error when the root cannot be canonicalized/read, or when a
60    /// local error occurs under `ErrorPolicy::Abort`.
61    pub fn scan(self) -> Result<ScanReport> {
62        scan_repository_with_runtime(&self.root, &self.options, None, &self.runtime)
63    }
64
65    /// Scans into a compact manifest that stores the canonical root once.
66    ///
67    /// This is the preferred report for very large repositories when callers
68    /// do not require an owned absolute path on every file record.
69    ///
70    /// # Errors
71    ///
72    /// Returns the same errors as [`Self::scan`].
73    pub fn scan_compact(self) -> Result<crate::CompactScanReport> {
74        compact::scan_repository_compact_with_runtime(&self.root, &self.options, &self.runtime)
75    }
76
77    /// Scans while reusing strong hashes from an older persistent report when
78    /// file identity, size and timestamps are unchanged.
79    ///
80    /// # Errors
81    ///
82    /// Returns the same errors as [`Self::scan`]. Reports from another root or
83    /// reports without file-version evidence are scanned without cache reuse.
84    pub fn scan_incremental(self, previous: &ScanReport) -> Result<ScanReport> {
85        let cache = previous.to_cache();
86        scan_repository_with_runtime(&self.root, &self.options, Some(&cache), &self.runtime)
87    }
88
89    /// Scans while reusing a compact, versioned local cache.
90    ///
91    /// Incompatible versions and caches belonging to another canonical root
92    /// are safely ignored.
93    ///
94    /// # Errors
95    ///
96    /// Returns the same errors as [`Self::scan`].
97    pub fn scan_cached(self, cache: &ScanCache) -> Result<ScanReport> {
98        scan_repository_with_runtime(&self.root, &self.options, Some(cache), &self.runtime)
99    }
100
101    /// Applies a watcher plan without traversing unchanged directories.
102    ///
103    /// Safe file-only plans re-match and inspect only changed paths, remove
104    /// deleted paths, merge unchanged manifest evidence, and recompute the
105    /// revision. Plans that can affect selection or directory structure fall
106    /// back to a complete scan.
107    ///
108    /// # Errors
109    ///
110    /// Returns the same errors as [`Self::scan`].
111    pub fn scan_watch_plan(
112        self,
113        previous: &ScanReport,
114        plan: &crate::WatchPlan,
115    ) -> Result<ScanReport> {
116        watch_update::scan_watch_plan(&self.root, &self.options, previous, plan, &self.runtime)
117    }
118}
119
120/// Scans a repository with default options.
121///
122/// # Errors
123///
124/// Returns an error when the root cannot be canonicalized/read, or when a local
125/// error occurs under `ErrorPolicy::Abort`.
126pub fn scan_repository(root: impl AsRef<Path>) -> Result<ScanReport> {
127    scan_repository_with_options(root.as_ref(), &ScanOptions::default(), None)
128}
129
130pub(crate) fn scan_repository_with_options(
131    root: &Path,
132    options: &ScanOptions,
133    previous: Option<&ScanCache>,
134) -> Result<ScanReport> {
135    scan_repository_with_runtime(root, options, previous, &ParallelRuntime::global())
136}
137
138pub(crate) fn scan_repository_with_runtime(
139    root: &Path,
140    options: &ScanOptions,
141    previous: Option<&ScanCache>,
142    parallel_runtime: &ParallelRuntime,
143) -> Result<ScanReport> {
144    let (mut report, runtime) = discover_repository_with_options(root, options, parallel_runtime)?;
145    let previous = previous.filter(|cache| cache.is_compatible(&report.root));
146    let inspected = inspect_files(
147        std::mem::take(&mut report.files),
148        options,
149        runtime.started,
150        previous,
151    )?;
152    report.files = inspected.files;
153    report.cache = inspected.cache;
154    report.skipped.extend(inspected.skipped);
155    if let Some(reason) = inspected.termination {
156        report.terminate(reason);
157    }
158    if !inspected.warnings.is_empty() {
159        report.complete = false;
160        report.warnings.extend(inspected.warnings);
161    }
162    finalize_report(&mut report);
163    Ok(report)
164}
165
166fn discover_repository_with_options(
167    root: &Path,
168    options: &ScanOptions,
169    parallel_runtime: &ParallelRuntime,
170) -> Result<(ScanReport, ScanRuntime)> {
171    if options.walk.root_symlink_policy == crate::RootSymlinkPolicy::Reject {
172        let metadata = std::fs::symlink_metadata(root).map_err(|source| Error::io(root, source))?;
173        if metadata.file_type().is_symlink() {
174            return Err(Error::io(
175                root,
176                std::io::Error::new(
177                    std::io::ErrorKind::InvalidInput,
178                    "root symlink rejected by policy",
179                ),
180            ));
181        }
182    }
183    let canonical = root
184        .canonicalize()
185        .map_err(|source| Error::io(root, source))?;
186    if !canonical.is_dir() {
187        return Err(Error::InvalidRoot(canonical));
188    }
189    let report = ScanReport::new(
190        canonical.clone(),
191        options.evidence == EvidenceMode::Complete,
192    );
193    let matcher = RepositoryMatcher::with_options(&canonical, options)?;
194    let runtime = ScanRuntime::new();
195    let (mut report, matcher, runtime) = if options.uses_parallel_traversal() {
196        discover_parallel(
197            &canonical,
198            options,
199            report,
200            matcher,
201            runtime,
202            parallel_runtime,
203        )?
204    } else {
205        discover_serial(&canonical, options, report, matcher, runtime)?
206    };
207
208    report.ignore_sources = matcher.sources().to_vec();
209    report.portable = matcher.portable();
210    if !matcher.warnings().is_empty() {
211        report.complete = false;
212        report.warnings.extend_from_slice(matcher.warnings());
213    }
214    if report.termination.is_none()
215        && let Some(reason) = runtime.external_termination(options)
216    {
217        report.terminate(reason);
218    }
219    apply_total_bytes_limit(&mut report, options);
220    Ok((report, runtime))
221}
222
223fn discover_serial(
224    canonical: &Path,
225    options: &ScanOptions,
226    mut report: ScanReport,
227    mut matcher: RepositoryMatcher,
228    mut runtime: ScanRuntime,
229) -> Result<(ScanReport, RepositoryMatcher, ScanRuntime)> {
230    let mut walker = Walker::with_options(canonical, options.walk_options())
231        .map_err(walker_error_into_scan_error)?;
232    loop {
233        if let Some(reason) = runtime.before_next(options) {
234            report.terminate(reason);
235            break;
236        }
237        let Some(item) = walker.next() else {
238            break;
239        };
240        runtime.record_entry();
241        match item {
242            Ok(entry) => {
243                let skip = process_entry(&entry, options, &mut report, &matcher, None)?;
244                if skip {
245                    walker.skip_current_dir();
246                } else if entry.is_dir() {
247                    matcher.prepare_directory(entry.path())?;
248                }
249            }
250            Err(error) if options.walk.error_policy == ErrorPolicy::Abort => {
251                return Err(walker_error_into_scan_error(error));
252            }
253            Err(error) => record_walk_error(&error, canonical, &mut report),
254        }
255    }
256    Ok((report, matcher, runtime))
257}
258
259struct ParallelDiscovery {
260    report: ScanReport,
261    matcher: RepositoryMatcher,
262    runtime: ScanRuntime,
263    error: Option<Error>,
264}
265
266fn discover_parallel(
267    canonical: &Path,
268    options: &ScanOptions,
269    report: ScanReport,
270    matcher: RepositoryMatcher,
271    runtime: ScanRuntime,
272    parallel_runtime: &ParallelRuntime,
273) -> Result<(ScanReport, RepositoryMatcher, ScanRuntime)> {
274    let state = Arc::new(Mutex::new(ParallelDiscovery {
275        report,
276        matcher,
277        runtime,
278        error: None,
279    }));
280    let visitor_state = Arc::clone(&state);
281    let visitor_options = options.clone();
282    let visitor_root = canonical.to_path_buf();
283    let cancellation = options.cancellation.clone().unwrap_or_default();
284    let traversal = dynamic::visit_batched(
285        canonical,
286        options.walk_options(),
287        options.traversal_workers(),
288        parallel_runtime,
289        &cancellation,
290        move |entries, errors| {
291            let mut state = visitor_state
292                .lock()
293                .expect("parallel scanner state is not poisoned");
294            let ParallelDiscovery {
295                report,
296                matcher,
297                runtime,
298                error: scan_error,
299            } = &mut *state;
300            let mut files = std::mem::take(&mut report.files);
301            let control = batch::process_parallel_batch(
302                entries,
303                errors,
304                &visitor_root,
305                &visitor_options,
306                report,
307                matcher,
308                runtime,
309                scan_error,
310                &mut files,
311                |path, relative, bytes, version| ScannedFile {
312                    absolute: path.to_path_buf(),
313                    relative,
314                    bytes,
315                    content_hash: None,
316                    content_fingerprint: None,
317                    version,
318                    binary_checked: false,
319                },
320            );
321            report.files = files;
322            control
323        },
324    );
325    if let Err(error) = traversal {
326        return Err(walker_error_into_scan_error(error));
327    }
328    let state = Arc::try_unwrap(state)
329        .ok()
330        .expect("parallel scanner visitor released shared state");
331    let mut state = state
332        .into_inner()
333        .expect("parallel scanner state is not poisoned");
334    if let Some(error) = state.error.take() {
335        return Err(error);
336    }
337    Ok((state.report, state.matcher, state.runtime))
338}