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}