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::walk::{walk, WalkParams};
14
15#[cfg(feature = "checksum")]
16use super::hash::detect_duplicates;
17#[cfg(feature = "checksum")]
18use super::report::DEFAULT_MAX_REPORTED_DUPLICATE_GROUPS;
19
20/// Default cap on `largest_files` — see `AnalyzeBuilder::top_n_largest`.
21pub const DEFAULT_TOP_N_LARGEST: usize = 10;
22
23pub struct AnalyzeBuilder {
24    root: PathBuf,
25    filter: AnalysisFilter,
26    error_strategy: AnalysisErrorStrategy,
27    max_depth: Option<usize>,
28    follow_symlinks: bool,
29    top_n_largest: usize,
30    detect_mime_types: bool,
31    max_reported_errors: usize,
32    #[cfg(feature = "checksum")]
33    detect_duplicates: bool,
34    #[cfg(feature = "checksum")]
35    hash_concurrency: Option<usize>,
36    #[cfg(feature = "checksum")]
37    max_reported_duplicates: usize,
38}
39
40impl AnalyzeBuilder {
41    pub(crate) fn new(root: impl Into<PathBuf>) -> Self {
42        Self {
43            root: root.into(),
44            filter: AnalysisFilter::default(),
45            error_strategy: AnalysisErrorStrategy::default(),
46            max_depth: None,
47            follow_symlinks: false,
48            top_n_largest: DEFAULT_TOP_N_LARGEST,
49            detect_mime_types: false,
50            max_reported_errors: DEFAULT_MAX_REPORTED_ERRORS,
51            #[cfg(feature = "checksum")]
52            detect_duplicates: false,
53            #[cfg(feature = "checksum")]
54            hash_concurrency: None,
55            #[cfg(feature = "checksum")]
56            max_reported_duplicates: DEFAULT_MAX_REPORTED_DUPLICATE_GROUPS,
57        }
58    }
59
60    /// Only files with one of these extensions (case-insensitive,
61    /// without the leading dot) are matched. Unset matches any
62    /// extension, including files with none.
63    pub fn extensions(mut self, exts: impl IntoIterator<Item = impl Into<String>>) -> Self {
64        self.filter.extensions = Some(exts.into_iter().map(Into::into).collect());
65        self
66    }
67
68    /// Glob patterns (matched against the path relative to the analyzed
69    /// root) that prune traversal entirely — an excluded directory is
70    /// never descended into, not merely omitted from the report.
71    pub fn exclude_globs(mut self, patterns: impl IntoIterator<Item = impl Into<String>>) -> Self {
72        self.filter.exclude_patterns = patterns.into_iter().map(Into::into).collect();
73        self
74    }
75
76    pub fn min_size(mut self, bytes: u64) -> Self {
77        self.filter.min_size = Some(bytes);
78        self
79    }
80
81    pub fn max_size(mut self, bytes: u64) -> Self {
82        self.filter.max_size = Some(bytes);
83        self
84    }
85
86    /// Only files modified at or after `t` are matched. A file with no
87    /// readable modified time never matches once this is set.
88    pub fn modified_after(mut self, t: SystemTime) -> Self {
89        self.filter.modified_after = Some(t);
90        self
91    }
92
93    /// Only files modified at or before `t` are matched. A file with no
94    /// readable modified time never matches once this is set.
95    pub fn modified_before(mut self, t: SystemTime) -> Self {
96        self.filter.modified_before = Some(t);
97        self
98    }
99
100    /// Bounds how far the walk descends: the analyzed root is depth 0,
101    /// its immediate children depth 1, and so on — passed straight
102    /// through to `walkdir`'s own `max_depth`, which prunes traversal
103    /// past the bound rather than filtering after the fact.
104    pub fn max_depth(mut self, depth: usize) -> Self {
105        self.max_depth = Some(depth);
106        self
107    }
108
109    /// Off by default. When enabled, `walkdir`'s own loop detection
110    /// surfaces a symlink cycle as a per-entry error, handled like any
111    /// other error via `.on_error()`.
112    pub fn follow_symlinks(mut self, follow: bool) -> Self {
113        self.follow_symlinks = follow;
114        self
115    }
116
117    /// How many of the largest matched files to keep in
118    /// `AnalysisReport::largest_files`. `0` disables the tracking
119    /// entirely.
120    pub fn top_n_largest(mut self, n: usize) -> Self {
121        self.top_n_largest = n;
122        self
123    }
124
125    /// Off by default — sniffing every matched file's header via `infer`
126    /// is an extra read per file, on top of the metadata `walkdir`
127    /// already reads for every entry.
128    pub fn detect_mime_types(mut self, enable: bool) -> Self {
129        self.detect_mime_types = enable;
130        self
131    }
132
133    pub fn on_error(mut self, strategy: AnalysisErrorStrategy) -> Self {
134        self.error_strategy = strategy;
135        self
136    }
137
138    /// Caps `AnalysisReport::errors`; `AnalysisReport::errors_total`
139    /// stays uncapped regardless of this setting.
140    pub fn max_reported_errors(mut self, n: usize) -> Self {
141        self.max_reported_errors = n;
142        self
143    }
144
145    /// Off by default — hashing every matched file (even the pre-filtered
146    /// size-collision candidates) is real I/O on top of the walk itself.
147    #[cfg(feature = "checksum")]
148    pub fn detect_duplicates(mut self, enable: bool) -> Self {
149        self.detect_duplicates = enable;
150        self
151    }
152
153    /// Defaults to `available_parallelism()`, matching
154    /// `CopyBuilder::batch_concurrency`.
155    #[cfg(feature = "checksum")]
156    pub fn hash_concurrency(mut self, n: usize) -> Self {
157        self.hash_concurrency = Some(n);
158        self
159    }
160
161    /// Caps `AnalysisReport::duplicates`; `duplicate_groups_total` and
162    /// `duplicate_bytes_wasted` stay uncapped regardless of this setting.
163    #[cfg(feature = "checksum")]
164    pub fn max_reported_duplicates(mut self, n: usize) -> Self {
165        self.max_reported_duplicates = n;
166        self
167    }
168
169    pub fn start(self) -> Result<AnalysisHandle> {
170        let cancel = CancellationToken::new();
171        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
172        let reporter = AnalysisProgressReporter::new(tx);
173        let cancel_for_task = cancel.clone();
174
175        #[cfg(feature = "checksum")]
176        let detect_duplicates_enabled = self.detect_duplicates;
177
178        let params = WalkParams {
179            root: self.root,
180            filter: self.filter,
181            error_strategy: self.error_strategy,
182            max_depth: self.max_depth,
183            follow_symlinks: self.follow_symlinks,
184            top_n_largest: self.top_n_largest,
185            detect_mime_types: self.detect_mime_types,
186            #[cfg(feature = "checksum")]
187            collect_duplicate_candidates: detect_duplicates_enabled,
188            max_reported_errors: self.max_reported_errors,
189        };
190
191        #[cfg(feature = "checksum")]
192        let error_strategy = self.error_strategy;
193        #[cfg(feature = "checksum")]
194        let hash_concurrency = self.hash_concurrency;
195        #[cfg(feature = "checksum")]
196        let max_reported_duplicates = self.max_reported_duplicates;
197        #[cfg(feature = "checksum")]
198        let max_reported_errors = self.max_reported_errors;
199
200        let join_handle = tokio::spawn(async move {
201            let started = Instant::now();
202            let outcome = walk(params, cancel_for_task.clone(), reporter.clone()).await?;
203
204            #[cfg(feature = "checksum")]
205            let (duplicates, duplicate_groups_total, duplicate_bytes_wasted, errors, errors_total) = {
206                let mut errors = outcome.errors;
207                let mut errors_total = outcome.errors_total;
208                if detect_duplicates_enabled {
209                    let hash_outcome = detect_duplicates(
210                        outcome.duplicate_candidates,
211                        hash_concurrency,
212                        max_reported_duplicates,
213                        max_reported_errors.saturating_sub(errors.len()),
214                        error_strategy,
215                        &cancel_for_task,
216                        &reporter,
217                    )
218                    .await?;
219                    errors.extend(hash_outcome.errors);
220                    errors_total += hash_outcome.errors_total;
221                    (
222                        hash_outcome.groups,
223                        hash_outcome.groups_total,
224                        hash_outcome.bytes_wasted,
225                        errors,
226                        errors_total,
227                    )
228                } else {
229                    (Vec::new(), 0, 0, errors, errors_total)
230                }
231            };
232            #[cfg(not(feature = "checksum"))]
233            let (errors, errors_total) = (outcome.errors, outcome.errors_total);
234
235            Ok(AnalysisReport {
236                file_count: outcome.file_count,
237                dir_count: outcome.dir_count,
238                total_size: outcome.total_size,
239                largest_files: outcome.largest_files,
240                by_extension: outcome.by_extension,
241                by_mime: outcome.by_mime,
242                age_buckets: outcome.age_buckets,
243                errors,
244                errors_total,
245                #[cfg(feature = "checksum")]
246                duplicates,
247                #[cfg(feature = "checksum")]
248                duplicate_groups_total,
249                #[cfg(feature = "checksum")]
250                duplicate_bytes_wasted,
251                duration: started.elapsed(),
252            })
253        });
254
255        Ok(AnalysisHandle::new(join_handle, rx, cancel))
256    }
257}