vct_core/scan/mod.rs
1//! Shared provider-scan primitives for the `usage` and `analysis` roll-ups.
2//!
3//! Both features discover the same provider sources, parse each one, and record
4//! the same candidate / parsed / failure diagnostics. This module owns the
5//! parts that do not depend on which feature is folding: the unified
6//! [`ScanDiagnostics`] result type and the dedicated scan thread pool.
7
8pub(crate) mod compact;
9pub(crate) mod descriptor;
10
11pub(crate) use compact::{
12 CompactSink, LoadedCompactSummary, fold_cached, fold_loaded, scan_cached_files,
13};
14pub(crate) use descriptor::scan_all_cached_files;
15
16use crate::models::ExtensionType;
17use anyhow::Result;
18use std::path::{Path, PathBuf};
19
20/// One independently readable source that could not be collected.
21///
22/// A source is the smallest unit this layer reads on its own: one session file,
23/// one Cursor store, or one provider database.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct ScanFailure {
26 /// Provider whose file, database, or store collection failed.
27 pub provider: ExtensionType,
28 /// File or collection root passed to the parser or database reader.
29 pub source: PathBuf,
30 /// Parser or reader error, or the reason a parsed result was rejected.
31 pub error: String,
32}
33
34/// Candidate, success, and failure counts for one scan.
35///
36/// A candidate is the smallest independently readable source. `parsed` counts
37/// candidates read successfully (including valid blank sources), not the number
38/// of sessions a database emitted.
39#[derive(Debug, Clone, Default, PartialEq, Eq)]
40pub struct ScanDiagnostics {
41 /// Number of independently readable sources discovered.
42 pub candidates: usize,
43 /// Number of candidates parsed or read successfully.
44 pub parsed: usize,
45 /// Failures in deterministic provider and source order.
46 pub failures: Vec<ScanFailure>,
47}
48
49impl ScanDiagnostics {
50 /// Whether at least one source failed or was only partially understood.
51 pub fn has_failures(&self) -> bool {
52 !self.failures.is_empty()
53 }
54
55 /// Whether candidates existed but none could be read successfully.
56 pub fn all_failed(&self) -> bool {
57 self.candidates > 0 && self.parsed == 0
58 }
59
60 /// Whether successful results coexist with one or more failures.
61 pub fn partially_failed(&self) -> bool {
62 self.parsed > 0 && self.has_failures()
63 }
64
65 /// Records one failed source without logging.
66 ///
67 /// Used for failures that are re-observed on every cached refresh (a
68 /// retained parse verdict), so logging here would append the same line each
69 /// tick. Direct discovery/read failures use [`record_hard_failure`] instead.
70 ///
71 /// [`record_hard_failure`]: Self::record_hard_failure
72 pub(crate) fn record_failure(&mut self, provider: ExtensionType, source: &Path, error: String) {
73 self.failures.push(ScanFailure {
74 provider,
75 source: source.to_path_buf(),
76 error,
77 });
78 }
79
80 /// Records a hard discovery/read failure and mirrors it to the daily log.
81 ///
82 /// For failures produced anew each scan (directory traversal, fingerprint
83 /// I/O, a hard parser error) — not the retained verdicts folded from cache —
84 /// so the log line matches one real failure per scan, as the analysis file
85 /// scan did before it moved onto the shared scanner. The log is file-only
86 /// (never stdout/stderr), so this stays TUI-safe.
87 pub(crate) fn record_hard_failure(
88 &mut self,
89 provider: ExtensionType,
90 source: &Path,
91 error: String,
92 ) {
93 log::warn!(
94 "failed to collect {} session from {}: {}",
95 provider,
96 source.display(),
97 error
98 );
99 self.record_failure(provider, source, error);
100 }
101
102 /// Sorts failures into the canonical, deterministic order:
103 /// provider scan rank, then source path, then error text.
104 pub(crate) fn finalize(&mut self) {
105 self.failures.sort_by(|left, right| {
106 left.provider
107 .scan_rank()
108 .cmp(&right.provider.scan_rank())
109 .then_with(|| left.source.cmp(&right.source))
110 .then_with(|| left.error.cmp(&right.error))
111 });
112 }
113}
114
115/// Builds the dedicated Rayon pool used by CLI scans.
116///
117/// Kept off Rayon's global pool so batch scans never contend with (or
118/// reconfigure) the process-wide thread pool.
119pub fn build_scan_pool(threads: usize) -> Result<rayon::ThreadPool> {
120 rayon::ThreadPoolBuilder::new()
121 .num_threads(threads.max(1))
122 .thread_name(|index| format!("vct-scan-{index}"))
123 .build()
124 .map_err(Into::into)
125}