Skip to main content

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};
10
11/// Snapshot of the entire download queue for API responses.
12#[derive(Clone, Debug, Default, Serialize, Deserialize)]
13pub struct QueueSnapshot {
14    /// Items currently in the queue.
15    pub items: Vec<QueuedDownload>,
16    /// Maximum queue capacity.
17    pub max_size: u32,
18    /// Number of active downloads (currently downloading).
19    pub active_count: u32,
20    /// Number of pending downloads (queued, waiting).
21    pub pending_count: u32,
22    /// Recent failures (kept for UI display).
23    pub recent_failures: Vec<FailedDownload>,
24}
25
26impl QueueSnapshot {
27    /// Create a new empty snapshot.
28    #[must_use]
29    pub const fn new(max_size: u32) -> Self {
30        Self {
31            items: Vec::new(),
32            max_size,
33            active_count: 0,
34            pending_count: 0,
35            recent_failures: Vec::new(),
36        }
37    }
38
39    /// Check if the queue is empty.
40    #[must_use]
41    pub const fn is_empty(&self) -> bool {
42        self.items.is_empty()
43    }
44
45    /// Check if the queue is full.
46    #[must_use]
47    pub const fn is_full(&self) -> bool {
48        self.items.len() >= self.max_size as usize
49    }
50
51    /// Get the total number of items.
52    #[must_use]
53    pub const fn len(&self) -> usize {
54        self.items.len()
55    }
56
57    /// Get an item by its ID.
58    pub fn get(&self, id: &str) -> Option<&QueuedDownload> {
59        self.items.iter().find(|item| item.id == id)
60    }
61}
62
63/// A single download in the queue.
64#[derive(Clone, Debug, Serialize, Deserialize)]
65pub struct QueuedDownload {
66    /// Canonical ID (`model_id:quantization` or `model_id`).
67    pub id: String,
68
69    /// Full model ID (e.g., "TheBloke/Llama-2-7B-GGUF").
70    pub model_id: String,
71
72    /// Resolved quantization (if specified).
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub quantization: Option<Quantization>,
75
76    /// Human-readable display name.
77    pub display_name: String,
78
79    /// Current status.
80    pub status: DownloadStatus,
81
82    /// Position in queue (1-based; 1 = active, 2+ = waiting).
83    pub position: u32,
84
85    /// Bytes downloaded so far.
86    pub downloaded_bytes: u64,
87
88    /// Total bytes to download.
89    pub total_bytes: u64,
90
91    /// Download speed in bytes per second; absent until the estimator warms up.
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub speed_bps: Option<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: None,
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    ///
170    /// `speed_bps` and `eta_seconds` come from the download manager's
171    /// `RateEstimator`; this type does not derive a rate or an ETA of its own.
172    pub fn update_progress(
173        &mut self,
174        downloaded: u64,
175        total: u64,
176        speed_bps: Option<f64>,
177        eta_seconds: Option<f64>,
178    ) {
179        self.downloaded_bytes = downloaded;
180        self.total_bytes = total;
181        self.speed_bps = speed_bps;
182        self.eta_seconds = eta_seconds;
183
184        self.progress_percent = if total > 0 {
185            #[expect(
186                clippy::cast_precision_loss,
187                reason = "precision loss acceptable for progress percentage"
188            )]
189            let progress = (downloaded as f64 / total as f64) * 100.0;
190            progress.clamp(0.0, 100.0)
191        } else {
192            0.0
193        };
194    }
195
196    /// Check if this download is currently active.
197    pub fn is_active(&self) -> bool {
198        self.status == DownloadStatus::Downloading
199    }
200
201    /// Check if this download is complete.
202    #[must_use]
203    pub const fn is_complete(&self) -> bool {
204        matches!(
205            self.status,
206            DownloadStatus::Completed | DownloadStatus::Cancelled | DownloadStatus::Failed
207        )
208    }
209}
210
211/// A failed download kept for display purposes.
212#[derive(Clone, Debug, Serialize, Deserialize)]
213pub struct FailedDownload {
214    /// Canonical ID of the failed download.
215    pub id: String,
216
217    /// Display name.
218    pub display_name: String,
219
220    /// Error message.
221    pub error: String,
222
223    /// Timestamp when the failure occurred (Unix epoch seconds).
224    pub failed_at: u64,
225
226    /// Whether the failure is recoverable (can retry).
227    pub recoverable: bool,
228
229    /// Bytes downloaded before failure.
230    pub downloaded_bytes: u64,
231}
232
233impl FailedDownload {
234    /// Create a new failed download record.
235    pub fn new(
236        id: impl Into<String>,
237        display_name: impl Into<String>,
238        error: impl Into<String>,
239        failed_at: u64,
240    ) -> Self {
241        Self {
242            id: id.into(),
243            display_name: display_name.into(),
244            error: error.into(),
245            failed_at,
246            recoverable: false,
247            downloaded_bytes: 0,
248        }
249    }
250
251    /// Mark as recoverable.
252    #[must_use]
253    pub const fn with_recoverable(mut self, recoverable: bool) -> Self {
254        self.recoverable = recoverable;
255        self
256    }
257
258    /// Set bytes downloaded before failure.
259    #[must_use]
260    pub const fn with_downloaded_bytes(mut self, bytes: u64) -> Self {
261        self.downloaded_bytes = bytes;
262        self
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn test_queue_snapshot_operations() {
272        let mut snapshot = QueueSnapshot::new(10);
273        assert!(snapshot.is_empty());
274        assert!(!snapshot.is_full());
275
276        snapshot
277            .items
278            .push(QueuedDownload::new("id1", "model", "Display", 1, 0));
279        assert!(!snapshot.is_empty());
280        assert_eq!(snapshot.len(), 1);
281        assert!(snapshot.get("id1").is_some());
282        assert!(snapshot.get("nonexistent").is_none());
283    }
284
285    #[test]
286    fn test_queued_download_progress() {
287        let mut download = QueuedDownload::new("id", "model", "Display", 1, 0);
288        download.update_progress(500, 1000, Some(100.0), Some(5.0));
289
290        assert_eq!(download.downloaded_bytes, 500);
291        assert!((download.progress_percent - 50.0).abs() < 0.01);
292        // Stored, not derived — the manager's estimator owns this number.
293        assert!((download.eta_seconds.unwrap() - 5.0).abs() < 0.01);
294    }
295
296    #[test]
297    fn test_serialization_roundtrip() {
298        let download = QueuedDownload::new("id", "model", "Display", 1, 1_234_567_890)
299            .with_quantization(Quantization::Q4KM);
300
301        let json = serde_json::to_string(&download).unwrap();
302        let parsed: QueuedDownload = serde_json::from_str(&json).unwrap();
303
304        assert_eq!(parsed.id, "id");
305        assert_eq!(parsed.quantization, Some(Quantization::Q4KM));
306    }
307
308    /// Comprehensive test: verify `is_active()` and `is_complete()` for all 7 `DownloadStatus` variants.
309    #[test]
310    fn test_status_classification_all_variants() {
311        let base = QueuedDownload::new("test-id", "test-model", "test-display", 1, 0);
312
313        // Queued: not active, not complete
314        let queued = base.clone().with_status(DownloadStatus::Queued);
315        assert!(!queued.is_active(), "Queued should not be active");
316        assert!(!queued.is_complete(), "Queued should not be complete");
317
318        // Downloading: active, not complete
319        let downloading = base.clone().with_status(DownloadStatus::Downloading);
320        assert!(downloading.is_active(), "Downloading should be active");
321        assert!(
322            !downloading.is_complete(),
323            "Downloading should not be complete"
324        );
325
326        // Finalizing: not active, not complete
327        let finalizing = base.clone().with_status(DownloadStatus::Finalizing);
328        assert!(!finalizing.is_active(), "Finalizing should not be active");
329        assert!(
330            !finalizing.is_complete(),
331            "Finalizing should not be complete"
332        );
333
334        // Registering: not active, not complete
335        let registering = base.clone().with_status(DownloadStatus::Registering);
336        assert!(!registering.is_active(), "Registering should not be active");
337        assert!(
338            !registering.is_complete(),
339            "Registering should not be complete"
340        );
341
342        // Completed: not active, complete
343        let completed = base.clone().with_status(DownloadStatus::Completed);
344        assert!(!completed.is_active(), "Completed should not be active");
345        assert!(completed.is_complete(), "Completed should be complete");
346
347        // Failed: not active, complete
348        let failed = base.clone().with_status(DownloadStatus::Failed);
349        assert!(!failed.is_active(), "Failed should not be active");
350        assert!(failed.is_complete(), "Failed should be complete");
351
352        // Cancelled: not active, complete
353        let cancelled = base.with_status(DownloadStatus::Cancelled);
354        assert!(!cancelled.is_active(), "Cancelled should not be active");
355        assert!(cancelled.is_complete(), "Cancelled should be complete");
356    }
357
358    /// Test `update_progress` when downloaded bytes exceed total bytes.
359    /// This documents the current behavior: `progress_percent` can exceed 100%, and `eta_seconds` becomes None.
360    #[test]
361    fn test_update_progress_downloaded_exceeds_total() {
362        let mut download = QueuedDownload::new("test-id", "test-model", "test-display", 1, 0);
363
364        // Call update_progress with downloaded > total
365        download.update_progress(1500, 1000, Some(100.0), None);
366
367        // Progress percent exceeds 100% (no clamping)
368        // Clamped: a bar cannot be more than full, and an overshoot here means
369        // the byte counter is double-counting, not that 150% of the file exists.
370        assert!(
371            (download.progress_percent - 100.0).abs() < 0.01,
372            "Progress should clamp to 100.0%"
373        );
374
375        assert!(
376            download.eta_seconds.is_none(),
377            "ETA should be None when downloaded >= total"
378        );
379
380        assert_eq!(download.downloaded_bytes, 1500);
381        assert!((download.speed_bps.unwrap() - 100.0).abs() < 0.01);
382    }
383
384    /// Test `update_progress` with zero total bytes (division-by-zero guard).
385    #[test]
386    #[allow(clippy::float_cmp)]
387    fn test_update_progress_zero_total_bytes() {
388        let mut download = QueuedDownload::new("test-id", "test-model", "test-display", 1, 0);
389
390        // Call update_progress with total = 0
391        download.update_progress(500, 0, Some(100.0), None);
392
393        // Progress percent is 0.0 (the `if total > 0` guard prevents division by zero)
394        assert_eq!(
395            download.progress_percent, 0.0,
396            "Progress should be 0.0 when total is 0"
397        );
398
399        // ETA is None because `total > downloaded` is false when total is 0
400        assert!(
401            download.eta_seconds.is_none(),
402            "ETA should be None when total is 0"
403        );
404
405        // Downloaded bytes and speed are still updated
406        assert_eq!(download.downloaded_bytes, 500);
407        assert!((download.speed_bps.unwrap() - 100.0).abs() < 0.01);
408    }
409
410    /// Test `update_progress` with zero speed (division-by-zero guard for ETA).
411    #[test]
412    #[allow(clippy::float_cmp)]
413    fn test_update_progress_zero_speed() {
414        let mut download = QueuedDownload::new("test-id", "test-model", "test-display", 1, 0);
415
416        // Call update_progress with speed = 0.0
417        download.update_progress(500, 1000, Some(0.0), None);
418
419        // Progress percent is still calculated correctly (50%)
420        assert_eq!(download.progress_percent, 50.0, "Progress should be 50.0%");
421
422        // A zero rate means "stalled". The estimator reports no ETA for it,
423        // and the DTO stores that absence rather than inventing a 0.
424        assert!(
425            download.eta_seconds.is_none(),
426            "ETA should be None when speed is 0"
427        );
428
429        assert_eq!(download.downloaded_bytes, 500);
430        assert_eq!(download.speed_bps, Some(0.0));
431    }
432
433    /// Test `update_progress` when download is complete (downloaded == total).
434    #[test]
435    #[allow(clippy::float_cmp)]
436    fn test_update_progress_complete_download() {
437        let mut download = QueuedDownload::new("test-id", "test-model", "test-display", 1, 0);
438
439        // Call update_progress with downloaded equal to total
440        download.update_progress(1000, 1000, Some(100.0), None);
441
442        // Progress percent should be 100%
443        assert_eq!(
444            download.progress_percent, 100.0,
445            "Progress should be 100.0%"
446        );
447
448        // ETA is None because `total > downloaded` guard is false when equal
449        assert!(
450            download.eta_seconds.is_none(),
451            "ETA should be None when download is complete"
452        );
453
454        // Downloaded bytes and speed are updated normally
455        assert_eq!(download.downloaded_bytes, 1000);
456        assert!((download.speed_bps.unwrap() - 100.0).abs() < 0.01);
457    }
458
459    /// Test `update_progress` with large u64 values — verifies no overflow/panic and results are in the right ballpark despite f64 precision loss.
460    #[test]
461    fn test_update_progress_large_u64_values() {
462        let mut download = QueuedDownload::new("test-id", "test-model", "test-display", 1, 0);
463
464        // 50 TB downloaded out of 100 TB total, at 1 GB/s
465        let downloaded: u64 = 50_000_000_000_000;
466        let total: u64 = 100_000_000_000_000;
467        let speed_bps: f64 = 1_000_000_000.0; // 1 GB/s
468
469        download.update_progress(downloaded, total, Some(speed_bps), Some(50_000.0));
470
471        // Progress should be approximately 50% (within tolerance for f64 precision loss)
472        assert!(
473            (download.progress_percent - 50.0).abs() < 0.1,
474            "Progress should be ~50%, got {}",
475            download.progress_percent
476        );
477
478        // ETA is stored verbatim from the estimator.
479        let eta = download.eta_seconds.expect("ETA should be Some");
480        assert!(
481            (eta - 50_000.0).abs() < 1.0,
482            "ETA should be ~50,000 seconds, got {eta}"
483        );
484
485        // Verify downloaded_bytes and speed were updated
486        assert_eq!(download.downloaded_bytes, downloaded);
487        assert!((download.speed_bps.unwrap() - speed_bps).abs() < 0.01);
488    }
489
490    /// Test `FailedDownload` builder pattern — defaults and chained setters.
491    #[test]
492    fn test_failed_download_builders() {
493        // Create with new() and verify defaults
494        let failed = FailedDownload::new("id", "Display", "network error", 1_234_567_890);
495
496        assert_eq!(failed.id, "id");
497        assert_eq!(failed.display_name, "Display");
498        assert_eq!(failed.error, "network error");
499        assert_eq!(failed.failed_at, 1_234_567_890);
500        // Defaults
501        assert!(!failed.recoverable, "recoverable should default to false");
502        assert_eq!(
503            failed.downloaded_bytes, 0,
504            "downloaded_bytes should default to 0"
505        );
506
507        // Chain builders and verify values are set
508        let failed2 = FailedDownload::new("id2", "Display2", "timeout", 0)
509            .with_recoverable(true)
510            .with_downloaded_bytes(500_000);
511
512        assert!(
513            failed2.recoverable,
514            "recoverable should be true after with_recoverable(true)"
515        );
516        assert_eq!(
517            failed2.downloaded_bytes, 500_000,
518            "downloaded_bytes should be 500_000"
519        );
520    }
521
522    /// Test `QueueSnapshot::default()` produces the same state as `QueueSnapshot::new(0)`.
523    #[test]
524    fn test_queue_snapshot_default() {
525        let default_snapshot = QueueSnapshot::default();
526        let zero_snapshot = QueueSnapshot::new(0);
527
528        // max_size should be 0 for both
529        assert_eq!(default_snapshot.max_size, 0);
530        assert_eq!(zero_snapshot.max_size, 0);
531        assert_eq!(default_snapshot.max_size, zero_snapshot.max_size);
532
533        // items should be empty
534        assert!(default_snapshot.items.is_empty());
535        assert_eq!(default_snapshot.items.len(), zero_snapshot.items.len());
536
537        // counts should be zero
538        assert_eq!(default_snapshot.active_count, 0);
539        assert_eq!(default_snapshot.pending_count, 0);
540
541        // recent_failures should be empty
542        assert!(default_snapshot.recent_failures.is_empty());
543        assert_eq!(
544            default_snapshot.recent_failures.len(),
545            zero_snapshot.recent_failures.len()
546        );
547    }
548}