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