1use super::events::DownloadStatus;
8use super::types::{Quantization, ShardInfo};
9use serde::{Deserialize, Serialize};
10
11#[derive(Clone, Debug, Default, Serialize, Deserialize)]
13pub struct QueueSnapshot {
14 pub items: Vec<QueuedDownload>,
16 pub max_size: u32,
18 pub active_count: u32,
20 pub pending_count: u32,
22 pub recent_failures: Vec<FailedDownload>,
24}
25
26impl QueueSnapshot {
27 #[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 #[must_use]
41 pub const fn is_empty(&self) -> bool {
42 self.items.is_empty()
43 }
44
45 #[must_use]
47 pub const fn is_full(&self) -> bool {
48 self.items.len() >= self.max_size as usize
49 }
50
51 #[must_use]
53 pub const fn len(&self) -> usize {
54 self.items.len()
55 }
56
57 pub fn get(&self, id: &str) -> Option<&QueuedDownload> {
59 self.items.iter().find(|item| item.id == id)
60 }
61}
62
63#[derive(Clone, Debug, Serialize, Deserialize)]
65pub struct QueuedDownload {
66 pub id: String,
68
69 pub model_id: String,
71
72 #[serde(skip_serializing_if = "Option::is_none")]
74 pub quantization: Option<Quantization>,
75
76 pub display_name: String,
78
79 pub status: DownloadStatus,
81
82 pub position: u32,
84
85 pub downloaded_bytes: u64,
87
88 pub total_bytes: u64,
90
91 #[serde(skip_serializing_if = "Option::is_none")]
93 pub speed_bps: Option<f64>,
94
95 #[serde(skip_serializing_if = "Option::is_none")]
97 pub eta_seconds: Option<f64>,
98
99 pub progress_percent: f64,
101
102 pub queued_at: u64,
104
105 #[serde(skip_serializing_if = "Option::is_none")]
107 pub started_at: Option<u64>,
108
109 #[serde(skip_serializing_if = "Option::is_none")]
111 pub group_id: Option<String>,
112
113 #[serde(skip_serializing_if = "Option::is_none")]
115 pub shard_info: Option<ShardInfo>,
116}
117
118impl QueuedDownload {
119 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 #[must_use]
148 pub const fn with_quantization(mut self, quant: Quantization) -> Self {
149 self.quantization = Some(quant);
150 self
151 }
152
153 #[must_use]
155 pub const fn with_status(mut self, status: DownloadStatus) -> Self {
156 self.status = status;
157 self
158 }
159
160 #[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 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 pub fn is_active(&self) -> bool {
198 self.status == DownloadStatus::Downloading
199 }
200
201 #[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#[derive(Clone, Debug, Serialize, Deserialize)]
213pub struct FailedDownload {
214 pub id: String,
216
217 pub display_name: String,
219
220 pub error: String,
222
223 pub failed_at: u64,
225
226 pub recoverable: bool,
228
229 pub downloaded_bytes: u64,
231}
232
233impl FailedDownload {
234 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 #[must_use]
253 pub const fn with_recoverable(mut self, recoverable: bool) -> Self {
254 self.recoverable = recoverable;
255 self
256 }
257
258 #[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 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 #[test]
310 fn test_status_classification_all_variants() {
311 let base = QueuedDownload::new("test-id", "test-model", "test-display", 1, 0);
312
313 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 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 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 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 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 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 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]
361 fn test_update_progress_downloaded_exceeds_total() {
362 let mut download = QueuedDownload::new("test-id", "test-model", "test-display", 1, 0);
363
364 download.update_progress(1500, 1000, Some(100.0), None);
366
367 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]
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 download.update_progress(500, 0, Some(100.0), None);
392
393 assert_eq!(
395 download.progress_percent, 0.0,
396 "Progress should be 0.0 when total is 0"
397 );
398
399 assert!(
401 download.eta_seconds.is_none(),
402 "ETA should be None when total is 0"
403 );
404
405 assert_eq!(download.downloaded_bytes, 500);
407 assert!((download.speed_bps.unwrap() - 100.0).abs() < 0.01);
408 }
409
410 #[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 download.update_progress(500, 1000, Some(0.0), None);
418
419 assert_eq!(download.progress_percent, 50.0, "Progress should be 50.0%");
421
422 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]
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 download.update_progress(1000, 1000, Some(100.0), None);
441
442 assert_eq!(
444 download.progress_percent, 100.0,
445 "Progress should be 100.0%"
446 );
447
448 assert!(
450 download.eta_seconds.is_none(),
451 "ETA should be None when download is complete"
452 );
453
454 assert_eq!(download.downloaded_bytes, 1000);
456 assert!((download.speed_bps.unwrap() - 100.0).abs() < 0.01);
457 }
458
459 #[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 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; download.update_progress(downloaded, total, Some(speed_bps), Some(50_000.0));
470
471 assert!(
473 (download.progress_percent - 50.0).abs() < 0.1,
474 "Progress should be ~50%, got {}",
475 download.progress_percent
476 );
477
478 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 assert_eq!(download.downloaded_bytes, downloaded);
487 assert!((download.speed_bps.unwrap() - speed_bps).abs() < 0.01);
488 }
489
490 #[test]
492 fn test_failed_download_builders() {
493 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 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 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]
524 fn test_queue_snapshot_default() {
525 let default_snapshot = QueueSnapshot::default();
526 let zero_snapshot = QueueSnapshot::new(0);
527
528 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 assert!(default_snapshot.items.is_empty());
535 assert_eq!(default_snapshot.items.len(), zero_snapshot.items.len());
536
537 assert_eq!(default_snapshot.active_count, 0);
539 assert_eq!(default_snapshot.pending_count, 0);
540
541 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}