Skip to main content

file_engine/
progress.rs

1use std::path::PathBuf;
2
3use tokio::sync::mpsc;
4
5use crate::profiler::Entry;
6
7/// See dev-docs/design/handle-progress.md. Discrete per-entry events rather
8/// than a cumulative snapshot; `EntryFailed` carries only the `Entry`,
9/// not the `Error` — `Error` isn't `Clone` (it wraps `std::io::Error`),
10/// and the failure detail is already available from the operation's
11/// final `OperationOutcome.failed` once the handle resolves.
12#[derive(Debug, Clone)]
13pub enum Progress {
14    /// Emitted once per phase, before any entries in that phase start.
15    /// `bytes_total` is `None` for phases with nothing byte-sized to
16    /// report (the delete sweeps). Can be emitted more than once per
17    /// operation — `sync` emits it once per phase (copy, then delete).
18    Started { bytes_total: Option<u64>, entries_total: usize },
19    EntryStarted { entry: Entry },
20    EntryCompleted { entry: Entry },
21    EntryFailed { entry: Entry },
22    /// The directory-creation pre-pass (`operations/pipeline.rs`'s
23    /// `ensure_directories_exist`), separate from `Started`/`Entry*`
24    /// since it operates on `DirEntry`, not `Entry` — carrying only the
25    /// destination path (not the full `DirEntry`) is enough for a
26    /// caller to show "N/M directories created" without extending
27    /// every `Entry`-typed variant to also accept `DirEntry`. Added
28    /// after a real run against a USB-connected exFAT drive spent about
29    /// a minute silently creating ~7,700 directories one at a time
30    /// before `Started` (for the file-copy phase) was ever emitted —
31    /// see dev-docs/design/handle-progress.md.
32    DirectoriesStarted { total: usize },
33    DirectoryCompleted { path: PathBuf },
34    DirectoryFailed { path: PathBuf },
35}
36
37/// `Send + Sync + Clone` sender wrapper threaded into every execution
38/// path that processes entries. Backed by an unbounded channel
39/// specifically so `.send()` is synchronous, not `async` — needed
40/// because `compress.rs`'s writer runs inside `spawn_blocking`, a
41/// non-async context that can't await a bounded channel's backpressure.
42#[derive(Clone)]
43pub(crate) struct ProgressReporter {
44    tx: mpsc::UnboundedSender<Progress>,
45}
46
47impl ProgressReporter {
48    pub(crate) fn new(tx: mpsc::UnboundedSender<Progress>) -> Self {
49        Self { tx }
50    }
51
52    /// A reporter whose receiver is immediately dropped — for tests that
53    /// don't care about progress and don't want to plumb a receiver
54    /// through just to ignore it.
55    #[cfg(test)]
56    pub(crate) fn noop() -> Self {
57        let (tx, _rx) = mpsc::unbounded_channel();
58        Self { tx }
59    }
60
61    /// A closed receiver means nobody's listening (the caller never
62    /// called `.progress()`, or dropped the stream) — not a failure,
63    /// just nothing to report to.
64    pub(crate) fn send(&self, progress: Progress) {
65        let _ = self.tx.send(progress);
66    }
67}
68
69#[cfg(test)]
70mod tests {
71    use std::path::PathBuf;
72
73    use super::*;
74
75    fn entry(name: &str) -> Entry {
76        Entry {
77            path: PathBuf::from(name),
78            relative_path: PathBuf::from(name),
79            size: 1,
80            modified: None,
81        }
82    }
83
84    #[test]
85    fn send_after_receiver_dropped_does_not_panic() {
86        let (tx, rx) = mpsc::unbounded_channel();
87        let reporter = ProgressReporter::new(tx);
88        drop(rx);
89
90        reporter.send(Progress::EntryStarted { entry: entry("a") });
91    }
92
93    #[tokio::test]
94    async fn sends_are_received_in_order() {
95        let (tx, mut rx) = mpsc::unbounded_channel();
96        let reporter = ProgressReporter::new(tx);
97
98        reporter.send(Progress::Started { bytes_total: Some(10), entries_total: 2 });
99        reporter.send(Progress::EntryStarted { entry: entry("a") });
100        reporter.send(Progress::EntryCompleted { entry: entry("a") });
101
102        drop(reporter); // otherwise the last recv() below blocks forever
103
104        assert!(matches!(rx.recv().await, Some(Progress::Started { .. })));
105        assert!(matches!(rx.recv().await, Some(Progress::EntryStarted { .. })));
106        assert!(matches!(rx.recv().await, Some(Progress::EntryCompleted { .. })));
107        assert!(rx.recv().await.is_none());
108    }
109}