1#[cfg(feature = "analyze")]
2mod analysis;
3#[cfg(feature = "checksum")]
4mod checksum;
5mod error;
6#[cfg(feature = "operations")]
7mod eta;
8#[cfg(feature = "operations")]
9mod handle;
10#[cfg(any(feature = "operations", feature = "watch"))]
16mod operations;
17#[cfg(feature = "operations")]
20mod paths;
21#[cfg(feature = "operations")]
22mod planner;
23#[cfg(feature = "operations")]
24mod profiler;
25#[cfg(feature = "operations")]
26mod progress;
27#[cfg(feature = "watch")]
28mod watch_event;
29#[cfg(feature = "watch")]
30mod watch_handle;
31
32#[cfg(feature = "analyze")]
33pub use analysis::Entry as AnalyzedEntry;
34#[cfg(feature = "analyze")]
35pub use analysis::{
36 AgeBuckets, AnalysisErrorStrategy, AnalysisHandle, AnalysisProgress, AnalysisReport,
37 AnalyzeBuilder, ExtensionStats, MimeStats, DEFAULT_MAX_REPORTED_ERRORS, DEFAULT_TOP_N_LARGEST,
38};
39#[cfg(feature = "checksum")]
40pub use analysis::{DuplicateGroup, DEFAULT_MAX_REPORTED_DUPLICATE_GROUPS};
41pub use error::{Error, Result};
42#[cfg(feature = "operations")]
43pub use eta::EtaEstimator;
44#[cfg(feature = "operations")]
45pub use handle::Handle;
46#[cfg(feature = "operations")]
47pub use progress::Progress;
48#[cfg(feature = "watch")]
49pub use watch_event::{WatchEvent, WatchEventKind};
50#[cfg(feature = "watch")]
51pub use watch_handle::WatchHandle;
52
53#[cfg(feature = "sync")]
54pub use operations::diff::DiffStrategy;
55#[cfg(feature = "operations")]
56pub use operations::CopyBuilder;
57#[cfg(feature = "operations")]
58pub use operations::MoveBuilder;
59#[cfg(feature = "operations")]
60pub use operations::MoveManyBuilder;
61#[cfg(feature = "watch")]
62pub use operations::WatchBuilder;
63#[cfg(feature = "compress")]
64pub use operations::{CompressBuilder, CompressFormat};
65#[cfg(feature = "remove")]
66pub use operations::{RemoveBuilder, RemoveOutcome};
67#[cfg(feature = "sync")]
68pub use operations::{SyncBuilder, SyncOutcome};
69
70#[cfg(feature = "operations")]
78pub use planner::{ErrorStrategy, OperationOutcome, SortOrder, StopReason};
79#[cfg(feature = "operations")]
80pub use profiler::Entry;
81
82pub struct FileEngine;
83
84impl Default for FileEngine {
85 fn default() -> Self {
86 Self::new()
87 }
88}
89
90impl FileEngine {
91 pub fn new() -> Self {
92 FileEngine
93 }
94
95 #[cfg(feature = "operations")]
96 pub fn copy(
97 &self,
98 source: impl Into<std::path::PathBuf>,
99 dest: impl Into<std::path::PathBuf>,
100 ) -> CopyBuilder {
101 CopyBuilder::new(source, dest)
102 }
103
104 #[cfg(feature = "operations")]
105 pub fn move_path(
106 &self,
107 source: impl Into<std::path::PathBuf>,
108 dest: impl Into<std::path::PathBuf>,
109 ) -> MoveBuilder {
110 MoveBuilder::new(source, dest)
111 }
112
113 #[cfg(feature = "operations")]
118 pub fn move_many(
119 &self,
120 sources: impl IntoIterator<Item = impl Into<std::path::PathBuf>>,
121 dest: impl Into<std::path::PathBuf>,
122 ) -> MoveManyBuilder {
123 MoveManyBuilder::new(sources, dest)
124 }
125
126 #[cfg(feature = "watch")]
127 pub fn watch(&self, path: impl Into<std::path::PathBuf>) -> WatchBuilder {
128 WatchBuilder::new(path)
129 }
130
131 #[cfg(feature = "remove")]
136 pub fn remove(&self, path: impl Into<std::path::PathBuf>) -> RemoveBuilder {
137 RemoveBuilder::new(path)
138 }
139
140 #[cfg(feature = "sync")]
141 pub fn sync(
142 &self,
143 source: impl Into<std::path::PathBuf>,
144 dest: impl Into<std::path::PathBuf>,
145 ) -> SyncBuilder {
146 SyncBuilder::new(source, dest)
147 }
148
149 #[cfg(feature = "analyze")]
150 pub fn analyze(&self, path: impl Into<std::path::PathBuf>) -> AnalyzeBuilder {
151 AnalyzeBuilder::new(path)
152 }
153
154 #[cfg(feature = "compress")]
155 pub fn compress(
156 &self,
157 source: impl Into<std::path::PathBuf>,
158 dest: impl Into<std::path::PathBuf>,
159 ) -> CompressBuilder {
160 CompressBuilder::new(source, dest)
161 }
162}
163
164#[cfg(all(test, feature = "operations", feature = "sync", feature = "compress"))]
165mod tests {
166 use std::fs;
167
168 use tempfile::tempdir;
169 use tokio_stream::StreamExt;
170
171 use super::*;
172
173 fn assert_well_formed(events: &[Progress], expected_entries: usize) {
178 let started_at = events
179 .iter()
180 .position(|e| matches!(e, Progress::Started { .. }));
181 assert!(started_at.is_some(), "expected a Started event");
182
183 let planned_at = events
187 .iter()
188 .position(|e| matches!(e, Progress::Planned { .. }))
189 .expect("expected a Planned event");
190 assert_eq!(planned_at, 0, "Planned must be the first event");
191
192 let planned_entries = events
193 .iter()
194 .find_map(|e| match e {
195 Progress::Planned {
196 small_files,
197 large_files,
198 ..
199 } => Some(small_files + large_files),
200 _ => None,
201 })
202 .unwrap();
203 assert_eq!(
204 planned_entries, expected_entries,
205 "Planned's file counts must agree with what actually ran"
206 );
207
208 if let Some(first_entry_started) = events
209 .iter()
210 .position(|e| matches!(e, Progress::EntryStarted { .. }))
211 {
212 assert!(
213 started_at.unwrap() < first_entry_started,
214 "Started must come before any EntryStarted"
215 );
216 }
217
218 let entries_total = events
219 .iter()
220 .find_map(|e| match e {
221 Progress::Started { entries_total, .. } => Some(*entries_total),
222 _ => None,
223 })
224 .unwrap();
225 assert_eq!(entries_total, expected_entries);
226
227 let terminal_count = events
228 .iter()
229 .filter(|e| {
230 matches!(
231 e,
232 Progress::EntryCompleted { .. } | Progress::EntryFailed { .. }
233 )
234 })
235 .count();
236 assert_eq!(terminal_count, expected_entries);
237 }
238
239 #[tokio::test]
240 async fn copy_end_to_end_through_the_public_api() {
241 let src_dir = tempdir().unwrap();
242 let dest_dir = tempdir().unwrap();
243 fs::write(src_dir.path().join("a.txt"), b"hello").unwrap();
244
245 let engine = FileEngine::new();
246 let mut handle = engine
247 .copy(src_dir.path(), dest_dir.path())
248 .start()
249 .unwrap();
250
251 let mut events = Vec::new();
252 while let Some(event) = handle.progress().next().await {
253 events.push(event);
254 }
255
256 let outcome = handle.await.unwrap();
257
258 assert_eq!(outcome.succeeded.len(), 1);
259 assert_eq!(fs::read(dest_dir.path().join("a.txt")).unwrap(), b"hello");
260 assert_well_formed(&events, 1);
261 assert!(
262 outcome.duration > std::time::Duration::ZERO,
263 "duration should be stamped by the time the handle resolves"
264 );
265 }
266
267 #[tokio::test]
268 async fn move_end_to_end_through_the_public_api() {
269 let root = tempdir().unwrap();
273 let src_file = root.path().join("a.txt");
274 let dest_file = root.path().join("dst.txt");
275 fs::write(&src_file, b"hello").unwrap();
276
277 let engine = FileEngine::new();
278 let handle = engine
279 .move_path(root.path().join("a.txt"), dest_file.clone())
280 .start()
281 .unwrap();
282 let outcome = handle.await.unwrap();
283
284 assert!(outcome.succeeded.is_empty());
286 assert!(!src_file.exists());
287 assert_eq!(fs::read(&dest_file).unwrap(), b"hello");
288 assert!(outcome.duration > std::time::Duration::ZERO);
290 }
291
292 #[tokio::test]
293 async fn move_many_end_to_end_through_the_public_api() {
294 let src_dir = tempdir().unwrap();
295 let dest_dir = tempdir().unwrap();
296 let a = src_dir.path().join("a.txt");
297 let b = src_dir.path().join("b.txt");
298 fs::write(&a, b"a").unwrap();
299 fs::write(&b, b"b").unwrap();
300
301 let engine = FileEngine::new();
302 let handle = engine
303 .move_many([a.clone(), b.clone()], dest_dir.path())
304 .start()
305 .unwrap();
306 let outcome = handle.await.unwrap();
307
308 assert!(outcome.sources_failed.is_empty());
309 assert!(!a.exists());
310 assert!(!b.exists());
311 assert_eq!(fs::read(dest_dir.path().join("a.txt")).unwrap(), b"a");
312 assert_eq!(fs::read(dest_dir.path().join("b.txt")).unwrap(), b"b");
313 }
314
315 #[tokio::test]
316 async fn sync_end_to_end_through_the_public_api() {
317 let src_dir = tempdir().unwrap();
318 let dest_dir = tempdir().unwrap();
319 fs::write(src_dir.path().join("new.txt"), b"new").unwrap();
320 fs::write(dest_dir.path().join("orphan.txt"), b"stale").unwrap();
321
322 let engine = FileEngine::new();
323 let handle = engine
324 .sync(src_dir.path(), dest_dir.path())
325 .start()
326 .unwrap();
327 let outcome = handle.await.unwrap();
328
329 assert_eq!(outcome.copy.succeeded.len(), 1);
330 assert_eq!(outcome.delete.succeeded.len(), 1);
331 assert_eq!(fs::read(dest_dir.path().join("new.txt")).unwrap(), b"new");
332 assert!(!dest_dir.path().join("orphan.txt").exists());
333 assert!(outcome.copy.duration > std::time::Duration::ZERO);
335 assert!(outcome.delete.duration > std::time::Duration::ZERO);
336 }
337
338 #[tokio::test]
339 async fn compress_end_to_end_through_the_public_api() {
340 let src_dir = tempdir().unwrap();
341 let out_dir = tempdir().unwrap();
342 fs::write(src_dir.path().join("a.txt"), b"a").unwrap();
343 let dest = out_dir.path().join("archive.zip");
344
345 let engine = FileEngine::new();
346 let mut handle = engine.compress(src_dir.path(), &dest).start().unwrap();
347
348 let mut events = Vec::new();
349 while let Some(event) = handle.progress().next().await {
350 events.push(event);
351 }
352
353 let outcome = handle.await.unwrap();
354
355 assert_eq!(outcome.succeeded.len(), 1);
356 assert!(dest.exists());
357 assert_well_formed(&events, 1);
358 assert!(outcome.duration > std::time::Duration::ZERO);
359 }
360}
361
362#[cfg(all(test, feature = "analyze"))]
363mod analyze_tests {
364 use std::fs;
365
366 use tempfile::tempdir;
367 use tokio_stream::StreamExt;
368
369 use super::*;
370
371 #[tokio::test]
372 async fn analyze_end_to_end_through_the_public_api() {
373 let dir = tempdir().unwrap();
374 fs::write(dir.path().join("a.txt"), vec![0u8; 10]).unwrap();
375 fs::write(dir.path().join("b.log"), vec![0u8; 20]).unwrap();
376
377 let engine = FileEngine::new();
378 let mut handle = engine.analyze(dir.path()).start().unwrap();
379
380 let mut progress_events = 0;
381 while (handle.progress().next().await).is_some() {
382 progress_events += 1;
383 }
384
385 let report = handle.await.unwrap();
386
387 assert_eq!(report.file_count, 2);
388 assert_eq!(report.total_size, 30);
389 assert_eq!(progress_events, 2);
390 assert_eq!(report.errors_total, 0);
391 assert!(report.duration > std::time::Duration::ZERO);
392 }
393
394 #[tokio::test]
395 async fn extension_filter_narrows_the_report() {
396 let dir = tempdir().unwrap();
397 fs::write(dir.path().join("a.txt"), b"x").unwrap();
398 fs::write(dir.path().join("b.log"), b"x").unwrap();
399
400 let engine = FileEngine::new();
401 let report = engine
402 .analyze(dir.path())
403 .extensions(["txt"])
404 .start()
405 .unwrap()
406 .await
407 .unwrap();
408
409 assert_eq!(report.file_count, 1);
410 }
411
412 #[cfg(feature = "checksum")]
413 #[tokio::test]
414 async fn duplicate_detection_finds_identical_content_by_hash_not_name() {
415 let dir = tempdir().unwrap();
416 fs::write(dir.path().join("a.txt"), b"same content").unwrap();
417 fs::write(dir.path().join("b.txt"), b"same content").unwrap();
418 fs::write(dir.path().join("c.txt"), b"different").unwrap();
419
420 let engine = FileEngine::new();
421 let report = engine
422 .analyze(dir.path())
423 .detect_duplicates(true)
424 .start()
425 .unwrap()
426 .await
427 .unwrap();
428
429 assert_eq!(report.duplicate_groups_total, 1);
430 assert_eq!(report.duplicates.len(), 1);
431 assert_eq!(report.duplicates[0].paths.len(), 2);
432 assert_eq!(report.duplicate_bytes_wasted, "same content".len() as u64);
433 }
434}