Skip to main content

file_engine/
lib.rs

1#[cfg(feature = "analyze")]
2mod analysis;
3#[cfg(feature = "checksum")]
4mod checksum;
5mod error;
6#[cfg(feature = "operations")]
7mod eta;
8#[cfg(feature = "operations")]
9mod handle;
10// `sync`/`compress` already imply `operations` via Cargo.toml, but
11// `watch` deliberately doesn't (it never touches the
12// Profiler/Planner/Dispatcher pipeline) — this needs its own condition
13// rather than reusing the `operations` feature alone, or `watch`-only
14// builds fail to find this module at all.
15#[cfg(any(feature = "operations", feature = "watch"))]
16mod operations;
17// Also intended for `sync`'s `diff.rs` once that's wired up to use it
18// too — currently only called from `profiler::validate`.
19#[cfg(feature = "operations")]
20mod paths;
21#[cfg(feature = "operations")]
22mod planner;
23#[cfg(feature = "operations")]
24mod profiler;
25#[cfg(feature = "operations")]
26mod progress;
27#[cfg(feature = "watch")]
28mod watch_event;
29#[cfg(feature = "watch")]
30mod watch_handle;
31
32#[cfg(feature = "analyze")]
33pub use analysis::Entry as AnalyzedEntry;
34#[cfg(feature = "analyze")]
35pub use analysis::{
36    AgeBuckets, AnalysisErrorStrategy, AnalysisHandle, AnalysisProgress, AnalysisReport,
37    AnalyzeBuilder, ExtensionStats, MimeStats, DEFAULT_MAX_REPORTED_ERRORS, DEFAULT_TOP_N_LARGEST,
38};
39#[cfg(feature = "checksum")]
40pub use analysis::{DuplicateGroup, DEFAULT_MAX_REPORTED_DUPLICATE_GROUPS};
41pub use error::{Error, Result};
42#[cfg(feature = "operations")]
43pub use eta::EtaEstimator;
44#[cfg(feature = "operations")]
45pub use handle::Handle;
46#[cfg(feature = "operations")]
47pub use progress::Progress;
48#[cfg(feature = "watch")]
49pub use watch_event::{WatchEvent, WatchEventKind};
50#[cfg(feature = "watch")]
51pub use watch_handle::WatchHandle;
52
53#[cfg(feature = "sync")]
54pub use operations::diff::DiffStrategy;
55#[cfg(feature = "operations")]
56pub use operations::CopyBuilder;
57#[cfg(feature = "operations")]
58pub use operations::MoveBuilder;
59#[cfg(feature = "operations")]
60pub use operations::MoveManyBuilder;
61#[cfg(feature = "watch")]
62pub use operations::WatchBuilder;
63#[cfg(feature = "compress")]
64pub use operations::{CompressBuilder, CompressFormat};
65#[cfg(feature = "remove")]
66pub use operations::{RemoveBuilder, RemoveOutcome};
67#[cfg(feature = "sync")]
68pub use operations::{SyncBuilder, SyncOutcome};
69
70// These aren't just re-exports of convenience — `ErrorStrategy`,
71// `SortOrder`, and `DiffStrategy` are parameter types on the builders'
72// public methods (`on_error`, `batch_sort_order`, `diff_strategy`), and
73// `OperationOutcome`/`StopReason`/`Entry` appear in the values those
74// builders return. Without these, callers outside this crate can't name
75// the types needed to call those methods or destructure the results,
76// even though `planner`/`profiler` mark them `pub`.
77#[cfg(feature = "operations")]
78pub use planner::{ErrorStrategy, OperationOutcome, SortOrder, StopReason};
79#[cfg(feature = "operations")]
80pub use profiler::Entry;
81
82pub struct FileEngine;
83
84impl Default for FileEngine {
85    fn default() -> Self {
86        Self::new()
87    }
88}
89
90impl FileEngine {
91    pub fn new() -> Self {
92        FileEngine
93    }
94
95    #[cfg(feature = "operations")]
96    pub fn copy(
97        &self,
98        source: impl Into<std::path::PathBuf>,
99        dest: impl Into<std::path::PathBuf>,
100    ) -> CopyBuilder {
101        CopyBuilder::new(source, dest)
102    }
103
104    #[cfg(feature = "operations")]
105    pub fn move_path(
106        &self,
107        source: impl Into<std::path::PathBuf>,
108        dest: impl Into<std::path::PathBuf>,
109    ) -> MoveBuilder {
110        MoveBuilder::new(source, dest)
111    }
112
113    /// Moves several independent sources into one destination directory
114    /// as a single batched operation. `dest` is always a directory the
115    /// sources land *inside* (each keeps its own basename), unlike
116    /// `.move_path()`'s `dest`, which can be a rename target.
117    #[cfg(feature = "operations")]
118    pub fn move_many(
119        &self,
120        sources: impl IntoIterator<Item = impl Into<std::path::PathBuf>>,
121        dest: impl Into<std::path::PathBuf>,
122    ) -> MoveManyBuilder {
123        MoveManyBuilder::new(sources, dest)
124    }
125
126    #[cfg(feature = "watch")]
127    pub fn watch(&self, path: impl Into<std::path::PathBuf>) -> WatchBuilder {
128        WatchBuilder::new(path)
129    }
130
131    /// Deletes files under `path` matching a set of criteria, rather
132    /// than `path` outright. Defaults to a dry run that only previews
133    /// matches, and moves matched files to the platform trash rather
134    /// than unlinking them — see `RemoveBuilder`'s doc comment for why.
135    #[cfg(feature = "remove")]
136    pub fn remove(&self, path: impl Into<std::path::PathBuf>) -> RemoveBuilder {
137        RemoveBuilder::new(path)
138    }
139
140    #[cfg(feature = "sync")]
141    pub fn sync(
142        &self,
143        source: impl Into<std::path::PathBuf>,
144        dest: impl Into<std::path::PathBuf>,
145    ) -> SyncBuilder {
146        SyncBuilder::new(source, dest)
147    }
148
149    #[cfg(feature = "analyze")]
150    pub fn analyze(&self, path: impl Into<std::path::PathBuf>) -> AnalyzeBuilder {
151        AnalyzeBuilder::new(path)
152    }
153
154    #[cfg(feature = "compress")]
155    pub fn compress(
156        &self,
157        source: impl Into<std::path::PathBuf>,
158        dest: impl Into<std::path::PathBuf>,
159    ) -> CompressBuilder {
160        CompressBuilder::new(source, dest)
161    }
162}
163
164#[cfg(all(test, feature = "operations", feature = "sync", feature = "compress"))]
165mod tests {
166    use std::fs;
167
168    use tempfile::tempdir;
169    use tokio_stream::StreamExt;
170
171    use super::*;
172
173    /// Confirms the event sequence contract `.start()` promises: a
174    /// `Started` before any `EntryStarted`, its `entries_total` matching
175    /// what actually ran, and one terminal event (`EntryCompleted` or
176    /// `EntryFailed`) per entry.
177    fn assert_well_formed(events: &[Progress], expected_entries: usize) {
178        let started_at = events
179            .iter()
180            .position(|e| matches!(e, Progress::Started { .. }));
181        assert!(started_at.is_some(), "expected a Started event");
182
183        // `Planned` has to precede everything, including the directory
184        // pre-pass — an ETA that only sees `Started` misses that phase
185        // entirely, which is the whole reason the variant exists.
186        let planned_at = events
187            .iter()
188            .position(|e| matches!(e, Progress::Planned { .. }))
189            .expect("expected a Planned event");
190        assert_eq!(planned_at, 0, "Planned must be the first event");
191
192        let planned_entries = events
193            .iter()
194            .find_map(|e| match e {
195                Progress::Planned {
196                    small_files,
197                    large_files,
198                    ..
199                } => Some(small_files + large_files),
200                _ => None,
201            })
202            .unwrap();
203        assert_eq!(
204            planned_entries, expected_entries,
205            "Planned's file counts must agree with what actually ran"
206        );
207
208        if let Some(first_entry_started) = events
209            .iter()
210            .position(|e| matches!(e, Progress::EntryStarted { .. }))
211        {
212            assert!(
213                started_at.unwrap() < first_entry_started,
214                "Started must come before any EntryStarted"
215            );
216        }
217
218        let entries_total = events
219            .iter()
220            .find_map(|e| match e {
221                Progress::Started { entries_total, .. } => Some(*entries_total),
222                _ => None,
223            })
224            .unwrap();
225        assert_eq!(entries_total, expected_entries);
226
227        let terminal_count = events
228            .iter()
229            .filter(|e| {
230                matches!(
231                    e,
232                    Progress::EntryCompleted { .. } | Progress::EntryFailed { .. }
233                )
234            })
235            .count();
236        assert_eq!(terminal_count, expected_entries);
237    }
238
239    #[tokio::test]
240    async fn copy_end_to_end_through_the_public_api() {
241        let src_dir = tempdir().unwrap();
242        let dest_dir = tempdir().unwrap();
243        fs::write(src_dir.path().join("a.txt"), b"hello").unwrap();
244
245        let engine = FileEngine::new();
246        let mut handle = engine
247            .copy(src_dir.path(), dest_dir.path())
248            .start()
249            .unwrap();
250
251        let mut events = Vec::new();
252        while let Some(event) = handle.progress().next().await {
253            events.push(event);
254        }
255
256        let outcome = handle.await.unwrap();
257
258        assert_eq!(outcome.succeeded.len(), 1);
259        assert_eq!(fs::read(dest_dir.path().join("a.txt")).unwrap(), b"hello");
260        assert_well_formed(&events, 1);
261        assert!(
262            outcome.duration > std::time::Duration::ZERO,
263            "duration should be stamped by the time the handle resolves"
264        );
265    }
266
267    #[tokio::test]
268    async fn move_end_to_end_through_the_public_api() {
269        // Both paths under one tempdir, guaranteeing the same filesystem
270        // so this exercises the atomic-rename fast path (matches
271        // move_path.rs's own same-filesystem test).
272        let root = tempdir().unwrap();
273        let src_file = root.path().join("a.txt");
274        let dest_file = root.path().join("dst.txt");
275        fs::write(&src_file, b"hello").unwrap();
276
277        let engine = FileEngine::new();
278        let handle = engine
279            .move_path(root.path().join("a.txt"), dest_file.clone())
280            .start()
281            .unwrap();
282        let outcome = handle.await.unwrap();
283
284        // Fast path enumerates no entries — matches move_path.rs's tests.
285        assert!(outcome.succeeded.is_empty());
286        assert!(!src_file.exists());
287        assert_eq!(fs::read(&dest_file).unwrap(), b"hello");
288        // Stamped even on the rename fast path, which enumerates nothing.
289        assert!(outcome.duration > std::time::Duration::ZERO);
290    }
291
292    #[tokio::test]
293    async fn move_many_end_to_end_through_the_public_api() {
294        let src_dir = tempdir().unwrap();
295        let dest_dir = tempdir().unwrap();
296        let a = src_dir.path().join("a.txt");
297        let b = src_dir.path().join("b.txt");
298        fs::write(&a, b"a").unwrap();
299        fs::write(&b, b"b").unwrap();
300
301        let engine = FileEngine::new();
302        let handle = engine
303            .move_many([a.clone(), b.clone()], dest_dir.path())
304            .start()
305            .unwrap();
306        let outcome = handle.await.unwrap();
307
308        assert!(outcome.sources_failed.is_empty());
309        assert!(!a.exists());
310        assert!(!b.exists());
311        assert_eq!(fs::read(dest_dir.path().join("a.txt")).unwrap(), b"a");
312        assert_eq!(fs::read(dest_dir.path().join("b.txt")).unwrap(), b"b");
313    }
314
315    #[tokio::test]
316    async fn sync_end_to_end_through_the_public_api() {
317        let src_dir = tempdir().unwrap();
318        let dest_dir = tempdir().unwrap();
319        fs::write(src_dir.path().join("new.txt"), b"new").unwrap();
320        fs::write(dest_dir.path().join("orphan.txt"), b"stale").unwrap();
321
322        let engine = FileEngine::new();
323        let handle = engine
324            .sync(src_dir.path(), dest_dir.path())
325            .start()
326            .unwrap();
327        let outcome = handle.await.unwrap();
328
329        assert_eq!(outcome.copy.succeeded.len(), 1);
330        assert_eq!(outcome.delete.succeeded.len(), 1);
331        assert_eq!(fs::read(dest_dir.path().join("new.txt")).unwrap(), b"new");
332        assert!(!dest_dir.path().join("orphan.txt").exists());
333        // Timed per phase, so both ran and neither inherited the other's.
334        assert!(outcome.copy.duration > std::time::Duration::ZERO);
335        assert!(outcome.delete.duration > std::time::Duration::ZERO);
336    }
337
338    #[tokio::test]
339    async fn compress_end_to_end_through_the_public_api() {
340        let src_dir = tempdir().unwrap();
341        let out_dir = tempdir().unwrap();
342        fs::write(src_dir.path().join("a.txt"), b"a").unwrap();
343        let dest = out_dir.path().join("archive.zip");
344
345        let engine = FileEngine::new();
346        let mut handle = engine.compress(src_dir.path(), &dest).start().unwrap();
347
348        let mut events = Vec::new();
349        while let Some(event) = handle.progress().next().await {
350            events.push(event);
351        }
352
353        let outcome = handle.await.unwrap();
354
355        assert_eq!(outcome.succeeded.len(), 1);
356        assert!(dest.exists());
357        assert_well_formed(&events, 1);
358        assert!(outcome.duration > std::time::Duration::ZERO);
359    }
360}
361
362#[cfg(all(test, feature = "analyze"))]
363mod analyze_tests {
364    use std::fs;
365
366    use tempfile::tempdir;
367    use tokio_stream::StreamExt;
368
369    use super::*;
370
371    #[tokio::test]
372    async fn analyze_end_to_end_through_the_public_api() {
373        let dir = tempdir().unwrap();
374        fs::write(dir.path().join("a.txt"), vec![0u8; 10]).unwrap();
375        fs::write(dir.path().join("b.log"), vec![0u8; 20]).unwrap();
376
377        let engine = FileEngine::new();
378        let mut handle = engine.analyze(dir.path()).start().unwrap();
379
380        let mut progress_events = 0;
381        while (handle.progress().next().await).is_some() {
382            progress_events += 1;
383        }
384
385        let report = handle.await.unwrap();
386
387        assert_eq!(report.file_count, 2);
388        assert_eq!(report.total_size, 30);
389        assert_eq!(progress_events, 2);
390        assert_eq!(report.errors_total, 0);
391        assert!(report.duration > std::time::Duration::ZERO);
392    }
393
394    #[tokio::test]
395    async fn extension_filter_narrows_the_report() {
396        let dir = tempdir().unwrap();
397        fs::write(dir.path().join("a.txt"), b"x").unwrap();
398        fs::write(dir.path().join("b.log"), b"x").unwrap();
399
400        let engine = FileEngine::new();
401        let report = engine
402            .analyze(dir.path())
403            .extensions(["txt"])
404            .start()
405            .unwrap()
406            .await
407            .unwrap();
408
409        assert_eq!(report.file_count, 1);
410    }
411
412    #[cfg(feature = "checksum")]
413    #[tokio::test]
414    async fn duplicate_detection_finds_identical_content_by_hash_not_name() {
415        let dir = tempdir().unwrap();
416        fs::write(dir.path().join("a.txt"), b"same content").unwrap();
417        fs::write(dir.path().join("b.txt"), b"same content").unwrap();
418        fs::write(dir.path().join("c.txt"), b"different").unwrap();
419
420        let engine = FileEngine::new();
421        let report = engine
422            .analyze(dir.path())
423            .detect_duplicates(true)
424            .start()
425            .unwrap()
426            .await
427            .unwrap();
428
429        assert_eq!(report.duplicate_groups_total, 1);
430        assert_eq!(report.duplicates.len(), 1);
431        assert_eq!(report.duplicates[0].paths.len(), 2);
432        assert_eq!(report.duplicate_bytes_wasted, "same content".len() as u64);
433    }
434}