gglib_core/download/events.rs
1//! Download events - discriminated union for all download state changes.
2
3use super::completion::QueueRunSummary;
4use super::types::ShardInfo;
5use serde::{Deserialize, Serialize};
6
7/// A summary of a download in the queue (for snapshots and API responses).
8#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
9pub struct DownloadSummary {
10 /// Canonical ID string (`model_id:quantization` or just `model_id`).
11 pub id: String,
12 /// Human-readable display name.
13 pub display_name: String,
14 /// Current status of this download.
15 pub status: DownloadStatus,
16 /// Position in queue (1 = currently downloading, 2+ = waiting).
17 pub position: u32,
18 /// Error message if status is Failed.
19 #[serde(skip_serializing_if = "Option::is_none")]
20 pub error: Option<String>,
21 /// Group ID for sharded downloads (all shards share the same `group_id`).
22 #[serde(skip_serializing_if = "Option::is_none")]
23 pub group_id: Option<String>,
24 /// Shard information if this is part of a sharded model.
25 #[serde(skip_serializing_if = "Option::is_none")]
26 pub shard_info: Option<ShardInfo>,
27}
28
29/// Status of a download.
30#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum DownloadStatus {
33 /// Waiting in the queue.
34 Queued,
35 /// Currently being downloaded.
36 Downloading,
37 /// Bytes are on disk; verifying / collecting metadata before registration.
38 Finalizing,
39 /// Registering the completed download in the model database.
40 Registering,
41 /// Completed successfully.
42 Completed,
43 /// Failed with an error.
44 Failed,
45 /// Cancelled by user.
46 Cancelled,
47}
48
49impl DownloadStatus {
50 /// Convert to string representation for database storage.
51 #[must_use]
52 pub const fn as_str(&self) -> &'static str {
53 match self {
54 Self::Queued => "queued",
55 Self::Downloading => "downloading",
56 Self::Finalizing => "finalizing",
57 Self::Registering => "registering",
58 Self::Completed => "completed",
59 Self::Failed => "failed",
60 Self::Cancelled => "cancelled",
61 }
62 }
63
64 /// Parse from string representation.
65 #[must_use]
66 pub fn parse(s: &str) -> Self {
67 match s {
68 "downloading" => Self::Downloading,
69 "finalizing" => Self::Finalizing,
70 "registering" => Self::Registering,
71 "completed" => Self::Completed,
72 "failed" => Self::Failed,
73 "cancelled" => Self::Cancelled,
74 // "queued" or unknown values default to Queued
75 _ => Self::Queued,
76 }
77 }
78
79 /// Human-readable label for UI display.
80 #[must_use]
81 pub const fn label(&self) -> &'static str {
82 match self {
83 Self::Queued => "Queued",
84 Self::Downloading => "Downloading",
85 Self::Finalizing => "Finalizing",
86 Self::Registering => "Registering",
87 Self::Completed => "Completed",
88 Self::Failed => "Failed",
89 Self::Cancelled => "Cancelled",
90 }
91 }
92}
93
94/// Single discriminated union for all download events.
95///
96/// The frontend handles this as a TypeScript discriminated union:
97///
98/// ```typescript
99/// type DownloadEvent =
100/// | { type: "queue_snapshot"; items: DownloadSummary[]; max_size: number }
101/// | { type: "download_started"; id: string; shard_index?: number; total_shards?: number }
102/// | { type: "download_progress"; id: string; downloaded: number; total: number;
103/// speed_bps?: number; eta_seconds?: number; percentage: number }
104/// | { type: "shard_progress"; id: string; shard_index: number;
105/// speed_bps?: number; eta_seconds?: number; ... }
106/// | { type: "download_completed"; id: string }
107/// | { type: "download_failed"; id: string; error: string }
108/// | { type: "download_cancelled"; id: string }
109/// | { type: "download_notice"; id: string; message: string };
110/// ```
111///
112/// `speed_bps` and `eta_seconds` are **optional and omitted when unknown** — a
113/// download that has just started has no meaningful rate yet. Renderers must
114/// show a placeholder for the absent case rather than substituting `0`, and
115/// must never compute a rate of their own from successive `downloaded` values;
116/// the manager's `RateEstimator` is the only source. The mirrored TypeScript
117/// declaration lives in `src/services/transport/types/events.ts`.
118#[derive(Clone, Debug, Serialize, Deserialize)]
119#[serde(tag = "type", rename_all = "snake_case")]
120pub enum DownloadEvent {
121 /// Snapshot of the entire queue state.
122 QueueSnapshot {
123 /// All items currently in the queue.
124 items: Vec<DownloadSummary>,
125 /// Maximum queue capacity.
126 max_size: u32,
127 },
128
129 /// A download has started.
130 DownloadStarted {
131 /// Canonical ID of the download.
132 id: String,
133 /// Current shard index (0-based), present only for sharded downloads.
134 #[serde(skip_serializing_if = "Option::is_none")]
135 shard_index: Option<u32>,
136 /// Total number of shards, present only for sharded downloads.
137 #[serde(skip_serializing_if = "Option::is_none")]
138 total_shards: Option<u32>,
139 },
140
141 /// Progress update for a non-sharded download.
142 DownloadProgress {
143 /// Canonical ID of the download.
144 id: String,
145 /// Bytes downloaded so far.
146 downloaded: u64,
147 /// Total bytes to download.
148 total: u64,
149 /// Current download speed in bytes per second.
150 ///
151 /// Absent until the estimator has warmed up. This is deliberately not
152 /// `0.0`: zero is a real reading meaning "stalled", and conflating the
153 /// two is what rendered `ETA: 0s` on a healthy download.
154 #[serde(skip_serializing_if = "Option::is_none")]
155 speed_bps: Option<f64>,
156 /// Estimated time remaining in seconds; absent when not yet known.
157 #[serde(skip_serializing_if = "Option::is_none")]
158 eta_seconds: Option<f64>,
159 /// Progress percentage (0.0 - 100.0).
160 percentage: f64,
161 },
162
163 /// Progress update for a sharded download.
164 ShardProgress {
165 /// Canonical ID of the download (group ID).
166 id: String,
167 /// Current shard index (0-based).
168 shard_index: u32,
169 /// Total number of shards.
170 total_shards: u32,
171 /// Filename of the current shard.
172 shard_filename: String,
173 /// Bytes downloaded for current shard.
174 shard_downloaded: u64,
175 /// Total bytes for current shard.
176 shard_total: u64,
177 /// Aggregate bytes downloaded across all shards.
178 aggregate_downloaded: u64,
179 /// Aggregate total bytes across all shards.
180 aggregate_total: u64,
181 /// Current download speed in bytes per second; absent until known.
182 ///
183 /// Measured across the whole shard group, not reset per shard.
184 #[serde(skip_serializing_if = "Option::is_none")]
185 speed_bps: Option<f64>,
186 /// Estimated time remaining in seconds; absent when not yet known.
187 #[serde(skip_serializing_if = "Option::is_none")]
188 eta_seconds: Option<f64>,
189 /// Aggregate progress percentage (0.0 - 100.0).
190 percentage: f64,
191 },
192
193 /// Download completed successfully.
194 DownloadCompleted {
195 /// Canonical ID of the download.
196 id: String,
197 /// Optional success message.
198 #[serde(skip_serializing_if = "Option::is_none")]
199 message: Option<String>,
200 },
201
202 /// Download failed with an error.
203 DownloadFailed {
204 /// Canonical ID of the download.
205 id: String,
206 /// Error message describing what went wrong.
207 error: String,
208 },
209
210 /// Download was cancelled by the user.
211 DownloadCancelled {
212 /// Canonical ID of the download.
213 id: String,
214 },
215
216 /// Lifecycle status transition for a download (e.g.
217 /// `Downloading` → `Finalizing` → `Registering`).
218 ///
219 /// Emitted at the boundaries between phases so transports can render a
220 /// non-frozen state while the manager is verifying bytes and writing the
221 /// model row to the database. Terminal states (`Completed`, `Failed`,
222 /// `Cancelled`) keep their dedicated event variants.
223 DownloadStatusChanged {
224 /// Canonical ID of the download.
225 id: String,
226 /// New status of the download.
227 status: DownloadStatus,
228 },
229
230 /// A transient, human-readable note about work happening for this
231 /// download that produces no byte progress of its own — e.g. building
232 /// the first-run Python environment for the fast downloader.
233 ///
234 /// Unlike [`Self::DownloadStatusChanged`] this carries free-form text
235 /// rather than a fixed [`DownloadStatus`] and is not persisted; it exists
236 /// purely so the renderer has something to show instead of looking
237 /// frozen while setup work happens before the first progress event.
238 DownloadNotice {
239 /// Canonical ID of the download.
240 id: String,
241 /// Human-readable note to display in place of progress.
242 message: String,
243 },
244
245 /// Queue run completed (all downloads in the queue finished).
246 ///
247 /// Emitted when the download queue transitions from busy → idle,
248 /// providing a complete summary of all artifacts that were processed
249 /// during the run.
250 QueueRunComplete {
251 /// Complete summary of the queue run.
252 summary: QueueRunSummary,
253 },
254}
255
256impl DownloadEvent {
257 /// Create a queue snapshot event.
258 #[must_use]
259 pub const fn queue_snapshot(items: Vec<DownloadSummary>, max_size: u32) -> Self {
260 Self::QueueSnapshot { items, max_size }
261 }
262
263 /// Create a download started event.
264 pub fn started(id: impl Into<String>) -> Self {
265 Self::DownloadStarted {
266 id: id.into(),
267 shard_index: None,
268 total_shards: None,
269 }
270 }
271
272 /// Create a download started event with shard information.
273 pub fn started_shard(id: impl Into<String>, shard_index: u32, total_shards: u32) -> Self {
274 Self::DownloadStarted {
275 id: id.into(),
276 shard_index: Some(shard_index),
277 total_shards: Some(total_shards),
278 }
279 }
280
281 /// Percentage complete, clamped to 0-100.
282 #[allow(clippy::cast_precision_loss)]
283 fn percent_of(downloaded: u64, total: u64) -> f64 {
284 if total == 0 {
285 return 0.0;
286 }
287 ((downloaded as f64 / total as f64) * 100.0).clamp(0.0, 100.0)
288 }
289
290 /// Create a non-sharded progress event.
291 ///
292 /// `speed_bps` and `eta_seconds` come from the manager's
293 /// [`RateEstimator`](crate::download::RateEstimator) — this constructor
294 /// deliberately does not derive an ETA of its own. Two estimators for one
295 /// number is how the CLI and the GUI ended up disagreeing.
296 pub fn progress(
297 id: impl Into<String>,
298 downloaded: u64,
299 total: u64,
300 speed_bps: Option<f64>,
301 eta_seconds: Option<f64>,
302 ) -> Self {
303 Self::DownloadProgress {
304 id: id.into(),
305 downloaded,
306 total,
307 speed_bps,
308 eta_seconds,
309 percentage: Self::percent_of(downloaded, total),
310 }
311 }
312
313 /// Create a sharded progress event.
314 ///
315 /// See [`progress`](Self::progress) on where the rate values come from.
316 #[allow(clippy::too_many_arguments)]
317 pub fn shard_progress(
318 id: impl Into<String>,
319 shard_index: u32,
320 total_shards: u32,
321 shard_filename: impl Into<String>,
322 shard_downloaded: u64,
323 shard_total: u64,
324 aggregate_downloaded: u64,
325 aggregate_total: u64,
326 speed_bps: Option<f64>,
327 eta_seconds: Option<f64>,
328 ) -> Self {
329 Self::ShardProgress {
330 id: id.into(),
331 shard_index,
332 total_shards,
333 shard_filename: shard_filename.into(),
334 shard_downloaded,
335 shard_total,
336 aggregate_downloaded,
337 aggregate_total,
338 speed_bps,
339 eta_seconds,
340 percentage: Self::percent_of(aggregate_downloaded, aggregate_total),
341 }
342 }
343
344 /// Create a download completed event.
345 pub fn completed(id: impl Into<String>, message: Option<impl Into<String>>) -> Self {
346 Self::DownloadCompleted {
347 id: id.into(),
348 message: message.map(Into::into),
349 }
350 }
351
352 /// Create a download failed event.
353 pub fn failed(id: impl Into<String>, error: impl Into<String>) -> Self {
354 Self::DownloadFailed {
355 id: id.into(),
356 error: error.into(),
357 }
358 }
359
360 /// Create a download cancelled event.
361 pub fn cancelled(id: impl Into<String>) -> Self {
362 Self::DownloadCancelled { id: id.into() }
363 }
364
365 /// Create a status-changed event for non-terminal lifecycle transitions.
366 pub fn status_changed(id: impl Into<String>, status: DownloadStatus) -> Self {
367 Self::DownloadStatusChanged {
368 id: id.into(),
369 status,
370 }
371 }
372
373 /// Create a queue run complete event.
374 pub const fn queue_run_complete(summary: QueueRunSummary) -> Self {
375 Self::QueueRunComplete { summary }
376 }
377
378 /// Get the download ID from any event type.
379 #[must_use]
380 pub fn id(&self) -> Option<&str> {
381 match self {
382 Self::QueueSnapshot { .. } | Self::QueueRunComplete { .. } => None,
383 Self::DownloadStarted { id, .. }
384 | Self::DownloadProgress { id, .. }
385 | Self::ShardProgress { id, .. }
386 | Self::DownloadCompleted { id, .. }
387 | Self::DownloadFailed { id, .. }
388 | Self::DownloadCancelled { id }
389 | Self::DownloadStatusChanged { id, .. }
390 | Self::DownloadNotice { id, .. } => Some(id),
391 }
392 }
393
394 /// Get the event name for wire protocols.
395 ///
396 /// This provides consistent event naming for Tauri and SSE transports.
397 /// Note: Both `ShardProgress` and `DownloadProgress` use "download:progress"
398 /// as the channel name; differentiation happens via the type discriminator.
399 #[must_use]
400 pub const fn event_name(&self) -> &'static str {
401 match self {
402 Self::QueueSnapshot { .. } => "download:queue_snapshot",
403 Self::DownloadStarted { .. } => "download:started",
404 Self::DownloadProgress { .. } | Self::ShardProgress { .. } => "download:progress",
405 Self::DownloadCompleted { .. } => "download:completed",
406 Self::DownloadFailed { .. } => "download:failed",
407 Self::DownloadCancelled { .. } => "download:cancelled",
408 Self::DownloadStatusChanged { .. } => "download:status_changed",
409 Self::DownloadNotice { .. } => "download:notice",
410 Self::QueueRunComplete { .. } => "download:queue_run_complete",
411 }
412 }
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418
419 #[test]
420 fn test_progress_event_calculations() {
421 let event = DownloadEvent::progress("id", 500, 1000, Some(100.0), Some(5.0));
422 match event {
423 DownloadEvent::DownloadProgress {
424 percentage,
425 eta_seconds,
426 speed_bps,
427 ..
428 } => {
429 assert!((percentage - 50.0).abs() < 0.01);
430 assert_eq!(eta_seconds, Some(5.0), "ETA is passed through, not derived");
431 assert_eq!(speed_bps, Some(100.0));
432 }
433 _ => panic!("Expected DownloadProgress"),
434 }
435 }
436
437 #[test]
438 fn unknown_rate_is_omitted_from_the_wire() {
439 let event = DownloadEvent::progress("id", 500, 1000, None, None);
440 let json = serde_json::to_string(&event).expect("serializes");
441 assert!(
442 !json.contains("speed_bps") && !json.contains("eta_seconds"),
443 "an unknown rate must be absent, never 0: {json}"
444 );
445 }
446
447 #[test]
448 fn percentage_is_clamped_and_safe_at_zero_total() {
449 let over = DownloadEvent::progress("id", 1500, 1000, None, None);
450 let unknown = DownloadEvent::progress("id", 500, 0, None, None);
451 for (event, expected) in [(over, 100.0), (unknown, 0.0)] {
452 match event {
453 DownloadEvent::DownloadProgress { percentage, .. } => {
454 assert!((percentage - expected).abs() < f64::EPSILON);
455 }
456 _ => panic!("Expected DownloadProgress"),
457 }
458 }
459 }
460
461 #[test]
462 fn test_event_id_extraction() {
463 assert_eq!(DownloadEvent::started("test").id(), Some("test"));
464 assert_eq!(DownloadEvent::cancelled("test").id(), Some("test"));
465 assert!(DownloadEvent::queue_snapshot(vec![], 10).id().is_none());
466 }
467}