Skip to main content

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}