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///   | { type: "shard_progress"; id: string; shard_index: number; ... }
104///   | { type: "download_completed"; id: string }
105///   | { type: "download_failed"; id: string; error: string }
106///   | { type: "download_cancelled"; id: string };
107/// ```
108#[derive(Clone, Debug, Serialize, Deserialize)]
109#[serde(tag = "type", rename_all = "snake_case")]
110pub enum DownloadEvent {
111    /// Snapshot of the entire queue state.
112    QueueSnapshot {
113        /// All items currently in the queue.
114        items: Vec<DownloadSummary>,
115        /// Maximum queue capacity.
116        max_size: u32,
117    },
118
119    /// A download has started.
120    DownloadStarted {
121        /// Canonical ID of the download.
122        id: String,
123        /// Current shard index (0-based), present only for sharded downloads.
124        #[serde(skip_serializing_if = "Option::is_none")]
125        shard_index: Option<u32>,
126        /// Total number of shards, present only for sharded downloads.
127        #[serde(skip_serializing_if = "Option::is_none")]
128        total_shards: Option<u32>,
129    },
130
131    /// Progress update for a non-sharded download.
132    DownloadProgress {
133        /// Canonical ID of the download.
134        id: String,
135        /// Bytes downloaded so far.
136        downloaded: u64,
137        /// Total bytes to download.
138        total: u64,
139        /// Current download speed in bytes per second.
140        speed_bps: f64,
141        /// Estimated time remaining in seconds.
142        eta_seconds: f64,
143        /// Progress percentage (0.0 - 100.0).
144        percentage: f64,
145    },
146
147    /// Progress update for a sharded download.
148    ShardProgress {
149        /// Canonical ID of the download (group ID).
150        id: String,
151        /// Current shard index (0-based).
152        shard_index: u32,
153        /// Total number of shards.
154        total_shards: u32,
155        /// Filename of the current shard.
156        shard_filename: String,
157        /// Bytes downloaded for current shard.
158        shard_downloaded: u64,
159        /// Total bytes for current shard.
160        shard_total: u64,
161        /// Aggregate bytes downloaded across all shards.
162        aggregate_downloaded: u64,
163        /// Aggregate total bytes across all shards.
164        aggregate_total: u64,
165        /// Current download speed in bytes per second.
166        speed_bps: f64,
167        /// Estimated time remaining in seconds.
168        eta_seconds: f64,
169        /// Aggregate progress percentage (0.0 - 100.0).
170        percentage: f64,
171    },
172
173    /// Download completed successfully.
174    DownloadCompleted {
175        /// Canonical ID of the download.
176        id: String,
177        /// Optional success message.
178        #[serde(skip_serializing_if = "Option::is_none")]
179        message: Option<String>,
180    },
181
182    /// Download failed with an error.
183    DownloadFailed {
184        /// Canonical ID of the download.
185        id: String,
186        /// Error message describing what went wrong.
187        error: String,
188    },
189
190    /// Download was cancelled by the user.
191    DownloadCancelled {
192        /// Canonical ID of the download.
193        id: String,
194    },
195
196    /// Lifecycle status transition for a download (e.g.
197    /// `Downloading` → `Finalizing` → `Registering`).
198    ///
199    /// Emitted at the boundaries between phases so transports can render a
200    /// non-frozen state while the manager is verifying bytes and writing the
201    /// model row to the database. Terminal states (`Completed`, `Failed`,
202    /// `Cancelled`) keep their dedicated event variants.
203    DownloadStatusChanged {
204        /// Canonical ID of the download.
205        id: String,
206        /// New status of the download.
207        status: DownloadStatus,
208    },
209
210    /// Queue run completed (all downloads in the queue finished).
211    ///
212    /// Emitted when the download queue transitions from busy → idle,
213    /// providing a complete summary of all artifacts that were processed
214    /// during the run.
215    QueueRunComplete {
216        /// Complete summary of the queue run.
217        summary: QueueRunSummary,
218    },
219}
220
221impl DownloadEvent {
222    /// Create a queue snapshot event.
223    #[must_use]
224    pub const fn queue_snapshot(items: Vec<DownloadSummary>, max_size: u32) -> Self {
225        Self::QueueSnapshot { items, max_size }
226    }
227
228    /// Create a download started event.
229    pub fn started(id: impl Into<String>) -> Self {
230        Self::DownloadStarted {
231            id: id.into(),
232            shard_index: None,
233            total_shards: None,
234        }
235    }
236
237    /// Create a download started event with shard information.
238    pub fn started_shard(id: impl Into<String>, shard_index: u32, total_shards: u32) -> Self {
239        Self::DownloadStarted {
240            id: id.into(),
241            shard_index: Some(shard_index),
242            total_shards: Some(total_shards),
243        }
244    }
245
246    /// Create a non-sharded progress event.
247    #[allow(clippy::cast_precision_loss)]
248    pub fn progress(id: impl Into<String>, downloaded: u64, total: u64, speed_bps: f64) -> Self {
249        let percentage = if total > 0 {
250            (downloaded as f64 / total as f64) * 100.0
251        } else {
252            0.0
253        };
254
255        let eta_seconds = if speed_bps > 0.0 && total > downloaded {
256            (total - downloaded) as f64 / speed_bps
257        } else {
258            0.0
259        };
260
261        Self::DownloadProgress {
262            id: id.into(),
263            downloaded,
264            total,
265            speed_bps,
266            eta_seconds,
267            percentage,
268        }
269    }
270
271    /// Create a sharded progress event.
272    #[allow(clippy::too_many_arguments, clippy::cast_precision_loss)]
273    pub fn shard_progress(
274        id: impl Into<String>,
275        shard_index: u32,
276        total_shards: u32,
277        shard_filename: impl Into<String>,
278        shard_downloaded: u64,
279        shard_total: u64,
280        aggregate_downloaded: u64,
281        aggregate_total: u64,
282        speed_bps: f64,
283    ) -> Self {
284        let percentage = if aggregate_total > 0 {
285            (aggregate_downloaded as f64 / aggregate_total as f64) * 100.0
286        } else {
287            0.0
288        };
289
290        let eta_seconds = if speed_bps > 0.0 && aggregate_total > aggregate_downloaded {
291            (aggregate_total - aggregate_downloaded) as f64 / speed_bps
292        } else {
293            0.0
294        };
295
296        Self::ShardProgress {
297            id: id.into(),
298            shard_index,
299            total_shards,
300            shard_filename: shard_filename.into(),
301            shard_downloaded,
302            shard_total,
303            aggregate_downloaded,
304            aggregate_total,
305            speed_bps,
306            eta_seconds,
307            percentage,
308        }
309    }
310
311    /// Create a download completed event.
312    pub fn completed(id: impl Into<String>, message: Option<impl Into<String>>) -> Self {
313        Self::DownloadCompleted {
314            id: id.into(),
315            message: message.map(Into::into),
316        }
317    }
318
319    /// Create a download failed event.
320    pub fn failed(id: impl Into<String>, error: impl Into<String>) -> Self {
321        Self::DownloadFailed {
322            id: id.into(),
323            error: error.into(),
324        }
325    }
326
327    /// Create a download cancelled event.
328    pub fn cancelled(id: impl Into<String>) -> Self {
329        Self::DownloadCancelled { id: id.into() }
330    }
331
332    /// Create a status-changed event for non-terminal lifecycle transitions.
333    pub fn status_changed(id: impl Into<String>, status: DownloadStatus) -> Self {
334        Self::DownloadStatusChanged {
335            id: id.into(),
336            status,
337        }
338    }
339
340    /// Create a queue run complete event.
341    pub const fn queue_run_complete(summary: QueueRunSummary) -> Self {
342        Self::QueueRunComplete { summary }
343    }
344
345    /// Get the download ID from any event type.
346    #[must_use]
347    pub fn id(&self) -> Option<&str> {
348        match self {
349            Self::QueueSnapshot { .. } | Self::QueueRunComplete { .. } => None,
350            Self::DownloadStarted { id, .. }
351            | Self::DownloadProgress { id, .. }
352            | Self::ShardProgress { id, .. }
353            | Self::DownloadCompleted { id, .. }
354            | Self::DownloadFailed { id, .. }
355            | Self::DownloadCancelled { id }
356            | Self::DownloadStatusChanged { id, .. } => Some(id),
357        }
358    }
359
360    /// Get the event name for wire protocols.
361    ///
362    /// This provides consistent event naming for Tauri and SSE transports.
363    /// Note: Both `ShardProgress` and `DownloadProgress` use "download:progress"
364    /// as the channel name; differentiation happens via the type discriminator.
365    #[must_use]
366    pub const fn event_name(&self) -> &'static str {
367        match self {
368            Self::QueueSnapshot { .. } => "download:queue_snapshot",
369            Self::DownloadStarted { .. } => "download:started",
370            Self::DownloadProgress { .. } | Self::ShardProgress { .. } => "download:progress",
371            Self::DownloadCompleted { .. } => "download:completed",
372            Self::DownloadFailed { .. } => "download:failed",
373            Self::DownloadCancelled { .. } => "download:cancelled",
374            Self::DownloadStatusChanged { .. } => "download:status_changed",
375            Self::QueueRunComplete { .. } => "download:queue_run_complete",
376        }
377    }
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383
384    #[test]
385    fn test_progress_event_calculations() {
386        let event = DownloadEvent::progress("id", 500, 1000, 100.0);
387        match event {
388            DownloadEvent::DownloadProgress {
389                percentage,
390                eta_seconds,
391                ..
392            } => {
393                assert!((percentage - 50.0).abs() < 0.01);
394                assert!((eta_seconds - 5.0).abs() < 0.01);
395            }
396            _ => panic!("Expected DownloadProgress"),
397        }
398    }
399
400    #[test]
401    fn test_event_id_extraction() {
402        assert_eq!(DownloadEvent::started("test").id(), Some("test"));
403        assert_eq!(DownloadEvent::cancelled("test").id(), Some("test"));
404        assert!(DownloadEvent::queue_snapshot(vec![], 10).id().is_none());
405    }
406}