1#[cfg(feature = "analyze")]
2mod analysis;
3mod error;
4#[cfg(feature = "operations")]
5mod eta;
6#[cfg(feature = "operations")]
7mod handle;
8#[cfg(any(feature = "operations", feature = "watch"))]
14mod operations;
15#[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#[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 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 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 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 assert!(outcome.succeeded.is_empty());
258 assert!(!src_file.exists());
259 assert_eq!(fs::read(&dest_file).unwrap(), b"hello");
260 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 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}