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}
24
25impl ProgressReporter {
26    /// Create a new ProgressReporter with a callback function.
27    ///
28    /// The callback receives two arguments:
29    ///   1. progress (u32, 0-100) — percentage complete
30    ///   2. message (&str) — human-readable status text
31    ///
32    /// USAGE:
33    /// ```rust
34    /// use bnto_core::ProgressReporter;
35    ///
36    /// // Simple logger
37    /// let reporter = ProgressReporter::new(|percent, message| {
38    ///     println!("{}% — {}", percent, message);
39    /// });
40    ///
41    /// // In a WASM bridge, wrap a js_sys::Function:
42    /// // let reporter = ProgressReporter::new(move |percent, message| {
43    /// //     let _ = js_callback.call2(&JsValue::NULL, &percent.into(), &message.into());
44    /// // });
45    /// ```
46    pub fn new(callback: impl Fn(u32, &str) + 'static) -> Self {
47        Self {
48            callback: Some(Box::new(callback)),
49        }
50    }
51
52    /// Create a no-op reporter that discards all progress updates.
53    /// Used in tests where we don't need progress reporting.
54    pub fn new_noop() -> Self {
55        Self { callback: None }
56    }
57
58    /// Report progress to the caller.
59    ///
60    /// Arguments:
61    ///   - `percent` — how far along we are (0 to 100)
62    ///   - `message` — what we're currently doing ("Compressing image 3/10...")
63    pub fn report(&self, percent: u32, message: &str) {
64        if let Some(cb) = &self.callback {
65            cb(percent, message);
66        }
67    }
68}
69
70// =============================================================================
71// Tests
72// =============================================================================
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    use std::sync::{Arc, Mutex};
79
80    #[test]
81    fn test_noop_reporter_doesnt_panic() {
82        // The no-op reporter should silently accept progress updates
83        // without crashing, even though there's no callback.
84        let reporter = ProgressReporter::new_noop();
85
86        // These should all succeed silently.
87        reporter.report(0, "Starting...");
88        reporter.report(50, "Halfway there...");
89        reporter.report(100, "Done!");
90    }
91
92    #[test]
93    fn test_noop_reporter_callback_is_none() {
94        let reporter = ProgressReporter::new_noop();
95
96        // In no-op mode, the callback should be None.
97        assert!(reporter.callback.is_none());
98    }
99
100    #[test]
101    fn test_reporter_calls_callback() {
102        // Create a shared Vec to record calls. We use Arc<Mutex<Vec>>
103        // so both the closure and the test body can access the data.
104        let calls: Arc<Mutex<Vec<(u32, String)>>> = Arc::new(Mutex::new(Vec::new()));
105
106        // Clone the Arc for the closure. This gives the closure its own
107        // "handle" to the shared Vec. The closure and the test body now
108        // both hold a reference to the SAME underlying Vec.
109        let calls_clone = Arc::clone(&calls);
110
111        // Create a reporter with a closure that records each call.
112        let reporter = ProgressReporter::new(move |percent, message| {
113            // `.lock().unwrap()` acquires the mutex lock. If another thread
114            // held it, we'd wait. `.unwrap()` panics if the mutex is poisoned
115            // (another thread panicked while holding the lock).
116            calls_clone
117                .lock()
118                .unwrap()
119                .push((percent, message.to_string()));
120        });
121
122        // Report some progress.
123        reporter.report(0, "Starting...");
124        reporter.report(50, "Halfway there...");
125        reporter.report(100, "Done!");
126
127        // Verify the callback was called with the right arguments.
128        let recorded = calls.lock().unwrap();
129        assert_eq!(recorded.len(), 3, "Should have recorded 3 calls");
130        assert_eq!(recorded[0], (0, "Starting...".to_string()));
131        assert_eq!(recorded[1], (50, "Halfway there...".to_string()));
132        assert_eq!(recorded[2], (100, "Done!".to_string()));
133    }
134
135    #[test]
136    fn test_reporter_with_callback_has_some() {
137        // A reporter created with `new()` should have Some(callback).
138        let reporter = ProgressReporter::new(|_percent, _message| {
139            // No-op for this test — we just want to check it's Some.
140        });
141        assert!(reporter.callback.is_some());
142    }
143}