Skip to main content

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}