Skip to main content

file_engine/
lib.rs

1mod error;
2#[cfg(feature = "operations")]
3mod handle;
4// `sync`/`compress` already imply `operations` via Cargo.toml, but
5// `watch` deliberately doesn't (it never touches the
6// Profiler/Planner/Dispatcher pipeline) — this needs its own condition
7// rather than reusing the `operations` feature alone, or `watch`-only
8// builds fail to find this module at all.
9#[cfg(any(feature = "operations", feature = "watch"))]
10mod operations;
11// Also intended for `sync`'s `diff.rs` once that's wired up to use it
12// too (dev-docs/design/filesystem-detection.md, item 6) — currently only
13// called from `profiler::validate`.
14#[cfg(feature = "operations")]
15mod paths;
16#[cfg(feature = "operations")]
17mod planner;
18#[cfg(feature = "operations")]
19mod profiler;
20#[cfg(feature = "operations")]
21mod progress;
22#[cfg(feature = "watch")]
23mod watch_event;
24#[cfg(feature = "watch")]
25mod watch_handle;
26
27pub use error::{Error, Result};
28#[cfg(feature = "operations")]
29pub use handle::Handle;
30#[cfg(feature = "operations")]
31pub use progress::Progress;
32#[cfg(feature = "watch")]
33pub use watch_event::{WatchEvent, WatchEventKind};
34#[cfg(feature = "watch")]
35pub use watch_handle::WatchHandle;
36
37#[cfg(feature = "operations")]
38pub use operations::CopyBuilder;
39#[cfg(feature = "compress")]
40pub use operations::{CompressBuilder, CompressFormat};
41#[cfg(feature = "operations")]
42pub use operations::MoveBuilder;
43#[cfg(feature = "sync")]
44pub use operations::{SyncBuilder, SyncOutcome};
45#[cfg(feature = "watch")]
46pub use operations::WatchBuilder;
47#[cfg(feature = "sync")]
48pub use operations::diff::DiffStrategy;
49
50// These aren't just re-exports of convenience — `ErrorStrategy`,
51// `SortOrder`, and `DiffStrategy` are parameter types on the builders'
52// public methods (`on_error`, `batch_sort_order`, `diff_strategy`), and
53// `OperationOutcome`/`StopReason`/`Entry` appear in the values those
54// builders return. Without these, callers outside this crate can't name
55// the types needed to call those methods or destructure the results,
56// even though `planner`/`profiler` mark them `pub`.
57#[cfg(feature = "operations")]
58pub use planner::{ErrorStrategy, OperationOutcome, SortOrder, StopReason};
59#[cfg(feature = "operations")]
60pub use profiler::Entry;
61
62pub struct FileEngine;
63
64impl Default for FileEngine {
65    fn default() -> Self {
66        Self::new()
67    }
68}
69
70impl FileEngine {
71    pub fn new() -> Self {
72        FileEngine
73    }
74
75    #[cfg(feature = "operations")]
76    pub fn copy(
77        &self,
78        source: impl Into<std::path::PathBuf>,
79        dest: impl Into<std::path::PathBuf>,
80    ) -> CopyBuilder {
81        CopyBuilder::new(source, dest)
82    }
83
84    #[cfg(feature = "operations")]
85    pub fn move_path(
86        &self,
87        source: impl Into<std::path::PathBuf>,
88        dest: impl Into<std::path::PathBuf>,
89    ) -> MoveBuilder {
90        MoveBuilder::new(source, dest)
91    }
92
93    #[cfg(feature = "watch")]
94    pub fn watch(&self, path: impl Into<std::path::PathBuf>) -> WatchBuilder {
95        WatchBuilder::new(path)
96    }
97
98    #[cfg(feature = "sync")]
99    pub fn sync(
100        &self,
101        source: impl Into<std::path::PathBuf>,
102        dest: impl Into<std::path::PathBuf>,
103    ) -> SyncBuilder {
104        SyncBuilder::new(source, dest)
105    }
106
107    #[cfg(feature = "compress")]
108    pub fn compress(
109        &self,
110        source: impl Into<std::path::PathBuf>,
111        dest: impl Into<std::path::PathBuf>,
112    ) -> CompressBuilder {
113        CompressBuilder::new(source, dest)
114    }
115}
116
117#[cfg(all(test, feature = "operations", feature = "sync", feature = "compress"))]
118mod tests {
119    use std::fs;
120
121    use tempfile::tempdir;
122    use tokio_stream::StreamExt;
123
124    use super::*;
125
126    /// Confirms the event sequence contract from
127    /// dev-docs/design/handle-progress.md's `.start()` test list: a `Started`
128    /// before any `EntryStarted`, its `entries_total` matching what
129    /// actually ran, and one terminal event (`EntryCompleted` or
130    /// `EntryFailed`) per entry.
131    fn assert_well_formed(events: &[Progress], expected_entries: usize) {
132        let started_at = events.iter().position(|e| matches!(e, Progress::Started { .. }));
133        assert!(started_at.is_some(), "expected a Started event");
134
135        if let Some(first_entry_started) = events.iter().position(|e| matches!(e, Progress::EntryStarted { .. })) {
136            assert!(started_at.unwrap() < first_entry_started, "Started must come before any EntryStarted");
137        }
138
139        let entries_total = events
140            .iter()
141            .find_map(|e| match e {
142                Progress::Started { entries_total, .. } => Some(*entries_total),
143                _ => None,
144            })
145            .unwrap();
146        assert_eq!(entries_total, expected_entries);
147
148        let terminal_count = events
149            .iter()
150            .filter(|e| matches!(e, Progress::EntryCompleted { .. } | Progress::EntryFailed { .. }))
151            .count();
152        assert_eq!(terminal_count, expected_entries);
153    }
154
155    #[tokio::test]
156    async fn copy_end_to_end_through_the_public_api() {
157        let src_dir = tempdir().unwrap();
158        let dest_dir = tempdir().unwrap();
159        fs::write(src_dir.path().join("a.txt"), b"hello").unwrap();
160
161        let engine = FileEngine::new();
162        let mut handle = engine.copy(src_dir.path(), dest_dir.path()).start().unwrap();
163
164        let mut events = Vec::new();
165        while let Some(event) = handle.progress().next().await {
166            events.push(event);
167        }
168
169        let outcome = handle.await.unwrap();
170
171        assert_eq!(outcome.succeeded.len(), 1);
172        assert_eq!(fs::read(dest_dir.path().join("a.txt")).unwrap(), b"hello");
173        assert_well_formed(&events, 1);
174    }
175
176    #[tokio::test]
177    async fn move_end_to_end_through_the_public_api() {
178        // Both paths under one tempdir, guaranteeing the same filesystem
179        // so this exercises the atomic-rename fast path (matches
180        // move_path.rs's own same-filesystem test).
181        let root = tempdir().unwrap();
182        let src_file = root.path().join("a.txt");
183        let dest_file = root.path().join("dst.txt");
184        fs::write(&src_file, b"hello").unwrap();
185
186        let engine = FileEngine::new();
187        let handle = engine.move_path(root.path().join("a.txt"), dest_file.clone()).start().unwrap();
188        let outcome = handle.await.unwrap();
189
190        // Fast path enumerates no entries — matches move_path.rs's tests.
191        assert!(outcome.succeeded.is_empty());
192        assert!(!src_file.exists());
193        assert_eq!(fs::read(&dest_file).unwrap(), b"hello");
194    }
195
196    #[tokio::test]
197    async fn sync_end_to_end_through_the_public_api() {
198        let src_dir = tempdir().unwrap();
199        let dest_dir = tempdir().unwrap();
200        fs::write(src_dir.path().join("new.txt"), b"new").unwrap();
201        fs::write(dest_dir.path().join("orphan.txt"), b"stale").unwrap();
202
203        let engine = FileEngine::new();
204        let handle = engine.sync(src_dir.path(), dest_dir.path()).start().unwrap();
205        let outcome = handle.await.unwrap();
206
207        assert_eq!(outcome.copy.succeeded.len(), 1);
208        assert_eq!(outcome.delete.succeeded.len(), 1);
209        assert_eq!(fs::read(dest_dir.path().join("new.txt")).unwrap(), b"new");
210        assert!(!dest_dir.path().join("orphan.txt").exists());
211    }
212
213    #[tokio::test]
214    async fn compress_end_to_end_through_the_public_api() {
215        let src_dir = tempdir().unwrap();
216        let out_dir = tempdir().unwrap();
217        fs::write(src_dir.path().join("a.txt"), b"a").unwrap();
218        let dest = out_dir.path().join("archive.zip");
219
220        let engine = FileEngine::new();
221        let mut handle = engine.compress(src_dir.path(), &dest).start().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!(dest.exists());
232        assert_well_formed(&events, 1);
233    }
234}