Skip to main content

file_engine/
lib.rs

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