bnto_core/progress.rs
1// =============================================================================
2// Progress Reporting — How Nodes Talk to the UI
3// =============================================================================
4//
5// Target-agnostic progress reporting. Uses a plain Rust closure instead of
6// `js_sys::Function` so bnto-core stays platform-independent. The WASM-specific
7// wrapping lives in each node crate's `wasm_bridge.rs`.
8
9// =============================================================================
10// ProgressReporter
11// =============================================================================
12
13/// Reports processing progress from a node back to the caller (UI, CLI, etc.).
14///
15/// Wraps an optional boxed closure. `Some(callback)` for real reporting,
16/// `None` for no-op mode (used in tests).
17pub struct ProgressReporter {
18 /// The callback function. When we call it, it sends a progress update
19 /// to wherever the caller wants (UI thread, console, etc.).
20 /// `None` = no-op mode (for tests or when progress isn't needed).
21 #[allow(clippy::type_complexity)]
22 callback: Option<Box<dyn Fn(u32, &str)>>,
23 /// Optional callback for streaming command output lines (stderr from child processes).
24 #[allow(clippy::type_complexity)]
25 output_callback: Option<Box<dyn Fn(&str)>>,
26}
27
28impl ProgressReporter {
29 /// Create a new ProgressReporter with a callback function.
30 ///
31 /// The callback receives two arguments:
32 /// 1. progress (u32, 0-100) — percentage complete
33 /// 2. message (&str) — human-readable status text
34 ///
35 /// USAGE:
36 /// ```rust
37 /// use bnto_core::ProgressReporter;
38 ///
39 /// // Simple logger
40 /// let reporter = ProgressReporter::new(|percent, message| {
41 /// println!("{}% — {}", percent, message);
42 /// });
43 ///
44 /// // In a WASM bridge, wrap a js_sys::Function:
45 /// // let reporter = ProgressReporter::new(move |percent, message| {
46 /// // let _ = js_callback.call2(&JsValue::NULL, &percent.into(), &message.into());
47 /// // });
48 /// ```
49 pub fn new(callback: impl Fn(u32, &str) + 'static) -> Self {
50 Self {
51 callback: Some(Box::new(callback)),
52 output_callback: None,
53 }
54 }
55
56 /// Create a no-op reporter that discards all progress updates.
57 /// Used in tests where we don't need progress reporting.
58 pub fn new_noop() -> Self {
59 Self {
60 callback: None,
61 output_callback: None,
62 }
63 }
64
65 /// Create a reporter with both progress and output callbacks.
66 ///
67 /// The output callback receives streaming lines from child process stderr
68 /// (e.g. yt-dlp download progress). Used by the executor to relay
69 /// command output to PipelineEvent::CommandOutput.
70 pub fn with_output(
71 callback: impl Fn(u32, &str) + 'static,
72 output_callback: impl Fn(&str) + 'static,
73 ) -> Self {
74 Self {
75 callback: Some(Box::new(callback)),
76 output_callback: Some(Box::new(output_callback)),
77 }
78 }
79
80 /// Report progress to the caller.
81 ///
82 /// Arguments:
83 /// - `percent` — how far along we are (0 to 100)
84 /// - `message` — what we're currently doing ("Compressing image 3/10...")
85 pub fn report(&self, percent: u32, message: &str) {
86 if let Some(cb) = &self.callback {
87 cb(percent, message);
88 }
89 }
90
91 /// Report a line of streaming output from a child process.
92 ///
93 /// Called by processors that use `run_command_streaming()` to relay
94 /// stderr lines (e.g. yt-dlp progress) to the pipeline reporter.
95 pub fn report_output(&self, line: &str) {
96 if let Some(cb) = &self.output_callback {
97 cb(line);
98 }
99 }
100}
101
102// =============================================================================
103// Tests
104// =============================================================================
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109
110 use std::sync::{Arc, Mutex};
111
112 #[test]
113 fn test_noop_reporter_doesnt_panic() {
114 // The no-op reporter should silently accept progress updates
115 // without crashing, even though there's no callback.
116 let reporter = ProgressReporter::new_noop();
117
118 // These should all succeed silently.
119 reporter.report(0, "Starting...");
120 reporter.report(50, "Halfway there...");
121 reporter.report(100, "Done!");
122 }
123
124 #[test]
125 fn test_noop_reporter_callback_is_none() {
126 let reporter = ProgressReporter::new_noop();
127
128 // In no-op mode, the callback should be None.
129 assert!(reporter.callback.is_none());
130 }
131
132 #[test]
133 fn test_reporter_calls_callback() {
134 // Create a shared Vec to record calls. We use Arc<Mutex<Vec>>
135 // so both the closure and the test body can access the data.
136 let calls: Arc<Mutex<Vec<(u32, String)>>> = Arc::new(Mutex::new(Vec::new()));
137
138 // Clone the Arc for the closure. This gives the closure its own
139 // "handle" to the shared Vec. The closure and the test body now
140 // both hold a reference to the SAME underlying Vec.
141 let calls_clone = Arc::clone(&calls);
142
143 // Create a reporter with a closure that records each call.
144 let reporter = ProgressReporter::new(move |percent, message| {
145 // `.lock().unwrap()` acquires the mutex lock. If another thread
146 // held it, we'd wait. `.unwrap()` panics if the mutex is poisoned
147 // (another thread panicked while holding the lock).
148 calls_clone
149 .lock()
150 .unwrap()
151 .push((percent, message.to_string()));
152 });
153
154 // Report some progress.
155 reporter.report(0, "Starting...");
156 reporter.report(50, "Halfway there...");
157 reporter.report(100, "Done!");
158
159 // Verify the callback was called with the right arguments.
160 let recorded = calls.lock().unwrap();
161 assert_eq!(recorded.len(), 3, "Should have recorded 3 calls");
162 assert_eq!(recorded[0], (0, "Starting...".to_string()));
163 assert_eq!(recorded[1], (50, "Halfway there...".to_string()));
164 assert_eq!(recorded[2], (100, "Done!".to_string()));
165 }
166
167 #[test]
168 fn report_output_calls_callback() {
169 let received: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
170 let received_clone = Arc::clone(&received);
171
172 let reporter = ProgressReporter::with_output(
173 |_, _| {},
174 move |line| received_clone.lock().unwrap().push(line.to_string()),
175 );
176
177 reporter.report_output("downloading 50%");
178 reporter.report_output("downloading 100%");
179
180 let lines = received.lock().unwrap();
181 assert_eq!(lines.len(), 2);
182 assert_eq!(lines[0], "downloading 50%");
183 assert_eq!(lines[1], "downloading 100%");
184 }
185
186 #[test]
187 fn report_output_noop_doesnt_panic() {
188 let reporter = ProgressReporter::new_noop();
189 reporter.report_output("ignored");
190 }
191
192 #[test]
193 fn test_reporter_with_callback_has_some() {
194 // A reporter created with `new()` should have Some(callback).
195 let reporter = ProgressReporter::new(|_percent, _message| {
196 // No-op for this test — we just want to check it's Some.
197 });
198 assert!(reporter.callback.is_some());
199 }
200}