Skip to main content

file_engine/analysis/
builder.rs

1use std::path::PathBuf;
2use std::time::{Instant, SystemTime};
3
4use tokio_util::sync::CancellationToken;
5
6use crate::error::Result;
7
8use super::error_strategy::AnalysisErrorStrategy;
9use super::filter::AnalysisFilter;
10use super::handle::AnalysisHandle;
11use super::progress::AnalysisProgressReporter;
12use super::report::{AnalysisReport, DEFAULT_MAX_REPORTED_ERRORS};
13use super::util::default_concurrency;
14use super::walk::{walk, WalkParams};
15
16#[cfg(feature = "checksum")]
17use super::hash::detect_duplicates;
18#[cfg(feature = "checksum")]
19use super::report::DEFAULT_MAX_REPORTED_DUPLICATE_GROUPS;
20
21/// Default cap on `largest_files` — see `AnalyzeBuilder::top_n_largest`.
22pub const DEFAULT_TOP_N_LARGEST: usize = 10;
23
24pub struct AnalyzeBuilder {
25    root: PathBuf,
26    filter: AnalysisFilter,
27    error_strategy: AnalysisErrorStrategy,
28    max_depth: Option<usize>,
29    follow_symlinks: bool,
30    top_n_largest: usize,
31    detect_mime_types: bool,
32    max_reported_errors: usize,
33    walk_concurrency: Option<usize>,
34    estimate_total: bool,
35    #[cfg(feature = "checksum")]
36    detect_duplicates: bool,
37    #[cfg(feature = "checksum")]
38    hash_concurrency: Option<usize>,
39    #[cfg(feature = "checksum")]
40    max_reported_duplicates: usize,
41}
42
43impl AnalyzeBuilder {
44    pub(crate) fn new(root: impl Into<PathBuf>) -> Self {
45        Self {
46            root: root.into(),
47            filter: AnalysisFilter::default(),
48            error_strategy: AnalysisErrorStrategy::default(),
49            max_depth: None,
50            follow_symlinks: false,
51            top_n_largest: DEFAULT_TOP_N_LARGEST,
52            detect_mime_types: false,
53            max_reported_errors: DEFAULT_MAX_REPORTED_ERRORS,
54            walk_concurrency: None,
55            estimate_total: false,
56            #[cfg(feature = "checksum")]
57            detect_duplicates: false,
58            #[cfg(feature = "checksum")]
59            hash_concurrency: None,
60            #[cfg(feature = "checksum")]
61            max_reported_duplicates: DEFAULT_MAX_REPORTED_DUPLICATE_GROUPS,
62        }
63    }
64
65    /// Only files with one of these extensions (case-insensitive,
66    /// without the leading dot) are matched. Unset matches any
67    /// extension, including files with none.
68    ///
69    /// Applied only after a file is already stat'd — unlike
70    /// `.exclude_globs()`, this never prunes traversal. To skip a whole
71    /// subtree without walking it, use `.exclude_globs()` instead.
72    pub fn extensions(mut self, exts: impl IntoIterator<Item = impl Into<String>>) -> Self {
73        self.filter.extensions = Some(exts.into_iter().map(Into::into).collect());
74        self
75    }
76
77    /// Glob patterns (matched against the path relative to the analyzed
78    /// root) that prune traversal entirely — an excluded directory is
79    /// never descended into, not merely omitted from the report. The
80    /// only filter here that actually skips the underlying I/O, rather
81    /// than filtering after a file has already been stat'd.
82    pub fn exclude_globs(mut self, patterns: impl IntoIterator<Item = impl Into<String>>) -> Self {
83        self.filter.exclude_patterns = patterns.into_iter().map(Into::into).collect();
84        self
85    }
86
87    /// Applied only after a file is already stat'd — unlike
88    /// `.exclude_globs()`, this never prunes traversal. To skip a whole
89    /// subtree without walking it, use `.exclude_globs()` instead.
90    pub fn min_size(mut self, bytes: u64) -> Self {
91        self.filter.min_size = Some(bytes);
92        self
93    }
94
95    /// Applied only after a file is already stat'd — unlike
96    /// `.exclude_globs()`, this never prunes traversal. To skip a whole
97    /// subtree without walking it, use `.exclude_globs()` instead.
98    pub fn max_size(mut self, bytes: u64) -> Self {
99        self.filter.max_size = Some(bytes);
100        self
101    }
102
103    /// Only files modified at or after `t` are matched. A file with no
104    /// readable modified time never matches once this is set.
105    ///
106    /// Applied only after a file is already stat'd — unlike
107    /// `.exclude_globs()`, this never prunes traversal. To skip a whole
108    /// subtree without walking it, use `.exclude_globs()` instead.
109    pub fn modified_after(mut self, t: SystemTime) -> Self {
110        self.filter.modified_after = Some(t);
111        self
112    }
113
114    /// Only files modified at or before `t` are matched. A file with no
115    /// readable modified time never matches once this is set.
116    ///
117    /// Applied only after a file is already stat'd — unlike
118    /// `.exclude_globs()`, this never prunes traversal. To skip a whole
119    /// subtree without walking it, use `.exclude_globs()` instead.
120    pub fn modified_before(mut self, t: SystemTime) -> Self {
121        self.filter.modified_before = Some(t);
122        self
123    }
124
125    /// Bounds how far the walk descends: the analyzed root is depth 0,
126    /// its immediate children depth 1, and so on — passed straight
127    /// through to `jwalk`'s own `max_depth`, which prunes traversal past
128    /// the bound rather than filtering after the fact.
129    pub fn max_depth(mut self, depth: usize) -> Self {
130        self.max_depth = Some(depth);
131        self
132    }
133
134    /// Off by default. When enabled, `jwalk`'s own loop detection
135    /// surfaces a symlink cycle as a per-entry error, handled like any
136    /// other error via `.on_error()`.
137    pub fn follow_symlinks(mut self, follow: bool) -> Self {
138        self.follow_symlinks = follow;
139        self
140    }
141
142    /// How many worker threads `jwalk` uses to read directories and stat
143    /// entries concurrently while walking. Defaults to
144    /// `available_parallelism()`, matching `hash_concurrency`.
145    ///
146    /// Traversal is still reported (and aggregated into `AnalysisReport`)
147    /// in a single stream on the calling task — this only parallelizes
148    /// the underlying directory reads and `stat` calls, which is where
149    /// wall time actually goes on large trees or slow/network
150    /// filesystems.
151    pub fn walk_concurrency(mut self, n: usize) -> Self {
152        self.walk_concurrency = Some(n);
153        self
154    }
155
156    /// Off by default. When enabled, `.start()` walks the tree twice:
157    /// once to count how many files match the configured filters, then
158    /// again to actually build the report. That first pass reports its
159    /// result as `AnalysisProgress::Started`'s `estimated_entries`
160    /// before any `EntryAnalyzed` events, so a caller can render a
161    /// determinate progress bar or ETA — at the cost of stat-ing every
162    /// file in the tree twice.
163    pub fn estimate_total(mut self, enable: bool) -> Self {
164        self.estimate_total = enable;
165        self
166    }
167
168    /// How many of the largest matched files to keep in
169    /// `AnalysisReport::largest_files`. `0` disables the tracking
170    /// entirely.
171    pub fn top_n_largest(mut self, n: usize) -> Self {
172        self.top_n_largest = n;
173        self
174    }
175
176    /// Off by default — sniffing every matched file's header via `infer`
177    /// is an extra read per file, on top of the metadata `walkdir`
178    /// already reads for every entry.
179    pub fn detect_mime_types(mut self, enable: bool) -> Self {
180        self.detect_mime_types = enable;
181        self
182    }
183
184    pub fn on_error(mut self, strategy: AnalysisErrorStrategy) -> Self {
185        self.error_strategy = strategy;
186        self
187    }
188
189    /// Caps `AnalysisReport::errors`; `AnalysisReport::errors_total`
190    /// stays uncapped regardless of this setting.
191    pub fn max_reported_errors(mut self, n: usize) -> Self {
192        self.max_reported_errors = n;
193        self
194    }
195
196    /// Off by default — hashing every matched file (even the pre-filtered
197    /// size-collision candidates) is real I/O on top of the walk itself.
198    #[cfg(feature = "checksum")]
199    pub fn detect_duplicates(mut self, enable: bool) -> Self {
200        self.detect_duplicates = enable;
201        self
202    }
203
204    /// Defaults to `available_parallelism()`, matching
205    /// `CopyBuilder::batch_concurrency`.
206    #[cfg(feature = "checksum")]
207    pub fn hash_concurrency(mut self, n: usize) -> Self {
208        self.hash_concurrency = Some(n);
209        self
210    }
211
212    /// Caps `AnalysisReport::duplicates`; `duplicate_groups_total` and
213    /// `duplicate_bytes_wasted` stay uncapped regardless of this setting.
214    #[cfg(feature = "checksum")]
215    pub fn max_reported_duplicates(mut self, n: usize) -> Self {
216        self.max_reported_duplicates = n;
217        self
218    }
219
220    pub fn start(self) -> Result<AnalysisHandle> {
221        let cancel = CancellationToken::new();
222        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
223        let reporter = AnalysisProgressReporter::new(tx);
224        let cancel_for_task = cancel.clone();
225
226        #[cfg(feature = "checksum")]
227        let detect_duplicates_enabled = self.detect_duplicates;
228
229        let params = WalkParams {
230            root: self.root,
231            filter: self.filter,
232            error_strategy: self.error_strategy,
233            max_depth: self.max_depth,
234            follow_symlinks: self.follow_symlinks,
235            top_n_largest: self.top_n_largest,
236            detect_mime_types: self.detect_mime_types,
237            #[cfg(feature = "checksum")]
238            collect_duplicate_candidates: detect_duplicates_enabled,
239            max_reported_errors: self.max_reported_errors,
240            walk_concurrency: self.walk_concurrency.unwrap_or_else(default_concurrency),
241            estimate_total: self.estimate_total,
242        };
243
244        #[cfg(feature = "checksum")]
245        let error_strategy = self.error_strategy;
246        #[cfg(feature = "checksum")]
247        let hash_concurrency = self.hash_concurrency;
248        #[cfg(feature = "checksum")]
249        let max_reported_duplicates = self.max_reported_duplicates;
250        #[cfg(feature = "checksum")]
251        let max_reported_errors = self.max_reported_errors;
252
253        let join_handle = tokio::spawn(async move {
254            let started = Instant::now();
255            let outcome = walk(params, cancel_for_task.clone(), reporter.clone()).await?;
256
257            #[cfg(feature = "checksum")]
258            let (duplicates, duplicate_groups_total, duplicate_bytes_wasted, errors, errors_total) = {
259                let mut errors = outcome.errors;
260                let mut errors_total = outcome.errors_total;
261                if detect_duplicates_enabled {
262                    let hash_outcome = detect_duplicates(
263                        outcome.duplicate_candidates,
264                        hash_concurrency,
265                        max_reported_duplicates,
266                        max_reported_errors.saturating_sub(errors.len()),
267                        error_strategy,
268                        &cancel_for_task,
269                        &reporter,
270                    )
271                    .await?;
272                    errors.extend(hash_outcome.errors);
273                    errors_total += hash_outcome.errors_total;
274                    (
275                        hash_outcome.groups,
276                        hash_outcome.groups_total,
277                        hash_outcome.bytes_wasted,
278                        errors,
279                        errors_total,
280                    )
281                } else {
282                    (Vec::new(), 0, 0, errors, errors_total)
283                }
284            };
285            #[cfg(not(feature = "checksum"))]
286            let (errors, errors_total) = (outcome.errors, outcome.errors_total);
287
288            Ok(AnalysisReport {
289                file_count: outcome.file_count,
290                dir_count: outcome.dir_count,
291                total_size: outcome.total_size,
292                largest_files: outcome.largest_files,
293                by_extension: outcome.by_extension,
294                by_mime: outcome.by_mime,
295                age_buckets: outcome.age_buckets,
296                errors,
297                errors_total,
298                #[cfg(feature = "checksum")]
299                duplicates,
300                #[cfg(feature = "checksum")]
301                duplicate_groups_total,
302                #[cfg(feature = "checksum")]
303                duplicate_bytes_wasted,
304                duration: started.elapsed(),
305            })
306        });
307
308        Ok(AnalysisHandle::new(join_handle, rx, cancel))
309    }
310}