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::WalkControl;
7use crate::parallel::dynamic::{self, BatchControl};
8use crate::report::ScanReport;
9use crate::runtime::ParallelRuntime;
10use crate::scan_finalize::finalize_report;
11use crate::scan_limits::{ScanRuntime, apply_total_bytes_limit};
12use crate::walker::{ErrorPolicy, Walker};
13use std::path::{Path, PathBuf};
14use std::sync::{Arc, Mutex};
15
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                if process_entry(&entry, options, &mut report, &mut matcher)? {
244                    walker.skip_current_dir();
245                }
246            }
247            Err(error) if options.walk.error_policy == ErrorPolicy::Abort => {
248                return Err(walker_error_into_scan_error(error));
249            }
250            Err(error) => record_walk_error(&error, canonical, &mut report),
251        }
252    }
253    Ok((report, matcher, runtime))
254}
255
256struct ParallelDiscovery {
257    report: ScanReport,
258    matcher: RepositoryMatcher,
259    runtime: ScanRuntime,
260    error: Option<Error>,
261}
262
263fn discover_parallel(
264    canonical: &Path,
265    options: &ScanOptions,
266    report: ScanReport,
267    matcher: RepositoryMatcher,
268    runtime: ScanRuntime,
269    parallel_runtime: &ParallelRuntime,
270) -> Result<(ScanReport, RepositoryMatcher, ScanRuntime)> {
271    let state = Arc::new(Mutex::new(ParallelDiscovery {
272        report,
273        matcher,
274        runtime,
275        error: None,
276    }));
277    let visitor_state = Arc::clone(&state);
278    let visitor_options = options.clone();
279    let visitor_root = canonical.to_path_buf();
280    let cancellation = options.cancellation.clone().unwrap_or_default();
281    let traversal = dynamic::visit_batched(
282        canonical,
283        options.walk_options(),
284        options.traversal_workers(),
285        parallel_runtime,
286        &cancellation,
287        move |entries, errors| {
288            let mut state = visitor_state
289                .lock()
290                .expect("parallel scanner state is not poisoned");
291            let mut controls = Vec::with_capacity(entries.len());
292            let mut quit = state.error.is_some();
293            for entry in entries {
294                if quit {
295                    controls.push(WalkControl::Quit);
296                    continue;
297                }
298                if let Some(reason) = state.runtime.before_next(&visitor_options) {
299                    state.report.terminate(reason);
300                    controls.push(WalkControl::Quit);
301                    quit = true;
302                    continue;
303                }
304                state.runtime.record_entry();
305                let ParallelDiscovery {
306                    report, matcher, ..
307                } = &mut *state;
308                match process_entry(entry, &visitor_options, report, matcher) {
309                    Ok(true) => controls.push(WalkControl::Skip),
310                    Ok(false) => controls.push(WalkControl::Continue),
311                    Err(error) => {
312                        state.error = Some(error);
313                        controls.push(WalkControl::Quit);
314                        quit = true;
315                    }
316                }
317            }
318            for error in errors {
319                if quit {
320                    break;
321                }
322                if let Some(reason) = state.runtime.before_next(&visitor_options) {
323                    state.report.terminate(reason);
324                    quit = true;
325                    break;
326                }
327                state.runtime.record_entry();
328                if visitor_options.walk.error_policy == ErrorPolicy::Continue {
329                    record_walk_error(error, &visitor_root, &mut state.report);
330                }
331            }
332            BatchControl {
333                entries: controls,
334                quit,
335            }
336        },
337    );
338    if let Err(error) = traversal {
339        return Err(walker_error_into_scan_error(error));
340    }
341    let state = Arc::try_unwrap(state)
342        .ok()
343        .expect("parallel scanner visitor released shared state");
344    let mut state = state
345        .into_inner()
346        .expect("parallel scanner state is not poisoned");
347    if let Some(error) = state.error.take() {
348        return Err(error);
349    }
350    Ok((state.report, state.matcher, state.runtime))
351}