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    /// Completed successfully.
38    Completed,
39    /// Failed with an error.
40    Failed,
41    /// Cancelled by user.
42    Cancelled,
43}
44
45impl DownloadStatus {
46    /// Convert to string representation for database storage.
47    #[must_use]
48    pub const fn as_str(&self) -> &'static str {
49        match self {
50            Self::Queued => "queued",
51            Self::Downloading => "downloading",
52            Self::Completed => "completed",
53            Self::Failed => "failed",
54            Self::Cancelled => "cancelled",
55        }
56    }
57
58    /// Parse from string representation.
59    #[must_use]
60    pub fn parse(s: &str) -> Self {
61        match s {
62            "downloading" => Self::Downloading,
63            "completed" => Self::Completed,
64            "failed" => Self::Failed,
65            "cancelled" => Self::Cancelled,
66            // "queued" or unknown values default to Queued
67            _ => Self::Queued,
68        }
69    }
70}
71
72/// Single discriminated union for all download events.
73///
74/// The frontend handles this as a TypeScript discriminated union:
75///
76/// ```typescript
77/// type DownloadEvent =
78///   | { type: "queue_snapshot"; items: DownloadSummary[]; max_size: number }
79///   | { type: "download_started"; id: string; shard_index?: number; total_shards?: number }
80///   | { type: "download_progress"; id: string; downloaded: number; total: number; ... }
81///   | { type: "shard_progress"; id: string; shard_index: number; ... }
82///   | { type: "download_completed"; id: string }
83///   | { type: "download_failed"; id: string; error: string }
84///   | { type: "download_cancelled"; id: string };
85/// ```
86#[derive(Clone, Debug, Serialize, Deserialize)]
87#[serde(tag = "type", rename_all = "snake_case")]
88pub enum DownloadEvent {
89    /// Snapshot of the entire queue state.
90    QueueSnapshot {
91        /// All items currently in the queue.
92        items: Vec<DownloadSummary>,
93        /// Maximum queue capacity.
94        max_size: u32,
95    },
96
97    /// A download has started.
98    DownloadStarted {
99        /// Canonical ID of the download.
100        id: String,
101        /// Current shard index (0-based), present only for sharded downloads.
102        #[serde(skip_serializing_if = "Option::is_none")]
103        shard_index: Option<u32>,
104        /// Total number of shards, present only for sharded downloads.
105        #[serde(skip_serializing_if = "Option::is_none")]
106        total_shards: Option<u32>,
107    },
108
109    /// Progress update for a non-sharded download.
110    DownloadProgress {
111        /// Canonical ID of the download.
112        id: String,
113        /// Bytes downloaded so far.
114        downloaded: u64,
115        /// Total bytes to download.
116        total: u64,
117        /// Current download speed in bytes per second.
118        speed_bps: f64,
119        /// Estimated time remaining in seconds.
120        eta_seconds: f64,
121        /// Progress percentage (0.0 - 100.0).
122        percentage: f64,
123    },
124
125    /// Progress update for a sharded download.
126    ShardProgress {
127        /// Canonical ID of the download (group ID).
128        id: String,
129        /// Current shard index (0-based).
130        shard_index: u32,
131        /// Total number of shards.
132        total_shards: u32,
133        /// Filename of the current shard.
134        shard_filename: String,
135        /// Bytes downloaded for current shard.
136        shard_downloaded: u64,
137        /// Total bytes for current shard.
138        shard_total: u64,
139        /// Aggregate bytes downloaded across all shards.
140        aggregate_downloaded: u64,
141        /// Aggregate total bytes across all shards.
142        aggregate_total: u64,
143        /// Current download speed in bytes per second.
144        speed_bps: f64,
145        /// Estimated time remaining in seconds.
146        eta_seconds: f64,
147        /// Aggregate progress percentage (0.0 - 100.0).
148        percentage: f64,
149    },
150
151    /// Download completed successfully.
152    DownloadCompleted {
153        /// Canonical ID of the download.
154        id: String,
155        /// Optional success message.
156        #[serde(skip_serializing_if = "Option::is_none")]
157        message: Option<String>,
158    },
159
160    /// Download failed with an error.
161    DownloadFailed {
162        /// Canonical ID of the download.
163        id: String,
164        /// Error message describing what went wrong.
165        error: String,
166    },
167
168    /// Download was cancelled by the user.
169    DownloadCancelled {
170        /// Canonical ID of the download.
171        id: String,
172    },
173
174    /// Queue run completed (all downloads in the queue finished).
175    ///
176    /// Emitted when the download queue transitions from busy → idle,
177    /// providing a complete summary of all artifacts that were processed
178    /// during the run.
179    QueueRunComplete {
180        /// Complete summary of the queue run.
181        summary: QueueRunSummary,
182    },
183}
184
185impl DownloadEvent {
186    /// Create a queue snapshot event.
187    #[must_use]
188    pub const fn queue_snapshot(items: Vec<DownloadSummary>, max_size: u32) -> Self {
189        Self::QueueSnapshot { items, max_size }
190    }
191
192    /// Create a download started event.
193    pub fn started(id: impl Into<String>) -> Self {
194        Self::DownloadStarted {
195            id: id.into(),
196            shard_index: None,
197            total_shards: None,
198        }
199    }
200
201    /// Create a download started event with shard information.
202    pub fn started_shard(id: impl Into<String>, shard_index: u32, total_shards: u32) -> Self {
203        Self::DownloadStarted {
204            id: id.into(),
205            shard_index: Some(shard_index),
206            total_shards: Some(total_shards),
207        }
208    }
209
210    /// Create a non-sharded progress event.
211    #[allow(clippy::cast_precision_loss)]
212    pub fn progress(id: impl Into<String>, downloaded: u64, total: u64, speed_bps: f64) -> Self {
213        let percentage = if total > 0 {
214            (downloaded as f64 / total as f64) * 100.0
215        } else {
216            0.0
217        };
218
219        let eta_seconds = if speed_bps > 0.0 && total > downloaded {
220            (total - downloaded) as f64 / speed_bps
221        } else {
222            0.0
223        };
224
225        Self::DownloadProgress {
226            id: id.into(),
227            downloaded,
228            total,
229            speed_bps,
230            eta_seconds,
231            percentage,
232        }
233    }
234
235    /// Create a sharded progress event.
236    #[allow(clippy::too_many_arguments, clippy::cast_precision_loss)]
237    pub fn shard_progress(
238        id: impl Into<String>,
239        shard_index: u32,
240        total_shards: u32,
241        shard_filename: impl Into<String>,
242        shard_downloaded: u64,
243        shard_total: u64,
244        aggregate_downloaded: u64,
245        aggregate_total: u64,
246        speed_bps: f64,
247    ) -> Self {
248        let percentage = if aggregate_total > 0 {
249            (aggregate_downloaded as f64 / aggregate_total as f64) * 100.0
250        } else {
251            0.0
252        };
253
254        let eta_seconds = if speed_bps > 0.0 && aggregate_total > aggregate_downloaded {
255            (aggregate_total - aggregate_downloaded) as f64 / speed_bps
256        } else {
257            0.0
258        };
259
260        Self::ShardProgress {
261            id: id.into(),
262            shard_index,
263            total_shards,
264            shard_filename: shard_filename.into(),
265            shard_downloaded,
266            shard_total,
267            aggregate_downloaded,
268            aggregate_total,
269            speed_bps,
270            eta_seconds,
271            percentage,
272        }
273    }
274
275    /// Create a download completed event.
276    pub fn completed(id: impl Into<String>, message: Option<impl Into<String>>) -> Self {
277        Self::DownloadCompleted {
278            id: id.into(),
279            message: message.map(Into::into),
280        }
281    }
282
283    /// Create a download failed event.
284    pub fn failed(id: impl Into<String>, error: impl Into<String>) -> Self {
285        Self::DownloadFailed {
286            id: id.into(),
287            error: error.into(),
288        }
289    }
290
291    /// Create a download cancelled event.
292    pub fn cancelled(id: impl Into<String>) -> Self {
293        Self::DownloadCancelled { id: id.into() }
294    }
295
296    /// Create a queue run complete event.
297    pub const fn queue_run_complete(summary: QueueRunSummary) -> Self {
298        Self::QueueRunComplete { summary }
299    }
300
301    /// Get the download ID from any event type.
302    #[must_use]
303    pub fn id(&self) -> Option<&str> {
304        match self {
305            Self::QueueSnapshot { .. } | Self::QueueRunComplete { .. } => None,
306            Self::DownloadStarted { id, .. }
307            | Self::DownloadProgress { id, .. }
308            | Self::ShardProgress { id, .. }
309            | Self::DownloadCompleted { id, .. }
310            | Self::DownloadFailed { id, .. }
311            | Self::DownloadCancelled { id } => Some(id),
312        }
313    }
314
315    /// Get the event name for wire protocols.
316    ///
317    /// This provides consistent event naming for Tauri and SSE transports.
318    /// Note: Both `ShardProgress` and `DownloadProgress` use "download:progress"
319    /// as the channel name; differentiation happens via the type discriminator.
320    #[must_use]
321    pub const fn event_name(&self) -> &'static str {
322        match self {
323            Self::QueueSnapshot { .. } => "download:queue_snapshot",
324            Self::DownloadStarted { .. } => "download:started",
325            Self::DownloadProgress { .. } | Self::ShardProgress { .. } => "download:progress",
326            Self::DownloadCompleted { .. } => "download:completed",
327            Self::DownloadFailed { .. } => "download:failed",
328            Self::DownloadCancelled { .. } => "download:cancelled",
329            Self::QueueRunComplete { .. } => "download:queue_run_complete",
330        }
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    #[test]
339    fn test_progress_event_calculations() {
340        let event = DownloadEvent::progress("id", 500, 1000, 100.0);
341        match event {
342            DownloadEvent::DownloadProgress {
343                percentage,
344                eta_seconds,
345                ..
346            } => {
347                assert!((percentage - 50.0).abs() < 0.01);
348                assert!((eta_seconds - 5.0).abs() < 0.01);
349            }
350            _ => panic!("Expected DownloadProgress"),
351        }
352    }
353
354    #[test]
355    fn test_event_id_extraction() {
356        assert_eq!(DownloadEvent::started("test").id(), Some("test"));
357        assert_eq!(DownloadEvent::cancelled("test").id(), Some("test"));
358        assert!(DownloadEvent::queue_snapshot(vec![], 10).id().is_none());
359    }
360}