file-engine 0.1.0

Async, cross-platform file operations engine for desktop apps and developer tools: copy, move, analyze, watch, compress, and sync files with progress reporting and cancellation.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
# FileEngine — Design Document

**Status:** Design settled. Section 9's original open questions have all
been resolved (each entry is struck through with a pointer to where the
resolution lives); it's kept as a record of what was decided and why,
not a live TODO list.

**Audience:** this document is written for an implementer (human or Claude
Code) picking up the project from scratch. It captures *decisions*, not just
options — where multiple approaches were discussed, only the chosen one is
recorded here.

---

## 1. Overview

`file-engine` is a standalone, open-source Rust crate providing async,
cross-platform file operations for desktop applications and developer
tooling: copy, move, analyze, watch, compress, and sync files, with progress
reporting and cooperative cancellation built in from the start.

It is **not** tied to any specific application — it ships as an independent
crate on crates.io, consumable by any Rust project (desktop apps, CLIs,
Tauri backends, etc.).

## 2. Goals & Non-Goals

**Goals**
- Safe, async-first file I/O primitives (built on `tokio`).
- A single public entry point (`FileEngine`) whose surface grows/shrinks via
  Cargo feature flags, so consumers only pay for what they use.
- Progress reporting and cancellation as first-class, not bolted on.
- Cross-platform (Linux, macOS, Windows) from day one.

**Non-Goals (v0.1)**
- Not a GUI file manager or a sync service — it is a library of primitives.
- No network/cloud storage backends (local filesystem only).
- No built-in retry/backoff policies — callers compose that themselves.

## 3. Project Structure

Follows standard Rust library conventions:

```
file-engine/
├── Cargo.toml
├── Cargo.lock              # .gitignore'd (this is a library)
├── .cargo/
│   └── config.toml         # rustflags = ["-D", "warnings"]
├── errors.toml              # embedded catalog for the optional "diagnostics" feature
├── LICENSE                  # MIT (§9.3)
├── README.md
├── src/
│   ├── lib.rs               # public API surface, re-exports, feature-gated modules
│   ├── engine.rs            # `FileEngine` struct + impl blocks split by feature
│   ├── error.rs             # `FileEngineError` (thiserror) + optional EngineDiagnostic impl
│   ├── progress.rs          # `Progress` struct
│   ├── handle.rs             # generic operation `Handle<T>` (Future + progress stream)
│   ├── operations/           # feature = "operations"
│   │   ├── mod.rs
│   │   ├── copy.rs           # CopyBuilder
│   │   └── move_op.rs         # MoveBuilder
│   ├── analyze.rs            # feature = "analyze" — AnalyzeBuilder, FileInfo, FileKind
│   ├── watch.rs               # feature = "watch" — WatchBuilder (see §9, open design)
│   ├── compress.rs            # feature = "compress" — CompressBuilder / DecompressBuilder
│   ├── sync.rs                 # feature = "sync" — SyncBuilder
│   └── permissions.rs          # feature = "permissions" — extends operations builders
├── tests/
│   └── integration/
└── examples/
    ├── basic_copy.rs
    └── with_diagnostics.rs     # requires --features diagnostics
```

`checksum` and `permissions` do **not** get their own top-level module with a
builder; they extend existing builders (`AnalyzeBuilder`, `CopyBuilder`,
`MoveBuilder`) via feature-gated `impl` blocks. See §8.3.

## 4. Cargo.toml — Feature Flags

```toml
[package]
name = "file-engine"
version = "0.1.0"
edition = "2021"
rust-version = "1.88"
license = "MIT"
description = "Async, cross-platform file operations engine for desktop apps and developer tools: copy, move, analyze, watch, compress, and sync files with progress reporting and cancellation."
repository = "https://github.com/<author>/file-engine"
readme = "README.md"
keywords = ["filesystem", "io", "async", "tokio", "copy"]
categories = ["filesystem", "asynchronous"]

[features]
default     = ["operations", "analyze"]

operations  = []
analyze     = ["dep:walkdir", "dep:infer"]
checksum    = ["analyze", "dep:blake3"]
watch       = ["dep:notify"]
compress    = ["dep:zip", "dep:flate2", "dep:walkdir"]
permissions = ["operations", "dep:nix"]
sync        = ["operations", "analyze"]
diagnostics = ["dep:error-engine"]

[dependencies]
tokio = { version = "1", features = ["fs", "rt", "rt-multi-thread", "macros", "sync", "io-util"] }
tokio-stream = "0.1"
tokio-util = { version = "0.7", features = ["rt"] }
thiserror = "1"

walkdir      = { version = "2",   optional = true }
infer        = { version = "0.16", optional = true }
blake3       = { version = "1",   optional = true }
notify       = { version = "6",   optional = true }
zip          = { version = "2",   optional = true }
flate2       = { version = "1",   optional = true }
error-engine = { version = "0.2", optional = true }

# `nix` is Unix-only (`#![cfg(unix)]` internally) — see §9.6. Declared as a
# target-specific dependency, not in `[dependencies]`, so enabling
# `permissions` on a non-Unix target doesn't try to pull in a crate that
# can't build there.
[target.'cfg(unix)'.dependencies]
nix = { version = "0.29", optional = true }

[dev-dependencies]
tokio = { version = "1", features = ["full"] }
tempfile = "3"
```

**Feature dependency tree (Cargo-enforced, not just convention):**

```
operations ◄── permissions   (permissions is also Unix-only, §9.6)
operations ◄── sync
analyze    ◄── checksum
analyze    ◄── sync
watch        (independent)
compress      (independent — but shares the `walkdir` *crate* with `analyze`
               for directory-tree archiving; that's a shared dependency, not
               a feature edge, so `compress` still doesn't cascade-enable
               `analyze`)
diagnostics   (independent)
```

`checksum` and `permissions` pull in their parent feature automatically —
`features = ["checksum"]` in a consumer's `Cargo.toml` cascades to enable
`analyze` as well. This means code inside `#[cfg(feature = "permissions")]`
blocks can assume `operations` types are available without an additional
runtime or compile-time check.

`thiserror`, `tokio`/`tokio-stream`, and `tokio-util` are **not** optional —
every feature combination needs `FileEngineError` and the async runtime, so
gating them would add complexity for no benefit. `tokio-util` specifically
backs `Handle<T>`'s `CancellationToken` (§7.1), and `Handle<T>` is
unconditionally compiled (§3) — an earlier draft gated `tokio-util` behind
the `operations` feature, which broke any build enabling `compress`,
`sync`, `watch`, or `diagnostics` without `operations` (`E0433`, no
`tokio_util` crate in scope). Keeping it a plain dependency avoids that
class of feature-combination bug entirely.

## 5. Error Handling

### 5.1 Base error type (always present)

`FileEngineError` is a plain `thiserror`-derived enum. It exists and is
fully usable regardless of which features are enabled — no feature gate on
the type itself.

```rust
use thiserror::Error;
use std::path::PathBuf;

#[derive(Debug, Error)]
pub enum FileEngineError {
    #[error("source not found: {0:?}")]
    SourceNotFound(PathBuf),

    #[error("destination already exists: {0:?}")]
    DestinationExists(PathBuf),

    #[error("operation cancelled")]
    Cancelled,

    #[error("insufficient disk space: needed {needed} bytes, available {available} bytes")]
    InsufficientSpace { needed: u64, available: u64 },

    #[error("permission denied: {0:?}")]
    PermissionDenied(PathBuf),

    #[error("io error on {path:?}: {source}")]
    Io {
        path: PathBuf,
        #[source]
        source: std::io::Error,
    },

    #[error("could not infer compression format from destination: {0:?}")]
    UnknownCompressFormat(PathBuf),

    #[error("gzip compression requires a single file, got a directory: {0:?}")]
    GzipRequiresFile(PathBuf),
}

pub type Result<T> = std::result::Result<T, FileEngineError>;
```

`UnknownCompressFormat` and `GzipRequiresFile` back the `compress`/`sync`
design in §8.4 — added here unconditionally rather than behind
`#[cfg(feature = "compress")]`, following the same "single enum, no
per-feature error enums" rule the `watch`-specific variant example already
implied. A build without `compress` enabled simply never constructs them.

Add variants as needed per feature (e.g. a `watch`-specific variant for a
failed OS-level watch registration), but keep them in this single enum —
do not create per-feature error enums.

### 5.2 Optional `diagnostics` feature — `error-engine` integration

`error-engine` (crates.io, currently v0.2.0) is a message-catalog +
presentation layer that sits on top of `thiserror`/`tracing`. It is **not**
a replacement for `thiserror` — `FileEngineError` above is unchanged whether
or not this feature is active.

When `diagnostics` is enabled:

```rust
#[cfg(feature = "diagnostics")]
use error_engine::{EngineDiagnostic, Severity};

#[cfg(feature = "diagnostics")]
impl EngineDiagnostic for FileEngineError {
    fn code(&self) -> &'static str {
        match self {
            Self::SourceNotFound(_) => "FE_SOURCE_NOT_FOUND",
            Self::DestinationExists(_) => "FE_DEST_EXISTS",
            Self::Cancelled => "FE_CANCELLED",
            Self::InsufficientSpace { .. } => "FE_NO_SPACE",
            Self::PermissionDenied(_) => "FE_PERMISSION_DENIED",
            Self::Io { .. } => "FE_IO",
            Self::UnknownCompressFormat(_) => "FE_UNKNOWN_COMPRESS_FORMAT",
            Self::GzipRequiresFile(_) => "FE_GZIP_REQUIRES_FILE",
        }
    }

    fn severity(&self) -> Severity {
        Severity::Error
    }

    fn context(&self) -> Vec<(&'static str, String)> {
        match self {
            Self::SourceNotFound(p) => vec![("path", p.display().to_string())],
            Self::DestinationExists(p) => vec![("path", p.display().to_string())],
            Self::InsufficientSpace { needed, available } => vec![
                ("needed", needed.to_string()),
                ("available", available.to_string()),
            ],
            Self::PermissionDenied(p) => vec![("path", p.display().to_string())],
            Self::Io { path, .. } => vec![("path", path.display().to_string())],
            Self::Cancelled => vec![],
            Self::UnknownCompressFormat(p) => vec![("path", p.display().to_string())],
            Self::GzipRequiresFile(p) => vec![("path", p.display().to_string())],
        }
    }
}

/// file-engine's own diagnostic catalog, embedded at compile time.
/// Consuming apps merge it into their own catalog:
///
/// ```ignore
/// let catalog = error_engine::Catalog::load_or_fallback("errors.toml")
///     .merged_with(file_engine::catalog());
/// ```
#[cfg(feature = "diagnostics")]
pub fn catalog() -> error_engine::Catalog {
    error_engine::Catalog::from_str(include_str!("../errors.toml"))
        .expect("file-engine's own catalog is valid TOML — covered by tests")
}
```

`errors.toml` at the crate root ships one entry per code above (`template`
+ optional `hint`), and is covered by a test that loads it via
`Catalog::from_str` and asserts it parses and that every `FE_*` code used in
`error.rs` has a matching entry (a simple string-matching test is enough —
no need for a proc macro).

**Why this shape:** `error-engine`'s own non-goals state there is no
automatic discovery of a dependency's catalog — merging is explicit via
`.merged_with(...)`. That's why `file-engine` exposes a plain `catalog()`
function rather than trying to auto-register anything.

## 6. Progress

```rust
#[derive(Debug, Clone)]
pub struct Progress {
    pub bytes_done: u64,
    pub bytes_total: u64,
    pub files_done: u64,
    pub files_total: u64,
    pub current_file: Option<PathBuf>,
}
```

Progress is delivered as a `Stream<Item = Progress>` (via
`tokio_stream::wrappers::UnboundedReceiverStream`), not a callback. This is
the idiomatic async-Rust choice and composes with `StreamExt` combinators
(`.throttle()`, etc.) for consumers who don't want to be flooded with
updates.

## 7. Builder + Handle Pattern

Every one-shot operation (`copy`, `move_path`, `analyze`, `compress`,
`sync`) follows the same shape: a chainable builder that configures the
operation, and a `.start()` that spawns it as a background `tokio` task and
returns a `Handle`. `watch` is a continuous stream of events with no single
final result, so it does *not* use `Handle<T>` — see §7.3.

```rust
let handle = engine
    .copy(src, dst)
    .overwrite(true)
    .cancellation_token(token)   // optional — engine generates one if omitted
    .start()?;

let mut progress = handle.progress(); // Stream<Item = Progress>
while let Some(p) = progress.next().await {
    // ...
}

let result: Result<()> = handle.await; // Handle implements Future
```

### 7.1 `Handle<T>`

A generic type in `handle.rs`, reused by every builder (`T` is the
operation's final output — `()` for copy/move, `Vec<FileInfo>` for analyze,
etc.):

```rust
pub struct Handle<T> {
    join: tokio::task::JoinHandle<Result<T>>,
    progress_rx: tokio_stream::wrappers::UnboundedReceiverStream<Progress>,
    cancel_token: tokio_util::sync::CancellationToken,
}

impl<T> Handle<T> {
    pub fn progress(&mut self) -> &mut UnboundedReceiverStream<Progress> { ... }
    pub fn cancel(&self) { self.cancel_token.cancel(); }
}

impl<T> Future for Handle<T> {
    type Output = Result<T>;
    // delegates to `self.join`
}
```

### 7.2 Builders

Each `*Builder` holds the parameters for its operation plus an optional
`CancellationToken`, and exposes `.start(self) -> Result<Handle<T>>`
(synchronous — spawning the task itself doesn't fail; errors surface
through the `Handle`). Builder-specific chainable methods (`.overwrite`,
`.recursive`, `.with_hash`, `.preserve_permissions`, etc.) are described per
feature in §8.

### 7.3 `watch``WatchBuilder` / `WatchHandle` (resolves §9.1)

`watch` follows the same *builder* half of the pattern but does not return a
`Handle<T>` — there is no single final `T` to resolve to, only an ongoing
stream of filesystem events. Instead it has its own sibling handle type,
`WatchHandle`, deliberately not a generalization of `Handle<T>` (forcing a
continuous stream into a type built around "one background task, one final
result" would leak awkwardness into every other builder).

```rust
let mut watch_handle = engine
    .watch(path)
    .recursive(true)              // default: true, matches AnalyzeBuilder
    .cancellation_token(token)    // optional, same as every other builder
    .start()?;

let mut events = watch_handle.events(); // Stream<Item = WatchEvent>
while let Some(event) = events.next().await {
    // ...
}

// Optional: detect the watch dying (cancelled, or an unrecoverable
// OS-level watch error). Not required for the common case — `events()`
// is the primary interface, unlike `Handle<T>` where `.await` is the point.
let result: Result<()> = watch_handle.await;
```

`WatchEvent` abstracts over `notify`'s event types so `notify` does not leak
through the public API:

```rust
pub enum WatchEventKind {
    Created,
    Modified,
    Removed,
    Other,
}

pub struct WatchEvent {
    pub kind: WatchEventKind,
    pub paths: Vec<PathBuf>,
}
```

`WatchHandle` mirrors `Handle<T>`'s shape and naming where the concepts
line up (`.cancel()`, a `Future` impl for optional awaiting) but swaps
`.progress()` for `.events()`:

```rust
pub struct WatchHandle {
    join: tokio::task::JoinHandle<Result<()>>,
    events_rx: tokio_stream::wrappers::UnboundedReceiverStream<WatchEvent>,
    cancel_token: tokio_util::sync::CancellationToken,
}

impl WatchHandle {
    pub fn events(&mut self) -> &mut UnboundedReceiverStream<WatchEvent> { ... }
    pub fn cancel(&self) { self.cancel_token.cancel(); }
}

impl Future for WatchHandle {
    type Output = Result<()>;
    // Resolves only when the watch task ends: via cancel() or an
    // unrecoverable OS-level watch error. Delegates to `self.join` the
    // same way `Handle<T>` does.
}
```

Internally, `WatchBuilder::start()` spawns a task that owns a
`notify::RecommendedWatcher`, maps raw `notify` events to `WatchEvent`, and
forwards them into the channel backing `events_rx` until cancelled or the
watcher errors — the same "spawn + channel + `CancellationToken`" plumbing
`Handle<T>`-based builders use, just without a meaningful final `T`.

## 8. Public API Surface — `FileEngine`

### 8.1 Base struct (always present)

```rust
pub struct FileEngine {
    default_options: EngineOptions,
}

#[derive(Debug, Clone)]
pub struct EngineOptions {
    pub buffer_size: usize,
    pub follow_symlinks: bool,
}

impl Default for EngineOptions {
    fn default() -> Self {
        Self { buffer_size: 1024 * 1024, follow_symlinks: false }
    }
}

impl FileEngine {
    pub fn new() -> Self {
        Self { default_options: EngineOptions::default() }
    }

    pub fn with_options(options: EngineOptions) -> Self {
        Self { default_options: options }
    }

    pub fn options(&self) -> &EngineOptions {
        &self.default_options
    }
}
```

Keep this struct holding only shared config. Do not add feature-specific
state here (e.g. no `notify` watcher handles) — those live inside the
feature's own builder/handle types.

### 8.2 Feature-gated `impl` blocks

One `impl FileEngine` block per feature, each in its own module and gated
with `#[cfg(feature = "...")]` at the `impl` level:

```rust
#[cfg(feature = "operations")]
impl FileEngine {
    pub fn copy(&self, src: impl Into<PathBuf>, dst: impl Into<PathBuf>) -> CopyBuilder { ... }
    pub fn move_path(&self, src: impl Into<PathBuf>, dst: impl Into<PathBuf>) -> MoveBuilder { ... }
}

#[cfg(feature = "analyze")]
impl FileEngine {
    pub fn analyze(&self, path: impl Into<PathBuf>) -> AnalyzeBuilder { ... }
}

#[cfg(feature = "watch")]
impl FileEngine {
    pub fn watch(&self, path: impl Into<PathBuf>) -> WatchBuilder { ... }
}

#[cfg(feature = "compress")]
impl FileEngine {
    pub fn compress(&self, src: impl Into<PathBuf>, dst: impl Into<PathBuf>) -> CompressBuilder { ... }
    pub fn decompress(&self, src: impl Into<PathBuf>, dst: impl Into<PathBuf>) -> DecompressBuilder { ... }
}

#[cfg(feature = "sync")]
impl FileEngine {
    pub fn sync(&self, src: impl Into<PathBuf>, dst: impl Into<PathBuf>) -> SyncBuilder { ... }
}
```

The corresponding module (`mod operations;`, `mod analyze;`, etc.) in
`lib.rs` must also be feature-gated, so the code isn't even parsed when the
feature is off:

```rust
#[cfg(feature = "operations")] mod operations;
#[cfg(feature = "analyze")]    mod analyze;
#[cfg(feature = "watch")]      mod watch;
#[cfg(feature = "compress")]   mod compress;
#[cfg(feature = "sync")]       mod sync;
// `permissions` is also gated on `unix`, not just the feature — see §9.6.
#[cfg(all(feature = "permissions", unix))] mod permissions;
```

### 8.3 `checksum` and `permissions` — enhancers, not their own surface

These two features do not add a method to `FileEngine`. Instead they add
methods to *existing* builders, conditionally:

```rust
#[cfg(feature = "checksum")]
impl AnalyzeBuilder {
    pub fn with_hash(mut self, enabled: bool) -> Self { ... }
}

#[cfg(feature = "permissions")]
impl CopyBuilder {
    pub fn preserve_permissions(mut self, enabled: bool) -> Self { ... }
}

#[cfg(feature = "permissions")]
impl MoveBuilder {
    pub fn preserve_permissions(mut self, enabled: bool) -> Self { ... }
}
```

Because `checksum` requires `analyze` and `permissions` requires
`operations` at the manifest level (§4), these `impl` blocks can rely on
`AnalyzeBuilder`/`CopyBuilder`/`MoveBuilder` always being compiled when they
themselves are compiled.

### 8.4 `CompressBuilder` / `DecompressBuilder` internals (resolves §9.2, compress half)

Two distinct formats, not one generic "compress" — driven by the
dependencies already locked into §4 (`zip`, `flate2`; deliberately no
`tar`):

```rust
pub enum CompressFormat {
    Zip,
    Gzip,
}
```

- **`Zip`** (via the `zip` crate): multi-entry archive. Handles a single
  file or a whole directory tree, preserving structure. The general case.
- **`Gzip`** (via `flate2`): a single compressed stream. Because there is no
  `tar` dependency, there is no way to fold a directory into one `.gz` the
  way `tar.gz` would — `Gzip` only accepts a single file as source.

```rust
#[cfg(feature = "compress")]
impl FileEngine {
    pub fn compress(&self, src: impl Into<PathBuf>, dst: impl Into<PathBuf>) -> CompressBuilder { ... }
    pub fn decompress(&self, src: impl Into<PathBuf>, dst: impl Into<PathBuf>) -> DecompressBuilder { ... }
}

impl CompressBuilder {
    /// Optional — inferred from `dst`'s extension when omitted
    /// (`.zip` → `Zip`, `.gz` → `Gzip`).
    pub fn format(mut self, format: CompressFormat) -> Self { ... }
    pub fn cancellation_token(mut self, token: CancellationToken) -> Self { ... }
    pub fn start(self) -> Result<Handle<()>> { ... }
}

impl DecompressBuilder {
    /// Optional — inferred from `src`'s extension when omitted.
    pub fn format(mut self, format: CompressFormat) -> Self { ... }
    pub fn cancellation_token(mut self, token: CancellationToken) -> Self { ... }
    pub fn start(self) -> Result<Handle<()>> { ... }
}
```

Both builders follow §7.2 exactly (`.start()` is synchronous and cannot
fail; format-inference failure and the `Gzip`-on-a-directory case surface
through the `Handle` as `FileEngineError::UnknownCompressFormat` /
`FileEngineError::GzipRequiresFile`, §5.1) — not as a panic or a
synchronous `Result` from `.format()`/`.start()` itself, so error handling
stays uniform with every other builder in the crate.

### 8.5 `SyncBuilder` internals (resolves §9.2, sync half)

`sync` is a **one-directional mirror**: it makes `dst` look like `src`, not
a bidirectional reconciliation. That's the simplest interpretation that
still needs a real design, and matches what `sync` conventionally means in
tools like `rsync`.

```rust
pub struct SyncSummary {
    pub copied: u64,
    pub updated: u64,
    pub deleted: u64,
    pub skipped: u64,
}

#[cfg(feature = "sync")]
impl FileEngine {
    pub fn sync(&self, src: impl Into<PathBuf>, dst: impl Into<PathBuf>) -> SyncBuilder { ... }
}

impl SyncBuilder {
    /// Default: false. When true, files present in `dst` but not in `src`
    /// are removed — full mirror rather than additive-only sync.
    pub fn delete_extraneous(mut self, enabled: bool) -> Self { ... }
    pub fn cancellation_token(mut self, token: CancellationToken) -> Self { ... }
    pub fn start(self) -> Result<Handle<SyncSummary>> { ... }
}
```

Unlike `copy`/`move` (`Handle<()>`), `sync` resolves to `SyncSummary` — a
meaningful final `T`, the same way `analyze` resolves to `Vec<FileInfo>`
rather than `()`.

Change detection defaults to size + mtime comparison (cheap, adds no
dependency — `sync` already requires `analyze` at the manifest level, §4,
so it can reuse that walk). When `checksum` is *also* enabled — a legal
combination, since both `sync` and `checksum` independently depend on
`analyze` rather than on each other — an enhancer impl adds hash-based
comparison, following the same pattern as `AnalyzeBuilder::with_hash` in
§8.3:

```rust
#[cfg(all(feature = "sync", feature = "checksum"))]
impl SyncBuilder {
    /// Default: false (size + mtime). When true, compares file content
    /// hashes instead — more expensive, immune to mtime-only changes.
    pub fn compare_by_hash(mut self, enabled: bool) -> Self { ... }
}
```

## 9. Open Design Questions (not yet settled — resolve before/while implementing)

These were flagged during design but not closed. Do not silently invent an
answer that contradicts the patterns above — if unsure, follow the closest
existing pattern and leave a `// TODO(design):` comment rather than
guessing silently.

1. ~~**`WatchBuilder` shape.**~~ **Resolved** — see §7.3. `.start()` returns
   `WatchHandle` (not `Handle<T>`), whose `.events()` returns
   `Stream<Item = WatchEvent>`; its `Future` impl resolves only when the
   watch task ends (cancellation or an unrecoverable watch error).

2. ~~**`CompressBuilder` / `DecompressBuilder` and `SyncBuilder` internals.**~~
   **Resolved** — see §8.4 (compress: `Zip` via the `zip` crate for
   files/directories, `Gzip` via `flate2` for single files only, format
   inferred from the path extension unless set explicitly) and §8.5 (sync:
   one-directional mirror to `Handle<SyncSummary>`, size+mtime comparison by
   default with a `checksum`-gated `.compare_by_hash()` enhancer).

3. ~~**License.**~~ **Resolved** — MIT only (not the dual `MIT OR
   Apache-2.0` this document originally assumed as the ecosystem default).
   `license = "MIT"` in `Cargo.toml` (§4), single `LICENSE` file at the
   crate root (§3).

4. ~~**MSRV.**~~ **Resolved**`rust-version = "1.88"` (§4). Not an
   editorial "last N releases" pick — it's the actual floor of the locked
   dependency graph as of this writing (`cargo tree --all-features`
   resolved against `Cargo.lock`).

   The binding constraint is transitive, not any direct dependency: `time`
   v0.3.53 declares `rust-version = "1.88.0"` and is pulled in because
   `zip`'s `default` feature set includes `time` (archive timestamp
   support). Direct dependencies are all lower (`tokio`/`tokio-stream`/
   `tokio-util` 1.71, `zip` itself 1.73.0, `flate2` 1.67.0, `nix` 1.69,
   `notify` 1.60, `thiserror` 1.61; `walkdir`/`infer`/`blake3`/
   `error-engine` declare none). The next-highest tier sits at 1.85
   (`hashbrown`/`indexmap`/`uuid`/`zeroize`/`getrandom`, via
   `infer → cfb → uuid` and `error-engine → toml → indexmap`) — dropping
   `zip`'s `time` feature to shed the 1.88 requirement would only buy back
   1.88 → 1.85, not lower, and costs zip archive timestamp support, so it
   isn't worth it.

   Caveat: every dependency in §4 is version-ranged (`"1"`, `"2"`, etc.),
   so a future `cargo update` can silently raise a transitive dep's own
   `rust-version` past 1.88 without any change to this crate's
   `Cargo.toml`. An MSRV check in CI (item 5, below) is what catches that
   drift — `rust-version` here is a floor as of *today's* lockfile, not a
   guarantee that stays true after every future `cargo update`.

5. ~~**CI.**~~ **Resolved** — see §11 for the full workflow
   (`.github/workflows/ci.yml`): `fmt`, `clippy` + `test` across a 3-OS
   matrix, `cargo hack --feature-powerset --depth 2` for feature-cfg
   coverage, and a pinned MSRV job. Surfaced item 6 (`permissions` /
   Windows) as a prerequisite while designing this.

6. ~~**`permissions` feature and Windows.**~~ **Resolved**`permissions`
   is Unix-only. §2 states cross-platform Linux/macOS/Windows as a goal,
   but `permissions`'s only dependency, `nix`, declares `#![cfg(unix)]` at
   its own crate root — it cannot build for Windows at all. Rather than let
   that surface as a confusing failure the moment `permissions.rs` grows a
   real implementation, it's now an explicit, structural exclusion:
   - `nix` moved from `[dependencies]` to `[target.'cfg(unix)'.dependencies]`
     (§4) — enabling `permissions` on a non-Unix target no longer tries to
     pull in a crate that can't build there.
   - `mod permissions;` in `lib.rs` is gated on `#[cfg(all(feature =
     "permissions", unix))]`, not just the feature (§8.2) — so on Windows,
     `.preserve_permissions()` simply doesn't exist on `CopyBuilder`/
     `MoveBuilder` at all. A consumer who calls it on Windows gets a clear
     "method not found" compile error, not a silent no-op that quietly
     fails to preserve permissions.
   - Verified by cross-checking against the `x86_64-pc-windows-gnu` target:
     `--features permissions` compiles (the feature request is inert off
     of Unix — Cargo doesn't hard-error, it just never activates `nix`),
     while the `permissions` module and its API are absent, confirmed by
     inspecting the compiled output. `--all-features` is therefore
     Unix-only in practice; Windows CI (§11) uses an explicit feature list
     that excludes `permissions` instead.

## 10. Implementation Conventions

Follow the author's general Rust conventions:

- **Errors:** `thiserror` for this library's typed errors (§5); never
  `anyhow` inside the library itself (`anyhow` is for application code, not
  crates like this one).
- **Async:** `tokio` with the features actually needed (not blanket
  `"full"` in the published crate's own `[dependencies]` — reserve `"full"`
  for `[dev-dependencies]`/examples).
- **Logging:** `tracing`, structured (`tracing::instrument`, key-value
  fields), never `println!` for observability. Note: this is orthogonal to
  `error-engine`'s `Engine::log()` — internal `tracing` calls for debugging
  the crate's own operations are independent of whatever diagnostic
  presentation a consumer layers on top via `diagnostics`.
- **Formatting/linting:** default `rustfmt`; `clippy` clean, no blanket
  `#[allow(...)]`; `.cargo/config.toml` with `rustflags = ["-D", "warnings"]`.
- **Tests:** unit tests colocated (`#[cfg(test)]`) per module; integration
  tests in `tests/integration/` using `tempfile` for filesystem fixtures;
  `#[tokio::test]` for async tests.
- **`main.rs`:** none — this is a library crate (`src/lib.rs` only, plus
  `examples/`).

## 11. CI (resolves §9.5)

`.github/workflows/ci.yml`, five jobs, all verified to actually pass
against the state of the crate as of this writing (not just written and
assumed correct):

- **`fmt`**`cargo fmt --all -- --check`. Ubuntu only; formatting isn't
  platform-dependent.
- **`clippy`** — 3-OS matrix (Ubuntu/macOS/Windows). `--all-features
  --all-targets -- -D warnings` on Unix; on Windows, an explicit feature
  list (`operations,analyze,checksum,watch,compress,sync,diagnostics`)
  that excludes `permissions` (§9.6) — `--all-features` isn't meaningful
  on Windows since one of the features can't exist there.
- **`test`** — same 3-OS matrix, same Windows feature-list exception. Runs
  `cargo test` twice per OS: once with default features, once with
  "all features valid for this OS". This is the job that actually catches
  platform-specific runtime bugs, not just compile-time `#[cfg]` mistakes.
- **`feature-matrix`**`cargo hack check --feature-powerset --depth 2
  --no-dev-deps`, Ubuntu only. Every single feature and every *pair* of
  features (36 combinations for 8 features, verified locally: all 40
  invocations `cargo-hack` actually runs, including the `default`
  pseudo-feature, passed). This directly satisfies §9's original ask for
  "a curated subset of combinations covering each dependency edge" —
  full pairwise coverage is a stronger, more systematic version of that
  rather than a hand-picked list, for a crate this size still fast enough
  to run on every push. Deliberately Ubuntu-only (Unix) — this job is a
  feature-`#[cfg]` correctness sweep, not a cross-platform check (`test`
  already does that), so it can safely include `permissions`.
- **`msrv`** — pinned to `rust-version` from `Cargo.toml` (currently 1.88,
  §9.4) via `dtolnay/rust-toolchain@1.88`, `cargo check --all-features`.
  Exists specifically to catch the drift scenario described in §9.4's
  resolution: a `cargo update` silently raising a transitive dependency's
  own MSRV past what this crate declares.

Not covered by this workflow, deliberately deferred: publishing/release
automation (`cargo publish`, changelog generation) and doc-coverage checks
— neither was asked for, and adding them now would be scope creep beyond
what §9.5 asked to resolve.