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