Skip to main content

weavatrix_scan/
report.rs

1use std::path::PathBuf;
2
3#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub struct FileIdentity {
6    pub file_system: u64,
7    pub file: u64,
8}
9
10#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
11#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
12pub struct FileVersion {
13    #[cfg_attr(feature = "serde", serde(default))]
14    pub modified_ns: Option<u128>,
15    #[cfg_attr(feature = "serde", serde(default))]
16    pub changed_ns: Option<u128>,
17    #[cfg_attr(feature = "serde", serde(default))]
18    pub identity: Option<FileIdentity>,
19}
20
21#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct ScannedFile {
24    #[cfg_attr(feature = "serde", serde(with = "crate::path_serde"))]
25    pub absolute: PathBuf,
26    pub relative: String,
27    pub bytes: u64,
28    pub content_hash: Option<String>,
29    /// Whole-content validation fingerprint used only for strict cache reuse.
30    #[cfg_attr(feature = "serde", serde(default))]
31    pub content_fingerprint: Option<String>,
32    #[cfg_attr(feature = "serde", serde(default))]
33    pub version: FileVersion,
34    #[cfg_attr(feature = "serde", serde(default))]
35    pub binary_checked: bool,
36}
37
38/// Selected-file evidence without a duplicated absolute path allocation.
39///
40/// Join `relative` to [`CompactScanReport::root`] only when an absolute path
41/// is needed.
42#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct CompactScannedFile {
45    pub relative: Box<str>,
46    pub bytes: u64,
47    /// Allocated only when content inspection was requested.
48    #[cfg_attr(feature = "serde", serde(default))]
49    pub content: Option<Box<CompactContentEvidence>>,
50}
51
52/// Optional rich evidence kept out of metadata-only compact entries.
53#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct CompactContentEvidence {
56    pub content_hash: Option<Box<str>>,
57    /// Whole-content validation fingerprint used only for strict cache reuse.
58    #[cfg_attr(feature = "serde", serde(default))]
59    pub content_fingerprint: Option<Box<str>>,
60    #[cfg_attr(feature = "serde", serde(default))]
61    pub version: FileVersion,
62    #[cfg_attr(feature = "serde", serde(default))]
63    pub binary_checked: bool,
64}
65
66impl CompactScannedFile {
67    /// Returns the strong content hash when content hashing was requested.
68    #[must_use]
69    pub fn content_hash(&self) -> Option<&str> {
70        self.content
71            .as_deref()
72            .and_then(|content| content.content_hash.as_deref())
73    }
74}
75
76#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
77#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
78pub struct ScanCacheStats {
79    pub reused_hashes: u64,
80    pub content_reads: u64,
81    /// Whole-file fingerprint reads used to validate cached SHA-256 values.
82    #[cfg_attr(feature = "serde", serde(default))]
83    pub fingerprint_reads: u64,
84}
85
86#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
87#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
88pub enum SkipKind {
89    Binary,
90    FileSystemBoundary,
91    Extension,
92    Ignored,
93    IoError,
94    MaxDepth,
95    Oversized,
96    PathEscape,
97    StandardDirectory,
98    Hidden,
99    Override,
100    Symlink,
101    SymlinkLoop,
102    ScanLimit,
103    ConcurrentModification,
104}
105
106#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct SkippedEntry {
109    pub relative: String,
110    pub kind: SkipKind,
111    pub detail: Option<String>,
112}
113
114#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct ScanWarning {
117    pub relative: Option<String>,
118    pub message: String,
119}
120
121#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
122#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
123pub enum IgnoreSourceKind {
124    GitGlobal,
125    GitExclude,
126    GitIgnore,
127    DotIgnore,
128    Custom,
129    Explicit,
130    Override,
131}
132
133#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct IgnoreSourceEvidence {
136    pub kind: IgnoreSourceKind,
137    pub location: String,
138    pub content_hash: String,
139}
140
141#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub enum ScanTermination {
144    MaxEntries,
145    MaxTotalBytes,
146    Timeout,
147    Cancelled,
148}
149
150#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct ScanReport {
153    #[cfg_attr(feature = "serde", serde(with = "crate::path_serde"))]
154    pub root: PathBuf,
155    pub files: Vec<ScannedFile>,
156    pub skipped: Vec<SkippedEntry>,
157    pub warnings: Vec<ScanWarning>,
158    /// Every ignore input that participated in path selection.
159    #[cfg_attr(feature = "serde", serde(default))]
160    pub ignore_sources: Vec<IgnoreSourceEvidence>,
161    pub revision: String,
162    /// False when local I/O or ignore-rule errors made evidence partial.
163    #[cfg_attr(feature = "serde", serde(default = "default_complete"))]
164    pub complete: bool,
165    /// Why a bounded scan stopped before exhausting the tree.
166    #[cfg_attr(feature = "serde", serde(default))]
167    pub termination: Option<ScanTermination>,
168    /// False when selection depended on host-level configuration.
169    #[cfg_attr(feature = "serde", serde(default = "default_portable"))]
170    pub portable: bool,
171    /// Evidence of content work reused from an older persistent report.
172    #[cfg_attr(feature = "serde", serde(default))]
173    pub cache: ScanCacheStats,
174    #[cfg_attr(feature = "serde", serde(skip, default = "default_record_skipped"))]
175    record_skipped: bool,
176}
177
178/// Memory-efficient deterministic manifest retaining the root path once.
179///
180/// This report preserves scanner selection, hashes, revision, warnings and
181/// typed skip evidence while avoiding one absolute `PathBuf` per selected
182/// file.
183#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
184#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct CompactScanReport {
186    #[cfg_attr(feature = "serde", serde(with = "crate::path_serde"))]
187    pub root: PathBuf,
188    pub files: Vec<CompactScannedFile>,
189    pub skipped: Vec<SkippedEntry>,
190    pub warnings: Vec<ScanWarning>,
191    #[cfg_attr(feature = "serde", serde(default))]
192    pub ignore_sources: Vec<IgnoreSourceEvidence>,
193    pub revision: String,
194    #[cfg_attr(feature = "serde", serde(default = "default_complete"))]
195    pub complete: bool,
196    #[cfg_attr(feature = "serde", serde(default))]
197    pub termination: Option<ScanTermination>,
198    #[cfg_attr(feature = "serde", serde(default = "default_portable"))]
199    pub portable: bool,
200    #[cfg_attr(feature = "serde", serde(default))]
201    pub cache: ScanCacheStats,
202}
203
204impl CompactScanReport {
205    /// Materializes an absolute path for one compact entry.
206    #[must_use]
207    pub fn absolute_path(&self, file: &CompactScannedFile) -> PathBuf {
208        self.root.join(file.relative.as_ref())
209    }
210
211    /// Materializes the compatibility report without reading file contents
212    /// again.
213    #[must_use]
214    pub fn into_scan_report(self) -> ScanReport {
215        let root = self.root;
216        let files = self
217            .files
218            .into_iter()
219            .map(|file| {
220                let content = file.content.map(|content| *content);
221                ScannedFile {
222                    absolute: root.join(file.relative.as_ref()),
223                    relative: file.relative.into(),
224                    bytes: file.bytes,
225                    content_hash: content
226                        .as_ref()
227                        .and_then(|value| value.content_hash.as_deref())
228                        .map(str::to_owned),
229                    content_fingerprint: content
230                        .as_ref()
231                        .and_then(|value| value.content_fingerprint.as_deref())
232                        .map(str::to_owned),
233                    version: content
234                        .as_ref()
235                        .map_or_else(FileVersion::default, |value| value.version),
236                    binary_checked: content.is_some_and(|value| value.binary_checked),
237                }
238            })
239            .collect();
240        ScanReport {
241            root,
242            files,
243            skipped: self.skipped,
244            warnings: self.warnings,
245            ignore_sources: self.ignore_sources,
246            revision: self.revision,
247            complete: self.complete,
248            termination: self.termination,
249            portable: self.portable,
250            cache: self.cache,
251            record_skipped: true,
252        }
253    }
254}
255
256#[cfg(feature = "serde")]
257const fn default_complete() -> bool {
258    true
259}
260
261#[cfg(feature = "serde")]
262const fn default_record_skipped() -> bool {
263    true
264}
265
266#[cfg(feature = "serde")]
267const fn default_portable() -> bool {
268    true
269}
270
271impl ScanReport {
272    /// Computes the deterministic changed-file set from an older report.
273    #[must_use]
274    pub fn delta_from(&self, previous: &Self) -> crate::ScanDelta {
275        crate::ScanDelta::between(previous, self)
276    }
277
278    pub(crate) fn new(root: PathBuf, record_skipped: bool) -> Self {
279        Self {
280            root,
281            files: Vec::new(),
282            skipped: Vec::new(),
283            warnings: Vec::new(),
284            ignore_sources: Vec::new(),
285            revision: String::new(),
286            complete: true,
287            termination: None,
288            portable: true,
289            cache: ScanCacheStats::default(),
290            record_skipped,
291        }
292    }
293
294    pub(crate) fn skip(&mut self, relative: String, kind: SkipKind, detail: Option<String>) {
295        if self.record_skipped {
296            self.skipped.push(SkippedEntry {
297                relative,
298                kind,
299                detail,
300            });
301        }
302    }
303
304    pub(crate) fn skip_borrowed(&mut self, relative: &str, kind: SkipKind, detail: Option<String>) {
305        if self.record_skipped {
306            self.skipped.push(SkippedEntry {
307                relative: relative.to_owned(),
308                kind,
309                detail,
310            });
311        }
312    }
313
314    pub(crate) fn warn(&mut self, relative: Option<String>, message: impl Into<String>) {
315        self.complete = false;
316        self.warnings.push(ScanWarning {
317            relative,
318            message: message.into(),
319        });
320    }
321
322    pub(crate) fn terminate(&mut self, reason: ScanTermination) {
323        self.complete = false;
324        self.termination.get_or_insert(reason);
325    }
326
327    pub(crate) fn finish_recording(&mut self) {
328        self.record_skipped = true;
329    }
330}