Skip to main content

file_engine/
lib.rs

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