1use serde::{Deserialize, Serialize};
8use std::fmt;
9use uuid::Uuid;
10
11use super::types::DownloadId;
12
13#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
27#[serde(tag = "kind", rename_all = "snake_case")]
28pub enum CompletionKey {
29 HfFile {
31 repo_id: String,
33 revision: String,
37 filename_canon: String,
40 #[serde(skip_serializing_if = "Option::is_none")]
43 quantization: Option<String>,
44 },
45
46 UrlFile {
48 url: String,
50 filename: String,
52 },
53
54 LocalFile {
56 path: String,
58 },
59}
60
61impl fmt::Display for CompletionKey {
62 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63 match self {
64 Self::HfFile {
65 repo_id,
66 quantization,
67 ..
68 } => {
69 if let Some(quant) = quantization {
70 write!(f, "{repo_id} ({quant})")
71 } else {
72 write!(f, "{repo_id}")
73 }
74 }
75 Self::UrlFile { filename, .. } => write!(f, "{filename}"),
76 Self::LocalFile { path } => {
77 if let Some(name) = path.rsplit('/').next() {
79 write!(f, "{name}")
80 } else {
81 write!(f, "{path}")
82 }
83 }
84 }
85 }
86}
87
88#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "snake_case")]
91pub enum CompletionKind {
92 Downloaded,
94 Failed,
96 Cancelled,
98 AlreadyPresent,
100}
101
102#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
104pub struct AttemptCounts {
105 pub downloaded: u32,
107 pub failed: u32,
109 pub cancelled: u32,
111}
112
113impl AttemptCounts {
114 #[must_use]
116 pub const fn from_kind(kind: CompletionKind) -> Self {
117 match kind {
118 CompletionKind::Downloaded => Self {
119 downloaded: 1,
120 failed: 0,
121 cancelled: 0,
122 },
123 CompletionKind::Failed => Self {
124 downloaded: 0,
125 failed: 1,
126 cancelled: 0,
127 },
128 CompletionKind::Cancelled => Self {
129 downloaded: 0,
130 failed: 0,
131 cancelled: 1,
132 },
133 CompletionKind::AlreadyPresent => Self {
134 downloaded: 0,
135 failed: 0,
136 cancelled: 0,
137 },
138 }
139 }
140
141 pub const fn increment(&mut self, kind: CompletionKind) {
143 match kind {
144 CompletionKind::Downloaded => self.downloaded += 1,
145 CompletionKind::Failed => self.failed += 1,
146 CompletionKind::Cancelled => self.cancelled += 1,
147 CompletionKind::AlreadyPresent => {
148 }
151 }
152 }
153
154 #[must_use]
156 pub const fn total(&self) -> u32 {
157 self.downloaded + self.failed + self.cancelled
158 }
159
160 #[must_use]
162 pub const fn has_retries(&self) -> bool {
163 self.total() > 1
164 }
165}
166
167#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
169pub struct CompletionDetail {
170 pub key: CompletionKey,
172 pub display_name: String,
174 pub last_result: CompletionKind,
176 pub last_completed_at_ms: u64,
178 pub download_ids: Vec<DownloadId>,
181 pub attempt_counts: AttemptCounts,
183}
184
185#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
190pub struct QueueRunSummary {
191 pub run_id: Uuid,
193 pub started_at_ms: u64,
195 pub completed_at_ms: u64,
197
198 pub total_attempts_downloaded: u32,
201 pub total_attempts_failed: u32,
203 pub total_attempts_cancelled: u32,
205
206 pub unique_models_downloaded: u32,
209 pub unique_models_failed: u32,
211 pub unique_models_cancelled: u32,
213
214 pub truncated: bool,
216
217 pub items: Vec<CompletionDetail>,
220}
221
222impl QueueRunSummary {
223 #[must_use]
225 pub const fn total_unique_models(&self) -> u32 {
226 self.unique_models_downloaded + self.unique_models_failed + self.unique_models_cancelled
227 }
228
229 #[must_use]
231 pub const fn total_attempts(&self) -> u32 {
232 self.total_attempts_downloaded + self.total_attempts_failed + self.total_attempts_cancelled
233 }
234
235 #[must_use]
237 pub fn has_retries(&self) -> bool {
238 self.items
239 .iter()
240 .any(|item| item.attempt_counts.has_retries())
241 }
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247 use crate::download::types::DownloadId;
248
249 #[test]
250 fn test_completion_key_display() {
251 let key = CompletionKey::HfFile {
252 repo_id: "unsloth/Llama-3-GGUF".to_string(),
253 revision: "main".to_string(),
254 filename_canon: "model.gguf".to_string(),
255 quantization: Some("Q4_K_M".to_string()),
256 };
257 assert_eq!(key.to_string(), "unsloth/Llama-3-GGUF (Q4_K_M)");
258
259 let key_no_quant = CompletionKey::HfFile {
260 repo_id: "unsloth/Llama-3-GGUF".to_string(),
261 revision: "main".to_string(),
262 filename_canon: "model.gguf".to_string(),
263 quantization: None,
264 };
265 assert_eq!(key_no_quant.to_string(), "unsloth/Llama-3-GGUF");
266 }
267
268 #[test]
269 fn test_completion_key_display_variants() {
270 let url_key = CompletionKey::UrlFile {
272 url: "https://example.com/model.gguf".to_string(),
273 filename: "model.gguf".to_string(),
274 };
275 assert_eq!(url_key.to_string(), "model.gguf");
276
277 let local_key = CompletionKey::LocalFile {
279 path: "/home/user/models/llama-3.Q4_K_M.gguf".to_string(),
280 };
281 assert_eq!(local_key.to_string(), "llama-3.Q4_K_M.gguf");
282
283 let local_no_slash = CompletionKey::LocalFile {
285 path: "model.gguf".to_string(),
286 };
287 assert_eq!(local_no_slash.to_string(), "model.gguf");
288 }
289
290 #[test]
291 fn test_completion_key_serde_roundtrip() {
292 let hf_with_quant = CompletionKey::HfFile {
294 repo_id: "unsloth/Llama-3-GGUF".to_string(),
295 revision: "main".to_string(),
296 filename_canon: "model.gguf".to_string(),
297 quantization: Some("Q4_K_M".to_string()),
298 };
299 let json = serde_json::to_string(&hf_with_quant).unwrap();
300 let parsed: CompletionKey = serde_json::from_str(&json).unwrap();
301 assert_eq!(parsed, hf_with_quant);
302
303 let hf_no_quant = CompletionKey::HfFile {
305 repo_id: "unsloth/Llama-3-GGUF".to_string(),
306 revision: "main".to_string(),
307 filename_canon: "model.gguf".to_string(),
308 quantization: None,
309 };
310 let json_no_quant = serde_json::to_string(&hf_no_quant).unwrap();
311 let parsed_no_quant: CompletionKey = serde_json::from_str(&json_no_quant).unwrap();
312 assert_eq!(parsed_no_quant, hf_no_quant);
313
314 let value: serde_json::Value = serde_json::from_str(&json_no_quant).unwrap();
316 assert!(
317 value.get("quantization").is_none(),
318 "quantization key should be absent when None, not present as null"
319 );
320
321 let value_with: serde_json::Value = serde_json::from_str(&json).unwrap();
323 assert!(
324 value_with.get("quantization").is_some(),
325 "quantization key should be present when Some"
326 );
327
328 let url_key = CompletionKey::UrlFile {
330 url: "https://example.com/model.gguf".to_string(),
331 filename: "model.gguf".to_string(),
332 };
333 let json_url = serde_json::to_string(&url_key).unwrap();
334 let parsed_url: CompletionKey = serde_json::from_str(&json_url).unwrap();
335 assert_eq!(parsed_url, url_key);
336
337 let local_key = CompletionKey::LocalFile {
339 path: "/home/user/models/llama.gguf".to_string(),
340 };
341 let json_local = serde_json::to_string(&local_key).unwrap();
342 let parsed_local: CompletionKey = serde_json::from_str(&json_local).unwrap();
343 assert_eq!(parsed_local, local_key);
344
345 for (kind, expected_wire) in [
347 (CompletionKind::Downloaded, "downloaded"),
348 (CompletionKind::Failed, "failed"),
349 (CompletionKind::Cancelled, "cancelled"),
350 (CompletionKind::AlreadyPresent, "already_present"),
351 ] {
352 let json = serde_json::to_string(&kind).unwrap();
353 assert!(
354 json.contains(expected_wire),
355 "Expected CompletionKind {kind:?} to serialize to snake_case '{expected_wire}', got: {json}"
356 );
357 let parsed: CompletionKind = serde_json::from_str(&json).unwrap();
358 assert_eq!(parsed, kind);
359 }
360 }
361
362 #[test]
363 fn test_completion_key_hash_dedup() {
364 use std::collections::HashSet;
365 use std::hash::{Hash, Hasher};
366
367 let key1 = CompletionKey::HfFile {
368 repo_id: "unsloth/Llama-3-GGUF".to_string(),
369 revision: "main".to_string(),
370 filename_canon: "model.gguf".to_string(),
371 quantization: Some("Q4_K_M".to_string()),
372 };
373 let key2 = CompletionKey::HfFile {
374 repo_id: "unsloth/Llama-3-GGUF".to_string(),
375 revision: "main".to_string(),
376 filename_canon: "model.gguf".to_string(),
377 quantization: Some("Q4_K_M".to_string()),
378 };
379
380 assert_eq!(key1, key2);
382
383 let mut h1 = std::collections::hash_map::DefaultHasher::new();
385 let mut h2 = std::collections::hash_map::DefaultHasher::new();
386 key1.hash(&mut h1);
387 key2.hash(&mut h2);
388 assert_eq!(h1.finish(), h2.finish());
389
390 let mut set = HashSet::new();
392 set.insert(key1);
393 set.insert(key2.clone());
394 assert_eq!(set.len(), 1);
395
396 let key_diff_revision = CompletionKey::HfFile {
398 repo_id: "unsloth/Llama-3-GGUF".to_string(),
399 revision: "v2.0".to_string(),
400 filename_canon: "model.gguf".to_string(),
401 quantization: Some("Q4_K_M".to_string()),
402 };
403 assert_ne!(key2, key_diff_revision);
404
405 let key_diff_filename = CompletionKey::HfFile {
407 repo_id: "unsloth/Llama-3-GGUF".to_string(),
408 revision: "main".to_string(),
409 filename_canon: "model-q8.gguf".to_string(),
410 quantization: Some("Q4_K_M".to_string()),
411 };
412 assert_ne!(key2, key_diff_filename);
413 }
414
415 #[test]
416 fn test_attempt_counts() {
417 let mut counts = AttemptCounts::from_kind(CompletionKind::Downloaded);
418 assert_eq!(counts.downloaded, 1);
419 assert_eq!(counts.total(), 1);
420 assert!(!counts.has_retries());
421
422 counts.increment(CompletionKind::Failed);
423 assert_eq!(counts.failed, 1);
424 assert_eq!(counts.total(), 2);
425 assert!(counts.has_retries());
426
427 counts.increment(CompletionKind::Downloaded);
428 assert_eq!(counts.downloaded, 2);
429 assert_eq!(counts.total(), 3);
430 }
431
432 #[test]
433 fn test_attempt_counts_all_kinds() {
434 let failed = AttemptCounts::from_kind(CompletionKind::Failed);
436 assert_eq!(failed.failed, 1);
437 assert_eq!(failed.downloaded, 0);
438 assert_eq!(failed.cancelled, 0);
439 assert_eq!(failed.total(), 1);
440
441 let cancelled = AttemptCounts::from_kind(CompletionKind::Cancelled);
443 assert_eq!(cancelled.cancelled, 1);
444 assert_eq!(cancelled.downloaded, 0);
445 assert_eq!(cancelled.failed, 0);
446 assert_eq!(cancelled.total(), 1);
447
448 let already = AttemptCounts::from_kind(CompletionKind::AlreadyPresent);
450 let default_counts = AttemptCounts::default();
451 assert_eq!(already, default_counts);
452 assert_eq!(already.downloaded, 0);
453 assert_eq!(already.failed, 0);
454 assert_eq!(already.cancelled, 0);
455 assert_eq!(already.total(), 0);
456
457 let mut counts = AttemptCounts::default();
459 counts.increment(CompletionKind::Cancelled);
460 assert_eq!(counts.cancelled, 1);
461 assert_eq!(counts.total(), 1);
462
463 let before = AttemptCounts {
465 downloaded: 2,
466 failed: 1,
467 cancelled: 0,
468 };
469 let mut counts = before;
470 counts.increment(CompletionKind::AlreadyPresent);
471 assert_eq!(
472 counts, before,
473 "increment(AlreadyPresent) should be a no-op"
474 );
475 }
476
477 #[test]
478 fn test_completion_detail_serde_roundtrip() {
479 let id1 = DownloadId::from_model("llama-3");
482 let id2 = DownloadId::new("unsloth/llama-3-gguf", Some("Q4_K_M"));
483
484 let key = CompletionKey::HfFile {
485 repo_id: "unsloth/llama-3-gguf".to_string(),
486 revision: "main".to_string(),
487 filename_canon: "model.gguf".to_string(),
488 quantization: Some("Q4_K_M".to_string()),
489 };
490
491 let mut counts = AttemptCounts::default();
492 counts.increment(CompletionKind::Failed);
493 counts.increment(CompletionKind::Downloaded);
494
495 let detail = CompletionDetail {
496 key,
497 display_name: "unsloth/llama-3-gguf (Q4_K_M)".to_string(),
498 last_result: CompletionKind::Downloaded,
499 last_completed_at_ms: 1_700_000_000_000,
500 download_ids: vec![id1.clone(), id2.clone()],
501 attempt_counts: counts,
502 };
503
504 let json = serde_json::to_string(&detail).expect("should serialize");
506
507 let restored: CompletionDetail = serde_json::from_str(&json).expect("should deserialize");
509
510 assert_eq!(detail, restored);
512
513 assert_eq!(restored.download_ids.len(), 2);
515 assert_eq!(restored.download_ids[0], id1);
516 assert_eq!(restored.download_ids[1], id2);
517 assert_eq!(restored.attempt_counts.failed, 1);
518 assert_eq!(restored.attempt_counts.downloaded, 1);
519 assert_eq!(restored.attempt_counts.total(), 2);
520
521 let value: serde_json::Value = serde_json::from_str(&json).expect("should parse as Value");
523 assert!(value.get("download_ids").is_some());
524 assert!(value.get("attempt_counts").is_some());
525 assert!(value.get("key").is_some());
526 assert!(value.get("display_name").is_some());
527 }
528
529 #[test]
530 fn test_queue_run_summary_totals() {
531 let summary = QueueRunSummary {
532 run_id: Uuid::nil(),
533 started_at_ms: 0,
534 completed_at_ms: 100_000,
535 total_attempts_downloaded: 5,
536 total_attempts_failed: 1,
537 total_attempts_cancelled: 0,
538 unique_models_downloaded: 3,
539 unique_models_failed: 1,
540 unique_models_cancelled: 0,
541 items: vec![],
542 truncated: false,
543 };
544
545 assert_eq!(summary.total_attempts(), 6);
546 assert_eq!(summary.total_unique_models(), 4);
547 }
548
549 #[test]
550 fn test_queue_run_summary_has_retries() {
551 let single_attempt_detail = CompletionDetail {
553 key: CompletionKey::HfFile {
554 repo_id: "model-a".to_string(),
555 revision: "main".to_string(),
556 filename_canon: "a.gguf".to_string(),
557 quantization: None,
558 },
559 display_name: "model-a".to_string(),
560 last_result: CompletionKind::Downloaded,
561 last_completed_at_ms: 1000,
562 download_ids: vec![DownloadId::from_model("model-a")],
563 attempt_counts: AttemptCounts::from_kind(CompletionKind::Downloaded),
564 };
565
566 let no_retry_summary = QueueRunSummary {
567 run_id: Uuid::nil(),
568 started_at_ms: 0,
569 completed_at_ms: 1000,
570 total_attempts_downloaded: 3,
571 total_attempts_failed: 0,
572 total_attempts_cancelled: 0,
573 unique_models_downloaded: 3,
574 unique_models_failed: 0,
575 unique_models_cancelled: 0,
576 items: vec![
577 single_attempt_detail.clone(),
578 single_attempt_detail.clone(),
579 single_attempt_detail.clone(),
580 ],
581 truncated: false,
582 };
583 assert!(
584 !no_retry_summary.has_retries(),
585 "should be false when all items have exactly 1 attempt"
586 );
587
588 let mut retried_counts = AttemptCounts::default();
590 retried_counts.increment(CompletionKind::Failed);
591 retried_counts.increment(CompletionKind::Failed);
592 retried_counts.increment(CompletionKind::Downloaded);
593
594 let retried_detail = CompletionDetail {
595 key: CompletionKey::HfFile {
596 repo_id: "model-b".to_string(),
597 revision: "main".to_string(),
598 filename_canon: "b.gguf".to_string(),
599 quantization: None,
600 },
601 display_name: "model-b".to_string(),
602 last_result: CompletionKind::Downloaded,
603 last_completed_at_ms: 2000,
604 download_ids: vec![
605 DownloadId::from_model("model-b"),
606 DownloadId::from_model("model-b"),
607 DownloadId::from_model("model-b"),
608 ],
609 attempt_counts: retried_counts,
610 };
611
612 let retry_summary = QueueRunSummary {
613 run_id: Uuid::nil(),
614 started_at_ms: 0,
615 completed_at_ms: 2000,
616 total_attempts_downloaded: 4,
618 total_attempts_failed: 2,
619 total_attempts_cancelled: 0,
620 unique_models_downloaded: 3,
621 unique_models_failed: 0,
622 unique_models_cancelled: 0,
623 items: vec![
624 single_attempt_detail.clone(),
625 retried_detail.clone(),
626 single_attempt_detail,
627 ],
628 truncated: false,
629 };
630 assert!(
631 retry_summary.has_retries(),
632 "should be true when at least one item has >1 attempt"
633 );
634
635 assert_eq!(retry_summary.total_unique_models(), 3);
637 assert_eq!(retry_summary.total_attempts(), 6);
638 assert_ne!(
639 retry_summary.total_unique_models(),
640 retry_summary.total_attempts(),
641 "unique models should differ from total attempts when retries occurred"
642 );
643
644 assert_eq!(retried_detail.attempt_counts.failed, 2);
646 assert_eq!(retried_detail.attempt_counts.downloaded, 1);
647 assert_eq!(retried_detail.attempt_counts.total(), 3);
648 assert!(retried_detail.attempt_counts.has_retries());
649 }
650}