file_engine/progress.rs
1use std::path::PathBuf;
2
3use tokio::sync::mpsc;
4
5use crate::profiler::Entry;
6
7/// Discrete per-entry events rather than a cumulative snapshot;
8/// `EntryFailed` carries only the `Entry`, not the `Error` — `Error` isn't
9/// `Clone` (it wraps `std::io::Error`), and the failure detail is already
10/// available from the operation's final `OperationOutcome.failed` once the
11/// handle resolves.
12/// `#[non_exhaustive]`: adding a variant here is otherwise a breaking
13/// change for any downstream exhaustive `match`, which is exactly what
14/// adding `Planned` was. Marked now so the next addition isn't.
15#[derive(Debug, Clone)]
16#[non_exhaustive]
17pub enum Progress {
18 /// The shape of the work about to be performed, emitted once per
19 /// phase *before* `DirectoriesStarted` and `Started` — i.e. before
20 /// the directory pre-pass that `Started` doesn't cover.
21 ///
22 /// Exists for cost estimation (`EtaEstimator`): the small/large
23 /// split is the difference between work whose cost is per-file
24 /// (syscall-bound) and work whose cost is per-byte (bandwidth-bound),
25 /// and `Started`'s single `bytes_total` can't distinguish them. A
26 /// consumer that only wants a progress bar can ignore this variant
27 /// entirely.
28 ///
29 /// Not emitted by the delete sweeps (`sync`'s orphan sweep,
30 /// `move_path`'s source cleanup) — those are metadata-only phases
31 /// with no byte-sized work to model, so they emit a bare `Started`.
32 Planned {
33 /// Directories needing an explicit `create_dir_all` — already
34 /// filtered to those with no file beneath them (see
35 /// `pipeline.rs`'s `directories_covered_by_files`), so this
36 /// matches the `DirectoriesStarted { total }` that follows
37 /// rather than the total directory count in the source tree.
38 directories: usize,
39 small_files: usize,
40 small_bytes: u64,
41 large_files: usize,
42 large_bytes: u64,
43 /// The threshold that produced the split above. Carried so a
44 /// consumer can classify each subsequent `Entry` the same way
45 /// the planner did, without having to know what the builder was
46 /// configured with.
47 small_file_threshold: u64,
48 },
49 /// Emitted once per phase, before any entries in that phase start.
50 /// `bytes_total` is `None` for phases with nothing byte-sized to
51 /// report (the delete sweeps). Can be emitted more than once per
52 /// operation — `sync` emits it once per phase (copy, then delete).
53 Started {
54 bytes_total: Option<u64>,
55 entries_total: usize,
56 },
57 EntryStarted {
58 entry: Entry,
59 },
60 /// Bytes written so far for an entry still in flight, sampled by
61 /// watching the destination file grow. Emitted only for large
62 /// (streamed) entries, and only while they take long enough to be
63 /// sampled at all — a copy that the filesystem satisfies by
64 /// copy-on-write finishes before the first sample and emits none.
65 ///
66 /// `bytes_copied` is cumulative, not a delta, and is clamped to the
67 /// entry's size. It is monotonically non-decreasing per entry.
68 ///
69 /// Exists because `tokio::fs::copy` is opaque while it runs: without
70 /// this, a single large file emits `EntryStarted` and then nothing
71 /// until it finishes, so its transfer rate is unmeasurable for exactly
72 /// as long as it takes to copy.
73 EntryProgress {
74 entry: Entry,
75 bytes_copied: u64,
76 },
77 EntryCompleted {
78 entry: Entry,
79 },
80 /// Emitted instead of `EntryCompleted` when the destination already
81 /// held byte-identical content, so the entry was left untouched
82 /// rather than copied (`.skip_if_identical()`, `checksum` feature).
83 EntrySkipped {
84 entry: Entry,
85 },
86 EntryFailed {
87 entry: Entry,
88 },
89 /// The directory-creation pre-pass (`operations/pipeline.rs`'s
90 /// `ensure_directories_exist`), separate from `Started`/`Entry*`
91 /// since it operates on `DirEntry`, not `Entry` — carrying only the
92 /// destination path (not the full `DirEntry`) is enough for a
93 /// caller to show "N/M directories created" without extending
94 /// every `Entry`-typed variant to also accept `DirEntry`. Added
95 /// after a real run against a USB-connected exFAT drive spent about
96 /// a minute silently creating ~7,700 directories one at a time
97 /// before `Started` (for the file-copy phase) was ever emitted.
98 DirectoriesStarted {
99 total: usize,
100 },
101 DirectoryCompleted {
102 path: PathBuf,
103 },
104 DirectoryFailed {
105 path: PathBuf,
106 },
107}
108
109/// `Send + Sync + Clone` sender wrapper threaded into every execution
110/// path that processes entries. Backed by an unbounded channel
111/// specifically so `.send()` is synchronous, not `async` — needed
112/// because `compress.rs`'s writer runs inside `spawn_blocking`, a
113/// non-async context that can't await a bounded channel's backpressure.
114#[derive(Clone)]
115pub(crate) struct ProgressReporter {
116 tx: mpsc::UnboundedSender<Progress>,
117}
118
119impl ProgressReporter {
120 pub(crate) fn new(tx: mpsc::UnboundedSender<Progress>) -> Self {
121 Self { tx }
122 }
123
124 /// A reporter whose receiver is immediately dropped — for tests that
125 /// don't care about progress and don't want to plumb a receiver
126 /// through just to ignore it.
127 #[cfg(test)]
128 pub(crate) fn noop() -> Self {
129 let (tx, _rx) = mpsc::unbounded_channel();
130 Self { tx }
131 }
132
133 /// A closed receiver means nobody's listening (the caller never
134 /// called `.progress()`, or dropped the stream) — not a failure,
135 /// just nothing to report to.
136 pub(crate) fn send(&self, progress: Progress) {
137 let _ = self.tx.send(progress);
138 }
139}
140
141#[cfg(test)]
142mod tests {
143 use std::path::PathBuf;
144
145 use super::*;
146
147 fn entry(name: &str) -> Entry {
148 Entry {
149 path: PathBuf::from(name),
150 relative_path: PathBuf::from(name),
151 size: 1,
152 modified: None,
153 }
154 }
155
156 #[test]
157 fn send_after_receiver_dropped_does_not_panic() {
158 let (tx, rx) = mpsc::unbounded_channel();
159 let reporter = ProgressReporter::new(tx);
160 drop(rx);
161
162 reporter.send(Progress::EntryStarted { entry: entry("a") });
163 }
164
165 #[tokio::test]
166 async fn sends_are_received_in_order() {
167 let (tx, mut rx) = mpsc::unbounded_channel();
168 let reporter = ProgressReporter::new(tx);
169
170 reporter.send(Progress::Started {
171 bytes_total: Some(10),
172 entries_total: 2,
173 });
174 reporter.send(Progress::EntryStarted { entry: entry("a") });
175 reporter.send(Progress::EntryCompleted { entry: entry("a") });
176
177 drop(reporter); // otherwise the last recv() below blocks forever
178
179 assert!(matches!(rx.recv().await, Some(Progress::Started { .. })));
180 assert!(matches!(
181 rx.recv().await,
182 Some(Progress::EntryStarted { .. })
183 ));
184 assert!(matches!(
185 rx.recv().await,
186 Some(Progress::EntryCompleted { .. })
187 ));
188 assert!(rx.recv().await.is_none());
189 }
190}