gglib_core/download/
queue.rs

1//! Queue DTOs for API responses and snapshots.
2//!
3//! These types are "UI safe" - Clone + Debug + Serialize + Deserialize with no
4//! infrastructure dependencies. They're used for transmitting queue state to
5//! frontends via SSE, Tauri events, or CLI output.
6
7use super::events::DownloadStatus;
8use super::types::{Quantization, ShardInfo};
9use serde::{Deserialize, Serialize};
10use std::time::Duration;
11
12/// Snapshot of the entire download queue for API responses.
13#[derive(Clone, Debug, Default, Serialize, Deserialize)]
14pub struct QueueSnapshot {
15    /// Items currently in the queue.
16    pub items: Vec<QueuedDownload>,
17    /// Maximum queue capacity.
18    pub max_size: u32,
19    /// Number of active downloads (currently downloading).
20    pub active_count: u32,
21    /// Number of pending downloads (queued, waiting).
22    pub pending_count: u32,
23    /// Recent failures (kept for UI display).
24    pub recent_failures: Vec<FailedDownload>,
25}
26
27impl QueueSnapshot {
28    /// Create a new empty snapshot.
29    #[must_use]
30    pub const fn new(max_size: u32) -> Self {
31        Self {
32            items: Vec::new(),
33            max_size,
34            active_count: 0,
35            pending_count: 0,
36            recent_failures: Vec::new(),
37        }
38    }
39
40    /// Check if the queue is empty.
41    #[must_use]
42    pub const fn is_empty(&self) -> bool {
43        self.items.is_empty()
44    }
45
46    /// Check if the queue is full.
47    #[must_use]
48    pub const fn is_full(&self) -> bool {
49        self.items.len() >= self.max_size as usize
50    }
51
52    /// Get the total number of items.
53    #[must_use]
54    pub const fn len(&self) -> usize {
55        self.items.len()
56    }
57
58    /// Get an item by its ID.
59    pub fn get(&self, id: &str) -> Option<&QueuedDownload> {
60        self.items.iter().find(|item| item.id == id)
61    }
62}
63
64/// A single download in the queue.
65#[derive(Clone, Debug, Serialize, Deserialize)]
66pub struct QueuedDownload {
67    /// Canonical ID (`model_id:quantization` or `model_id`).
68    pub id: String,
69
70    /// Full model ID (e.g., "TheBloke/Llama-2-7B-GGUF").
71    pub model_id: String,
72
73    /// Resolved quantization (if specified).
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub quantization: Option<Quantization>,
76
77    /// Human-readable display name.
78    pub display_name: String,
79
80    /// Current status.
81    pub status: DownloadStatus,
82
83    /// Position in queue (1-based; 1 = active, 2+ = waiting).
84    pub position: u32,
85
86    /// Bytes downloaded so far.
87    pub downloaded_bytes: u64,
88
89    /// Total bytes to download.
90    pub total_bytes: u64,
91
92    /// Download speed in bytes per second.
93    pub speed_bps: f64,
94
95    /// Estimated time remaining.
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub eta_seconds: Option<f64>,
98
99    /// Progress as percentage (0.0 - 100.0).
100    pub progress_percent: f64,
101
102    /// Timestamp when download was queued (Unix epoch seconds).
103    pub queued_at: u64,
104
105    /// Timestamp when download started (Unix epoch seconds).
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub started_at: Option<u64>,
108
109    /// Group ID for sharded downloads.
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub group_id: Option<String>,
112
113    /// Shard information if this is part of a sharded download.
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub shard_info: Option<ShardInfo>,
116}
117
118impl QueuedDownload {
119    /// Create a new queued download in initial state.
120    pub fn new(
121        id: impl Into<String>,
122        model_id: impl Into<String>,
123        display_name: impl Into<String>,
124        position: u32,
125        queued_at: u64,
126    ) -> Self {
127        Self {
128            id: id.into(),
129            model_id: model_id.into(),
130            quantization: None,
131            display_name: display_name.into(),
132            status: DownloadStatus::Queued,
133            position,
134            downloaded_bytes: 0,
135            total_bytes: 0,
136            speed_bps: 0.0,
137            eta_seconds: None,
138            progress_percent: 0.0,
139            queued_at,
140            started_at: None,
141            group_id: None,
142            shard_info: None,
143        }
144    }
145
146    /// Set the quantization.
147    #[must_use]
148    pub const fn with_quantization(mut self, quant: Quantization) -> Self {
149        self.quantization = Some(quant);
150        self
151    }
152
153    /// Set the download status.
154    #[must_use]
155    pub const fn with_status(mut self, status: DownloadStatus) -> Self {
156        self.status = status;
157        self
158    }
159
160    /// Set shard information.
161    #[must_use]
162    pub fn with_shard_info(mut self, group_id: String, shard_info: ShardInfo) -> Self {
163        self.group_id = Some(group_id);
164        self.shard_info = Some(shard_info);
165        self
166    }
167
168    /// Update progress from bytes downloaded.
169    pub fn update_progress(&mut self, downloaded: u64, total: u64, speed_bps: f64) {
170        self.downloaded_bytes = downloaded;
171        self.total_bytes = total;
172        self.speed_bps = speed_bps;
173
174        self.progress_percent = if total > 0 {
175            #[expect(
176                clippy::cast_precision_loss,
177                reason = "precision loss acceptable for progress percentage"
178            )]
179            let progress = (downloaded as f64 / total as f64) * 100.0;
180            progress
181        } else {
182            0.0
183        };
184
185        self.eta_seconds = if speed_bps > 0.0 && total > downloaded {
186            #[expect(
187                clippy::cast_precision_loss,
188                reason = "precision loss acceptable for ETA calculation"
189            )]
190            let eta = (total - downloaded) as f64 / speed_bps;
191            Some(eta)
192        } else {
193            None
194        };
195    }
196
197    /// Check if this download is currently active.
198    pub fn is_active(&self) -> bool {
199        self.status == DownloadStatus::Downloading
200    }
201
202    /// Check if this download is complete.
203    #[must_use]
204    pub const fn is_complete(&self) -> bool {
205        matches!(
206            self.status,
207            DownloadStatus::Completed | DownloadStatus::Cancelled | DownloadStatus::Failed
208        )
209    }
210
211    /// Get formatted speed string (e.g., "5.2 MB/s").
212    pub fn speed_display(&self) -> String {
213        format_bytes_per_second(self.speed_bps)
214    }
215
216    /// Get formatted ETA string (e.g., "2m 30s").
217    #[must_use]
218    pub fn eta_display(&self) -> Option<String> {
219        self.eta_seconds.map(|secs| {
220            #[expect(
221                clippy::cast_possible_truncation,
222                clippy::cast_sign_loss,
223                reason = "ETA seconds are always positive and within u64 range"
224            )]
225            let secs_u64 = secs as u64;
226            format_duration(secs_u64)
227        })
228    }
229}
230
231/// A failed download kept for display purposes.
232#[derive(Clone, Debug, Serialize, Deserialize)]
233pub struct FailedDownload {
234    /// Canonical ID of the failed download.
235    pub id: String,
236
237    /// Display name.
238    pub display_name: String,
239
240    /// Error message.
241    pub error: String,
242
243    /// Timestamp when the failure occurred (Unix epoch seconds).
244    pub failed_at: u64,
245
246    /// Whether the failure is recoverable (can retry).
247    pub recoverable: bool,
248
249    /// Bytes downloaded before failure.
250    pub downloaded_bytes: u64,
251}
252
253impl FailedDownload {
254    /// Create a new failed download record.
255    pub fn new(
256        id: impl Into<String>,
257        display_name: impl Into<String>,
258        error: impl Into<String>,
259        failed_at: u64,
260    ) -> Self {
261        Self {
262            id: id.into(),
263            display_name: display_name.into(),
264            error: error.into(),
265            failed_at,
266            recoverable: false,
267            downloaded_bytes: 0,
268        }
269    }
270
271    /// Mark as recoverable.
272    #[must_use]
273    pub const fn with_recoverable(mut self, recoverable: bool) -> Self {
274        self.recoverable = recoverable;
275        self
276    }
277
278    /// Set bytes downloaded before failure.
279    #[must_use]
280    pub const fn with_downloaded_bytes(mut self, bytes: u64) -> Self {
281        self.downloaded_bytes = bytes;
282        self
283    }
284}
285
286/// Format bytes per second as human-readable string.
287fn format_bytes_per_second(bps: f64) -> String {
288    let (value, unit) = if bps >= 1_000_000_000.0 {
289        (bps / 1_000_000_000.0, "GB/s")
290    } else if bps >= 1_000_000.0 {
291        (bps / 1_000_000.0, "MB/s")
292    } else if bps >= 1_000.0 {
293        (bps / 1_000.0, "KB/s")
294    } else {
295        return format!("{bps:.0} B/s");
296    };
297    format!("{value:.1} {unit}")
298}
299
300/// Format seconds as human-readable duration.
301fn format_duration(secs: u64) -> String {
302    let duration = Duration::from_secs(secs);
303    let hours = duration.as_secs() / 3600;
304    let minutes = (duration.as_secs() % 3600) / 60;
305    let seconds = duration.as_secs() % 60;
306
307    if hours > 0 {
308        format!("{hours}h {minutes}m")
309    } else if minutes > 0 {
310        format!("{minutes}m {seconds}s")
311    } else {
312        format!("{seconds}s")
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    #[test]
321    fn test_queue_snapshot_operations() {
322        let mut snapshot = QueueSnapshot::new(10);
323        assert!(snapshot.is_empty());
324        assert!(!snapshot.is_full());
325
326        snapshot
327            .items
328            .push(QueuedDownload::new("id1", "model", "Display", 1, 0));
329        assert!(!snapshot.is_empty());
330        assert_eq!(snapshot.len(), 1);
331        assert!(snapshot.get("id1").is_some());
332        assert!(snapshot.get("nonexistent").is_none());
333    }
334
335    #[test]
336    fn test_queued_download_progress() {
337        let mut download = QueuedDownload::new("id", "model", "Display", 1, 0);
338        download.update_progress(500, 1000, 100.0);
339
340        assert_eq!(download.downloaded_bytes, 500);
341        assert!((download.progress_percent - 50.0).abs() < 0.01);
342        assert!((download.eta_seconds.unwrap() - 5.0).abs() < 0.01);
343    }
344
345    #[test]
346    fn test_speed_display() {
347        let mut download = QueuedDownload::new("id", "model", "Display", 1, 0);
348
349        download.speed_bps = 5_000_000.0;
350        assert_eq!(download.speed_display(), "5.0 MB/s");
351
352        download.speed_bps = 1_500_000_000.0;
353        assert_eq!(download.speed_display(), "1.5 GB/s");
354
355        download.speed_bps = 500.0;
356        assert_eq!(download.speed_display(), "500 B/s");
357    }
358
359    #[test]
360    fn test_format_duration() {
361        assert_eq!(format_duration(30), "30s");
362        assert_eq!(format_duration(90), "1m 30s");
363        assert_eq!(format_duration(3661), "1h 1m");
364    }
365
366    #[test]
367    fn test_serialization_roundtrip() {
368        let download = QueuedDownload::new("id", "model", "Display", 1, 1_234_567_890)
369            .with_quantization(Quantization::Q4KM);
370
371        let json = serde_json::to_string(&download).unwrap();
372        let parsed: QueuedDownload = serde_json::from_str(&json).unwrap();
373
374        assert_eq!(parsed.id, "id");
375        assert_eq!(parsed.quantization, Some(Quantization::Q4KM));
376    }
377}