gglib_core/ports/download_event_emitter.rs
1//! Download event emitter port.
2//!
3//! This port abstracts download event emission, allowing the download manager
4//! to emit events without coupling to transport details (SSE, Tauri, etc.).
5
6use std::sync::Arc;
7
8use crate::download::DownloadEvent;
9use crate::events::AppEvent;
10
11use super::AppEventEmitter;
12
13/// Port for emitting download events.
14///
15/// This trait abstracts away the transport mechanism for download events.
16/// Implementations handle the actual event delivery (channels, SSE, Tauri events).
17///
18/// # Example
19///
20/// ```ignore
21/// // In download manager
22/// fn on_progress(&self, emitter: &dyn DownloadEventEmitterPort) {
23/// emitter.emit(DownloadEvent::DownloadProgress { ... });
24/// }
25/// ```
26pub trait DownloadEventEmitterPort: Send + Sync {
27 /// Emit a download event.
28 ///
29 /// Implementations should handle the event asynchronously or buffer it.
30 /// This method should not block.
31 fn emit(&self, event: DownloadEvent);
32
33 /// Clone this emitter into a boxed trait object.
34 ///
35 /// This enables cloning of `Arc<dyn DownloadEventEmitterPort>` without
36 /// requiring the underlying type to implement Clone.
37 fn clone_box(&self) -> Box<dyn DownloadEventEmitterPort>;
38}
39
40/// A no-op download event emitter for tests and CLI contexts.
41///
42/// This implementation discards all events, making it suitable for:
43/// - Unit tests that don't need to verify event emission
44/// - CLI applications that handle progress differently
45/// - Contexts where event emission is optional
46#[derive(Debug, Clone, Default)]
47pub struct NoopDownloadEmitter;
48
49impl NoopDownloadEmitter {
50 /// Create a new no-op download emitter.
51 #[must_use]
52 pub const fn new() -> Self {
53 Self
54 }
55}
56
57impl DownloadEventEmitterPort for NoopDownloadEmitter {
58 fn emit(&self, _event: DownloadEvent) {
59 // Intentionally do nothing
60 }
61
62 fn clone_box(&self) -> Box<dyn DownloadEventEmitterPort> {
63 Box::new(self.clone())
64 }
65}
66
67/// Bridge adapter that converts `DownloadEvent` to `AppEvent` and forwards to `AppEventEmitter`.
68///
69/// This provides a single wiring path: download manager emits `DownloadEvent`,
70/// which gets mapped to the appropriate `AppEvent` variant and sent through
71/// the shared event infrastructure.
72///
73/// # Example
74///
75/// ```ignore
76/// let app_emitter: Arc<dyn AppEventEmitter> = /* Tauri or SSE emitter */;
77/// let download_emitter = AppEventBridge::new(app_emitter);
78///
79/// // Pass to download manager
80/// let manager = build_download_manager(DownloadManagerDeps {
81/// event_emitter: Arc::new(download_emitter),
82/// ...
83/// });
84/// ```
85#[derive(Clone)]
86pub struct AppEventBridge {
87 inner: Arc<dyn AppEventEmitter>,
88}
89
90impl AppEventBridge {
91 /// Create a new bridge wrapping an `AppEventEmitter`.
92 pub fn new(emitter: Arc<dyn AppEventEmitter>) -> Self {
93 Self { inner: emitter }
94 }
95
96 /// Wrap a `DownloadEvent` in an `AppEvent`.
97 ///
98 /// Preserves all download event details including shard progress information.
99 const fn map_event(event: DownloadEvent) -> AppEvent {
100 AppEvent::Download { event }
101 }
102}
103
104impl DownloadEventEmitterPort for AppEventBridge {
105 fn emit(&self, event: DownloadEvent) {
106 let app_event = Self::map_event(event);
107 self.inner.emit(app_event);
108 }
109
110 fn clone_box(&self) -> Box<dyn DownloadEventEmitterPort> {
111 Box::new(self.clone())
112 }
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118
119 #[test]
120 fn test_noop_emitter() {
121 let emitter = NoopDownloadEmitter::new();
122
123 // Should not panic
124 emitter.emit(DownloadEvent::DownloadStarted {
125 id: "test".to_string(),
126 });
127 }
128
129 #[test]
130 fn test_noop_emitter_clone_box() {
131 let emitter = NoopDownloadEmitter::new();
132 let _boxed: Box<dyn DownloadEventEmitterPort> = emitter.clone_box();
133 }
134
135 #[test]
136 fn test_arc_emitter() {
137 let emitter: Arc<dyn DownloadEventEmitterPort> = Arc::new(NoopDownloadEmitter::new());
138 emitter.emit(DownloadEvent::DownloadStarted {
139 id: "test".to_string(),
140 });
141 }
142
143 /// Regression test: `ShardProgress` must be preserved through `AppEventBridge`.
144 ///
145 /// This prevents the bug where shard detail was collapsed to generic `DownloadProgress`,
146 /// causing the UI to lose per-shard and aggregate progress information.
147 #[test]
148 fn test_shard_progress_preserved_in_bridge() {
149 use std::sync::Mutex;
150
151 // Mock AppEventEmitter that captures emitted events
152 #[derive(Clone)]
153 struct MockEmitter {
154 captured: Arc<Mutex<Vec<AppEvent>>>,
155 }
156
157 impl AppEventEmitter for MockEmitter {
158 fn emit(&self, event: AppEvent) {
159 self.captured.lock().unwrap().push(event);
160 }
161
162 fn clone_box(&self) -> Box<dyn AppEventEmitter> {
163 Box::new(self.clone())
164 }
165 }
166
167 let captured = Arc::new(Mutex::new(Vec::new()));
168 let mock = Arc::new(MockEmitter {
169 captured: captured.clone(),
170 });
171 let bridge = AppEventBridge::new(mock);
172
173 // Emit a ShardProgress event
174 let shard_event = DownloadEvent::shard_progress(
175 "model:Q4_K_M",
176 0,
177 4,
178 "model-00001-of-00004.gguf",
179 500_000,
180 1_000_000,
181 500_000,
182 4_000_000,
183 1024.0,
184 );
185
186 bridge.emit(shard_event);
187
188 // Verify it's wrapped, not collapsed
189 let events = captured.lock().unwrap();
190 assert_eq!(events.len(), 1, "Should emit exactly one AppEvent");
191
192 match &events[0] {
193 AppEvent::Download { event } => match event {
194 DownloadEvent::ShardProgress {
195 shard_index,
196 total_shards,
197 shard_filename,
198 shard_downloaded,
199 shard_total,
200 aggregate_downloaded,
201 aggregate_total,
202 ..
203 } => {
204 assert_eq!(*shard_index, 0);
205 assert_eq!(*total_shards, 4);
206 assert_eq!(shard_filename, "model-00001-of-00004.gguf");
207 assert_eq!(*shard_downloaded, 500_000);
208 assert_eq!(*shard_total, 1_000_000);
209 assert_eq!(*aggregate_downloaded, 500_000);
210 assert_eq!(*aggregate_total, 4_000_000);
211 }
212 _ => panic!("Expected ShardProgress to be preserved, got {event:?}"),
213 },
214 other => panic!("Expected AppEvent::Download wrapper, got {other:?}"),
215 }
216 }
217}