Skip to main content

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    EntryFailed {
81        entry: Entry,
82    },
83    /// The directory-creation pre-pass (`operations/pipeline.rs`'s
84    /// `ensure_directories_exist`), separate from `Started`/`Entry*`
85    /// since it operates on `DirEntry`, not `Entry` — carrying only the
86    /// destination path (not the full `DirEntry`) is enough for a
87    /// caller to show "N/M directories created" without extending
88    /// every `Entry`-typed variant to also accept `DirEntry`. Added
89    /// after a real run against a USB-connected exFAT drive spent about
90    /// a minute silently creating ~7,700 directories one at a time
91    /// before `Started` (for the file-copy phase) was ever emitted.
92    DirectoriesStarted {
93        total: usize,
94    },
95    DirectoryCompleted {
96        path: PathBuf,
97    },
98    DirectoryFailed {
99        path: PathBuf,
100    },
101}
102
103/// `Send + Sync + Clone` sender wrapper threaded into every execution
104/// path that processes entries. Backed by an unbounded channel
105/// specifically so `.send()` is synchronous, not `async` — needed
106/// because `compress.rs`'s writer runs inside `spawn_blocking`, a
107/// non-async context that can't await a bounded channel's backpressure.
108#[derive(Clone)]
109pub(crate) struct ProgressReporter {
110    tx: mpsc::UnboundedSender<Progress>,
111}
112
113impl ProgressReporter {
114    pub(crate) fn new(tx: mpsc::UnboundedSender<Progress>) -> Self {
115        Self { tx }
116    }
117
118    /// A reporter whose receiver is immediately dropped — for tests that
119    /// don't care about progress and don't want to plumb a receiver
120    /// through just to ignore it.
121    #[cfg(test)]
122    pub(crate) fn noop() -> Self {
123        let (tx, _rx) = mpsc::unbounded_channel();
124        Self { tx }
125    }
126
127    /// A closed receiver means nobody's listening (the caller never
128    /// called `.progress()`, or dropped the stream) — not a failure,
129    /// just nothing to report to.
130    pub(crate) fn send(&self, progress: Progress) {
131        let _ = self.tx.send(progress);
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use std::path::PathBuf;
138
139    use super::*;
140
141    fn entry(name: &str) -> Entry {
142        Entry {
143            path: PathBuf::from(name),
144            relative_path: PathBuf::from(name),
145            size: 1,
146            modified: None,
147        }
148    }
149
150    #[test]
151    fn send_after_receiver_dropped_does_not_panic() {
152        let (tx, rx) = mpsc::unbounded_channel();
153        let reporter = ProgressReporter::new(tx);
154        drop(rx);
155
156        reporter.send(Progress::EntryStarted { entry: entry("a") });
157    }
158
159    #[tokio::test]
160    async fn sends_are_received_in_order() {
161        let (tx, mut rx) = mpsc::unbounded_channel();
162        let reporter = ProgressReporter::new(tx);
163
164        reporter.send(Progress::Started {
165            bytes_total: Some(10),
166            entries_total: 2,
167        });
168        reporter.send(Progress::EntryStarted { entry: entry("a") });
169        reporter.send(Progress::EntryCompleted { entry: entry("a") });
170
171        drop(reporter); // otherwise the last recv() below blocks forever
172
173        assert!(matches!(rx.recv().await, Some(Progress::Started { .. })));
174        assert!(matches!(
175            rx.recv().await,
176            Some(Progress::EntryStarted { .. })
177        ));
178        assert!(matches!(
179            rx.recv().await,
180            Some(Progress::EntryCompleted { .. })
181        ));
182        assert!(rx.recv().await.is_none());
183    }
184}