file-engine 2.4.0

Async, cross-platform file operations engine for desktop apps and developer tools: copy, move, sync, watch, and compress files with progress reporting and cancellation.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
#[cfg(feature = "analyze")]
mod analysis;
#[cfg(feature = "checksum")]
mod checksum;
mod error;
#[cfg(feature = "operations")]
mod eta;
#[cfg(feature = "operations")]
mod handle;
// `sync`/`compress` already imply `operations` via Cargo.toml, but
// `watch` deliberately doesn't (it never touches the
// Profiler/Planner/Dispatcher pipeline) — this needs its own condition
// rather than reusing the `operations` feature alone, or `watch`-only
// builds fail to find this module at all.
#[cfg(any(feature = "operations", feature = "watch"))]
mod operations;
// Also intended for `sync`'s `diff.rs` once that's wired up to use it
// too — currently only called from `profiler::validate`.
#[cfg(feature = "operations")]
mod paths;
#[cfg(feature = "operations")]
mod planner;
#[cfg(feature = "operations")]
mod profiler;
#[cfg(feature = "operations")]
mod progress;
#[cfg(feature = "watch")]
mod watch_event;
#[cfg(feature = "watch")]
mod watch_handle;

#[cfg(feature = "analyze")]
pub use analysis::Entry as AnalyzedEntry;
#[cfg(feature = "analyze")]
pub use analysis::{
    AgeBuckets, AnalysisErrorStrategy, AnalysisHandle, AnalysisProgress, AnalysisReport,
    AnalyzeBuilder, ExtensionStats, MimeStats, DEFAULT_MAX_REPORTED_ERRORS, DEFAULT_TOP_N_LARGEST,
};
#[cfg(feature = "checksum")]
pub use analysis::{DuplicateGroup, DEFAULT_MAX_REPORTED_DUPLICATE_GROUPS};
pub use error::{Error, Result};
#[cfg(feature = "operations")]
pub use eta::EtaEstimator;
#[cfg(feature = "operations")]
pub use handle::Handle;
#[cfg(feature = "operations")]
pub use progress::Progress;
#[cfg(feature = "watch")]
pub use watch_event::{WatchEvent, WatchEventKind};
#[cfg(feature = "watch")]
pub use watch_handle::WatchHandle;

#[cfg(feature = "sync")]
pub use operations::diff::DiffStrategy;
#[cfg(feature = "operations")]
pub use operations::CopyBuilder;
#[cfg(feature = "operations")]
pub use operations::MoveBuilder;
#[cfg(feature = "operations")]
pub use operations::MoveManyBuilder;
#[cfg(feature = "watch")]
pub use operations::WatchBuilder;
#[cfg(feature = "compress")]
pub use operations::{CompressBuilder, CompressFormat};
#[cfg(feature = "remove")]
pub use operations::{RemoveBuilder, RemoveOutcome};
#[cfg(feature = "sync")]
pub use operations::{SyncBuilder, SyncOutcome};

// These aren't just re-exports of convenience — `ErrorStrategy`,
// `SortOrder`, and `DiffStrategy` are parameter types on the builders'
// public methods (`on_error`, `batch_sort_order`, `diff_strategy`), and
// `OperationOutcome`/`StopReason`/`Entry` appear in the values those
// builders return. Without these, callers outside this crate can't name
// the types needed to call those methods or destructure the results,
// even though `planner`/`profiler` mark them `pub`.
#[cfg(feature = "operations")]
pub use planner::{ErrorStrategy, OperationOutcome, SortOrder, StopReason};
#[cfg(feature = "operations")]
pub use profiler::Entry;

pub struct FileEngine;

impl Default for FileEngine {
    fn default() -> Self {
        Self::new()
    }
}

impl FileEngine {
    pub fn new() -> Self {
        FileEngine
    }

    #[cfg(feature = "operations")]
    pub fn copy(
        &self,
        source: impl Into<std::path::PathBuf>,
        dest: impl Into<std::path::PathBuf>,
    ) -> CopyBuilder {
        CopyBuilder::new(source, dest)
    }

    #[cfg(feature = "operations")]
    pub fn move_path(
        &self,
        source: impl Into<std::path::PathBuf>,
        dest: impl Into<std::path::PathBuf>,
    ) -> MoveBuilder {
        MoveBuilder::new(source, dest)
    }

    /// Moves several independent sources into one destination directory
    /// as a single batched operation. `dest` is always a directory the
    /// sources land *inside* (each keeps its own basename), unlike
    /// `.move_path()`'s `dest`, which can be a rename target.
    #[cfg(feature = "operations")]
    pub fn move_many(
        &self,
        sources: impl IntoIterator<Item = impl Into<std::path::PathBuf>>,
        dest: impl Into<std::path::PathBuf>,
    ) -> MoveManyBuilder {
        MoveManyBuilder::new(sources, dest)
    }

    #[cfg(feature = "watch")]
    pub fn watch(&self, path: impl Into<std::path::PathBuf>) -> WatchBuilder {
        WatchBuilder::new(path)
    }

    /// Deletes files under `path` matching a set of criteria, rather
    /// than `path` outright. Defaults to a dry run that only previews
    /// matches, and moves matched files to the platform trash rather
    /// than unlinking them — see `RemoveBuilder`'s doc comment for why.
    #[cfg(feature = "remove")]
    pub fn remove(&self, path: impl Into<std::path::PathBuf>) -> RemoveBuilder {
        RemoveBuilder::new(path)
    }

    #[cfg(feature = "sync")]
    pub fn sync(
        &self,
        source: impl Into<std::path::PathBuf>,
        dest: impl Into<std::path::PathBuf>,
    ) -> SyncBuilder {
        SyncBuilder::new(source, dest)
    }

    #[cfg(feature = "analyze")]
    pub fn analyze(&self, path: impl Into<std::path::PathBuf>) -> AnalyzeBuilder {
        AnalyzeBuilder::new(path)
    }

    #[cfg(feature = "compress")]
    pub fn compress(
        &self,
        source: impl Into<std::path::PathBuf>,
        dest: impl Into<std::path::PathBuf>,
    ) -> CompressBuilder {
        CompressBuilder::new(source, dest)
    }
}

#[cfg(all(test, feature = "operations", feature = "sync", feature = "compress"))]
mod tests {
    use std::fs;

    use tempfile::tempdir;
    use tokio_stream::StreamExt;

    use super::*;

    /// Confirms the event sequence contract `.start()` promises: a
    /// `Started` before any `EntryStarted`, its `entries_total` matching
    /// what actually ran, and one terminal event (`EntryCompleted` or
    /// `EntryFailed`) per entry.
    fn assert_well_formed(events: &[Progress], expected_entries: usize) {
        let started_at = events
            .iter()
            .position(|e| matches!(e, Progress::Started { .. }));
        assert!(started_at.is_some(), "expected a Started event");

        // `Planned` has to precede everything, including the directory
        // pre-pass — an ETA that only sees `Started` misses that phase
        // entirely, which is the whole reason the variant exists.
        let planned_at = events
            .iter()
            .position(|e| matches!(e, Progress::Planned { .. }))
            .expect("expected a Planned event");
        assert_eq!(planned_at, 0, "Planned must be the first event");

        let planned_entries = events
            .iter()
            .find_map(|e| match e {
                Progress::Planned {
                    small_files,
                    large_files,
                    ..
                } => Some(small_files + large_files),
                _ => None,
            })
            .unwrap();
        assert_eq!(
            planned_entries, expected_entries,
            "Planned's file counts must agree with what actually ran"
        );

        if let Some(first_entry_started) = events
            .iter()
            .position(|e| matches!(e, Progress::EntryStarted { .. }))
        {
            assert!(
                started_at.unwrap() < first_entry_started,
                "Started must come before any EntryStarted"
            );
        }

        let entries_total = events
            .iter()
            .find_map(|e| match e {
                Progress::Started { entries_total, .. } => Some(*entries_total),
                _ => None,
            })
            .unwrap();
        assert_eq!(entries_total, expected_entries);

        let terminal_count = events
            .iter()
            .filter(|e| {
                matches!(
                    e,
                    Progress::EntryCompleted { .. } | Progress::EntryFailed { .. }
                )
            })
            .count();
        assert_eq!(terminal_count, expected_entries);
    }

    #[tokio::test]
    async fn copy_end_to_end_through_the_public_api() {
        let src_dir = tempdir().unwrap();
        let dest_dir = tempdir().unwrap();
        fs::write(src_dir.path().join("a.txt"), b"hello").unwrap();

        let engine = FileEngine::new();
        let mut handle = engine
            .copy(src_dir.path(), dest_dir.path())
            .start()
            .unwrap();

        let mut events = Vec::new();
        while let Some(event) = handle.progress().next().await {
            events.push(event);
        }

        let outcome = handle.await.unwrap();

        assert_eq!(outcome.succeeded.len(), 1);
        assert_eq!(fs::read(dest_dir.path().join("a.txt")).unwrap(), b"hello");
        assert_well_formed(&events, 1);
        assert!(
            outcome.duration > std::time::Duration::ZERO,
            "duration should be stamped by the time the handle resolves"
        );
    }

    #[tokio::test]
    async fn move_end_to_end_through_the_public_api() {
        // Both paths under one tempdir, guaranteeing the same filesystem
        // so this exercises the atomic-rename fast path (matches
        // move_path.rs's own same-filesystem test).
        let root = tempdir().unwrap();
        let src_file = root.path().join("a.txt");
        let dest_file = root.path().join("dst.txt");
        fs::write(&src_file, b"hello").unwrap();

        let engine = FileEngine::new();
        let handle = engine
            .move_path(root.path().join("a.txt"), dest_file.clone())
            .start()
            .unwrap();
        let outcome = handle.await.unwrap();

        // Fast path enumerates no entries — matches move_path.rs's tests.
        assert!(outcome.succeeded.is_empty());
        assert!(!src_file.exists());
        assert_eq!(fs::read(&dest_file).unwrap(), b"hello");
        // Stamped even on the rename fast path, which enumerates nothing.
        assert!(outcome.duration > std::time::Duration::ZERO);
    }

    #[tokio::test]
    async fn move_many_end_to_end_through_the_public_api() {
        let src_dir = tempdir().unwrap();
        let dest_dir = tempdir().unwrap();
        let a = src_dir.path().join("a.txt");
        let b = src_dir.path().join("b.txt");
        fs::write(&a, b"a").unwrap();
        fs::write(&b, b"b").unwrap();

        let engine = FileEngine::new();
        let handle = engine
            .move_many([a.clone(), b.clone()], dest_dir.path())
            .start()
            .unwrap();
        let outcome = handle.await.unwrap();

        assert!(outcome.sources_failed.is_empty());
        assert!(!a.exists());
        assert!(!b.exists());
        assert_eq!(fs::read(dest_dir.path().join("a.txt")).unwrap(), b"a");
        assert_eq!(fs::read(dest_dir.path().join("b.txt")).unwrap(), b"b");
    }

    #[tokio::test]
    async fn sync_end_to_end_through_the_public_api() {
        let src_dir = tempdir().unwrap();
        let dest_dir = tempdir().unwrap();
        fs::write(src_dir.path().join("new.txt"), b"new").unwrap();
        fs::write(dest_dir.path().join("orphan.txt"), b"stale").unwrap();

        let engine = FileEngine::new();
        let handle = engine
            .sync(src_dir.path(), dest_dir.path())
            .start()
            .unwrap();
        let outcome = handle.await.unwrap();

        assert_eq!(outcome.copy.succeeded.len(), 1);
        assert_eq!(outcome.delete.succeeded.len(), 1);
        assert_eq!(fs::read(dest_dir.path().join("new.txt")).unwrap(), b"new");
        assert!(!dest_dir.path().join("orphan.txt").exists());
        // Timed per phase, so both ran and neither inherited the other's.
        assert!(outcome.copy.duration > std::time::Duration::ZERO);
        assert!(outcome.delete.duration > std::time::Duration::ZERO);
    }

    #[tokio::test]
    async fn compress_end_to_end_through_the_public_api() {
        let src_dir = tempdir().unwrap();
        let out_dir = tempdir().unwrap();
        fs::write(src_dir.path().join("a.txt"), b"a").unwrap();
        let dest = out_dir.path().join("archive.zip");

        let engine = FileEngine::new();
        let mut handle = engine.compress(src_dir.path(), &dest).start().unwrap();

        let mut events = Vec::new();
        while let Some(event) = handle.progress().next().await {
            events.push(event);
        }

        let outcome = handle.await.unwrap();

        assert_eq!(outcome.succeeded.len(), 1);
        assert!(dest.exists());
        assert_well_formed(&events, 1);
        assert!(outcome.duration > std::time::Duration::ZERO);
    }
}

#[cfg(all(test, feature = "analyze"))]
mod analyze_tests {
    use std::fs;

    use tempfile::tempdir;
    use tokio_stream::StreamExt;

    use super::*;

    #[tokio::test]
    async fn analyze_end_to_end_through_the_public_api() {
        let dir = tempdir().unwrap();
        fs::write(dir.path().join("a.txt"), vec![0u8; 10]).unwrap();
        fs::write(dir.path().join("b.log"), vec![0u8; 20]).unwrap();

        let engine = FileEngine::new();
        let mut handle = engine.analyze(dir.path()).start().unwrap();

        let mut progress_events = 0;
        while (handle.progress().next().await).is_some() {
            progress_events += 1;
        }

        let report = handle.await.unwrap();

        assert_eq!(report.file_count, 2);
        assert_eq!(report.total_size, 30);
        assert_eq!(progress_events, 2);
        assert_eq!(report.errors_total, 0);
        assert!(report.duration > std::time::Duration::ZERO);
    }

    #[tokio::test]
    async fn extension_filter_narrows_the_report() {
        let dir = tempdir().unwrap();
        fs::write(dir.path().join("a.txt"), b"x").unwrap();
        fs::write(dir.path().join("b.log"), b"x").unwrap();

        let engine = FileEngine::new();
        let report = engine
            .analyze(dir.path())
            .extensions(["txt"])
            .start()
            .unwrap()
            .await
            .unwrap();

        assert_eq!(report.file_count, 1);
    }

    #[cfg(feature = "checksum")]
    #[tokio::test]
    async fn duplicate_detection_finds_identical_content_by_hash_not_name() {
        let dir = tempdir().unwrap();
        fs::write(dir.path().join("a.txt"), b"same content").unwrap();
        fs::write(dir.path().join("b.txt"), b"same content").unwrap();
        fs::write(dir.path().join("c.txt"), b"different").unwrap();

        let engine = FileEngine::new();
        let report = engine
            .analyze(dir.path())
            .detect_duplicates(true)
            .start()
            .unwrap()
            .await
            .unwrap();

        assert_eq!(report.duplicate_groups_total, 1);
        assert_eq!(report.duplicates.len(), 1);
        assert_eq!(report.duplicates[0].paths.len(), 2);
        assert_eq!(report.duplicate_bytes_wasted, "same content".len() as u64);
    }
}