1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3
4use crate::api::client::ApiClient;
5use crate::manifest::operations::get_after_hash_blobs;
6use crate::manifest::schema::PatchManifest;
7use crate::patch::apply::PatchSources;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum DownloadMode {
18 Diff,
19 Package,
20 File,
21}
22
23impl DownloadMode {
24 pub fn as_tag(&self) -> &'static str {
27 match self {
28 DownloadMode::Diff => "diff",
29 DownloadMode::Package => "package",
30 DownloadMode::File => "file",
31 }
32 }
33
34 pub fn parse(s: &str) -> Result<Self, String> {
36 match s.to_ascii_lowercase().as_str() {
37 "diff" => Ok(DownloadMode::Diff),
38 "package" => Ok(DownloadMode::Package),
39 "file" | "blob" => Ok(DownloadMode::File),
40 other => Err(format!(
41 "unknown download mode '{}'. Expected diff, package, or file.",
42 other
43 )),
44 }
45 }
46}
47
48#[derive(Debug, Clone)]
50pub struct BlobFetchResult {
51 pub hash: String,
52 pub success: bool,
53 pub error: Option<String>,
54}
55
56#[derive(Debug, Clone, Default)]
58pub struct FetchMissingBlobsResult {
59 pub total: usize,
60 pub downloaded: usize,
61 pub failed: usize,
62 pub skipped: usize,
63 pub results: Vec<BlobFetchResult>,
64}
65
66pub type OnProgress = Box<dyn Fn(&str, usize, usize) + Send + Sync>;
70
71pub async fn get_missing_blobs(manifest: &PatchManifest, blobs_path: &Path) -> HashSet<String> {
80 let after_hash_blobs = get_after_hash_blobs(manifest);
81 let mut missing = HashSet::new();
82
83 for hash in after_hash_blobs {
84 let blob_path = blobs_path.join(&hash);
85 if tokio::fs::metadata(&blob_path).await.is_err() {
86 missing.insert(hash);
87 }
88 }
89
90 missing
91}
92
93pub async fn fetch_missing_blobs(
106 manifest: &PatchManifest,
107 blobs_path: &Path,
108 client: &ApiClient,
109 on_progress: Option<&OnProgress>,
110) -> FetchMissingBlobsResult {
111 let missing = get_missing_blobs(manifest, blobs_path).await;
112
113 if missing.is_empty() {
114 return FetchMissingBlobsResult::default();
115 }
116
117 if let Err(e) = tokio::fs::create_dir_all(blobs_path).await {
119 return all_failed_result(missing.iter(), |h| {
120 (h.clone(), format!("Cannot create blobs directory: {}", e))
121 });
122 }
123
124 let hashes: Vec<String> = missing.into_iter().collect();
125 download_hashes(&hashes, blobs_path, client, on_progress).await
126}
127
128fn all_failed_result<'a, I, F>(items: I, mut into_pair: F) -> FetchMissingBlobsResult
132where
133 I: IntoIterator<Item = &'a String>,
134 F: FnMut(&'a String) -> (String, String),
135{
136 let results: Vec<BlobFetchResult> = items
137 .into_iter()
138 .map(|item| {
139 let (hash, error) = into_pair(item);
140 BlobFetchResult {
141 hash,
142 success: false,
143 error: Some(error),
144 }
145 })
146 .collect();
147 let failed = results.len();
148 FetchMissingBlobsResult {
149 total: failed,
150 failed,
151 results,
152 ..FetchMissingBlobsResult::default()
153 }
154}
155
156pub async fn fetch_blobs_by_hash(
163 hashes: &HashSet<String>,
164 blobs_path: &Path,
165 client: &ApiClient,
166 on_progress: Option<&OnProgress>,
167) -> FetchMissingBlobsResult {
168 if hashes.is_empty() {
169 return FetchMissingBlobsResult::default();
170 }
171
172 if let Err(e) = tokio::fs::create_dir_all(blobs_path).await {
174 return all_failed_result(hashes.iter(), |h| {
175 (h.clone(), format!("Cannot create blobs directory: {}", e))
176 });
177 }
178
179 let mut to_download: Vec<String> = Vec::new();
181 let mut skipped: usize = 0;
182 let mut results: Vec<BlobFetchResult> = Vec::new();
183
184 for hash in hashes {
185 let blob_path = blobs_path.join(hash);
186 if tokio::fs::metadata(&blob_path).await.is_ok() {
187 skipped += 1;
188 results.push(BlobFetchResult {
189 hash: hash.clone(),
190 success: true,
191 error: None,
192 });
193 } else {
194 to_download.push(hash.clone());
195 }
196 }
197
198 if to_download.is_empty() {
199 return FetchMissingBlobsResult {
200 total: hashes.len(),
201 downloaded: 0,
202 failed: 0,
203 skipped,
204 results,
205 };
206 }
207
208 let download_result = download_hashes(&to_download, blobs_path, client, on_progress).await;
209
210 FetchMissingBlobsResult {
211 total: hashes.len(),
212 downloaded: download_result.downloaded,
213 failed: download_result.failed,
214 skipped,
215 results: {
216 let mut combined = results;
217 combined.extend(download_result.results);
218 combined
219 },
220 }
221}
222
223pub async fn get_missing_archives(
227 manifest: &PatchManifest,
228 archives_dir: &Path,
229) -> HashSet<String> {
230 let mut missing = HashSet::new();
231 for record in manifest.patches.values() {
232 let archive_path = archives_dir.join(format!("{}.tar.gz", record.uuid));
233 if tokio::fs::metadata(&archive_path).await.is_err() {
234 missing.insert(record.uuid.clone());
235 }
236 }
237 missing
238}
239
240pub async fn fetch_missing_sources(
254 manifest: &PatchManifest,
255 sources: &PatchSources<'_>,
256 mode: DownloadMode,
257 client: &ApiClient,
258 on_progress: Option<&OnProgress>,
259) -> FetchMissingBlobsResult {
260 match mode {
261 DownloadMode::File => {
262 fetch_missing_blobs(manifest, sources.blobs_path, client, on_progress).await
263 }
264 DownloadMode::Diff => match sources.diffs_path {
265 Some(dir) => {
266 fetch_missing_archives_inner(manifest, dir, ArchiveKind::Diff, client, on_progress)
267 .await
268 }
269 None => FetchMissingBlobsResult::default(),
270 },
271 DownloadMode::Package => match sources.packages_path {
272 Some(dir) => {
273 fetch_missing_archives_inner(
274 manifest,
275 dir,
276 ArchiveKind::Package,
277 client,
278 on_progress,
279 )
280 .await
281 }
282 None => FetchMissingBlobsResult::default(),
283 },
284 }
285}
286
287#[derive(Debug, Clone, Copy)]
288enum ArchiveKind {
289 Diff,
290 Package,
291}
292
293async fn fetch_missing_archives_inner(
294 manifest: &PatchManifest,
295 archives_dir: &Path,
296 kind: ArchiveKind,
297 client: &ApiClient,
298 on_progress: Option<&OnProgress>,
299) -> FetchMissingBlobsResult {
300 let missing = get_missing_archives(manifest, archives_dir).await;
301 if missing.is_empty() {
302 return FetchMissingBlobsResult::default();
303 }
304
305 if let Err(e) = tokio::fs::create_dir_all(archives_dir).await {
306 return all_failed_result(missing.iter(), |u| {
307 (
308 u.clone(),
309 format!("Cannot create archives directory: {}", e),
310 )
311 });
312 }
313
314 let uuids: Vec<String> = missing.into_iter().collect();
315 let total = uuids.len();
316 let mut downloaded = 0usize;
317 let mut failed = 0usize;
318 let mut results = Vec::with_capacity(total);
319
320 for (i, uuid) in uuids.iter().enumerate() {
321 if let Some(ref cb) = on_progress {
322 cb(uuid, i + 1, total);
323 }
324
325 let fetch_result = match kind {
326 ArchiveKind::Diff => client.fetch_diff(uuid).await,
327 ArchiveKind::Package => client.fetch_package(uuid).await,
328 };
329
330 match fetch_result {
331 Ok(Some(data)) => {
332 let archive_path: PathBuf = archives_dir.join(format!("{}.tar.gz", uuid));
333 match tokio::fs::write(&archive_path, &data).await {
334 Ok(()) => {
335 results.push(BlobFetchResult {
336 hash: uuid.clone(),
337 success: true,
338 error: None,
339 });
340 downloaded += 1;
341 }
342 Err(e) => {
343 results.push(BlobFetchResult {
344 hash: uuid.clone(),
345 success: false,
346 error: Some(format!("Failed to write archive to disk: {}", e)),
347 });
348 failed += 1;
349 }
350 }
351 }
352 Ok(None) => {
353 results.push(BlobFetchResult {
354 hash: uuid.clone(),
355 success: false,
356 error: Some(format!(
357 "{} archive not found on server",
358 match kind {
359 ArchiveKind::Diff => "Diff",
360 ArchiveKind::Package => "Package",
361 }
362 )),
363 });
364 failed += 1;
365 }
366 Err(e) => {
367 results.push(BlobFetchResult {
368 hash: uuid.clone(),
369 success: false,
370 error: Some(e.to_string()),
371 });
372 failed += 1;
373 }
374 }
375 }
376
377 FetchMissingBlobsResult {
378 total,
379 downloaded,
380 failed,
381 skipped: 0,
382 results,
383 }
384}
385
386pub fn format_fetch_result(result: &FetchMissingBlobsResult) -> String {
388 if result.total == 0 {
389 return "All blobs are present locally.".to_string();
390 }
391
392 let mut lines: Vec<String> = Vec::new();
393
394 if result.downloaded > 0 {
395 lines.push(format!("Downloaded {} blob(s)", result.downloaded));
396 }
397
398 if result.skipped > 0 {
399 lines.push(format!(
400 "{} blob(s) already present locally",
401 result.skipped
402 ));
403 }
404
405 if result.failed > 0 {
406 lines.push(format!("Failed to download {} blob(s)", result.failed));
407
408 let failed_results: Vec<&BlobFetchResult> =
409 result.results.iter().filter(|r| !r.success).collect();
410
411 for r in failed_results.iter().take(5) {
412 let short_hash = if r.hash.len() >= 12 {
413 &r.hash[..12]
414 } else {
415 &r.hash
416 };
417 let err = r.error.as_deref().unwrap_or("unknown error");
418 lines.push(format!(" - {}...: {}", short_hash, err));
419 }
420
421 if failed_results.len() > 5 {
422 lines.push(format!(" ... and {} more", failed_results.len() - 5));
423 }
424 }
425
426 if lines.is_empty() {
429 return "All blobs are present locally.".to_string();
430 }
431
432 lines.join("\n")
433}
434
435fn blob_hash_matches(expected: &str, actual: &str) -> bool {
449 expected.eq_ignore_ascii_case(actual)
450}
451
452async fn download_hashes(
455 hashes: &[String],
456 blobs_path: &Path,
457 client: &ApiClient,
458 on_progress: Option<&OnProgress>,
459) -> FetchMissingBlobsResult {
460 let total = hashes.len();
461 let mut downloaded: usize = 0;
462 let mut failed: usize = 0;
463 let mut results: Vec<BlobFetchResult> = Vec::with_capacity(total);
464
465 for (i, hash) in hashes.iter().enumerate() {
466 if let Some(ref cb) = on_progress {
467 cb(hash, i + 1, total);
468 }
469
470 match client.fetch_blob(hash).await {
471 Ok(Some(data)) => {
472 let actual_hash = crate::hash::git_sha256::compute_git_sha256_from_bytes(&data);
474 if !blob_hash_matches(hash, &actual_hash) {
475 results.push(BlobFetchResult {
476 hash: hash.clone(),
477 success: false,
478 error: Some(format!(
479 "Content hash mismatch: expected {}, got {}",
480 hash, actual_hash
481 )),
482 });
483 failed += 1;
484 continue;
485 }
486
487 let blob_path: PathBuf = blobs_path.join(hash);
488 match tokio::fs::write(&blob_path, &data).await {
489 Ok(()) => {
490 results.push(BlobFetchResult {
491 hash: hash.clone(),
492 success: true,
493 error: None,
494 });
495 downloaded += 1;
496 }
497 Err(e) => {
498 results.push(BlobFetchResult {
499 hash: hash.clone(),
500 success: false,
501 error: Some(format!("Failed to write blob to disk: {}", e)),
502 });
503 failed += 1;
504 }
505 }
506 }
507 Ok(None) => {
508 results.push(BlobFetchResult {
509 hash: hash.clone(),
510 success: false,
511 error: Some("Blob not found on server".to_string()),
512 });
513 failed += 1;
514 }
515 Err(e) => {
516 results.push(BlobFetchResult {
517 hash: hash.clone(),
518 success: false,
519 error: Some(e.to_string()),
520 });
521 failed += 1;
522 }
523 }
524 }
525
526 FetchMissingBlobsResult {
527 total,
528 downloaded,
529 failed,
530 skipped: 0,
531 results,
532 }
533}
534
535#[cfg(test)]
536mod tests {
537 use super::*;
538 use crate::manifest::schema::{PatchFileInfo, PatchManifest, PatchRecord};
539 use std::collections::HashMap;
540
541 fn make_manifest_with_hashes(after_hashes: &[&str]) -> PatchManifest {
542 let mut files = HashMap::new();
543 for (i, ah) in after_hashes.iter().enumerate() {
544 files.insert(
545 format!("package/file{}.js", i),
546 PatchFileInfo {
547 before_hash: format!("before{}{}", "0".repeat(58), format!("{:06}", i)),
548 after_hash: ah.to_string(),
549 },
550 );
551 }
552
553 let mut patches = HashMap::new();
554 patches.insert(
555 "pkg:npm/test@1.0.0".to_string(),
556 PatchRecord {
557 uuid: "test-uuid".to_string(),
558 exported_at: "2024-01-01T00:00:00Z".to_string(),
559 files,
560 vulnerabilities: HashMap::new(),
561 description: "test".to_string(),
562 license: "MIT".to_string(),
563 tier: "free".to_string(),
564 },
565 );
566
567 PatchManifest { patches }
568 }
569
570 #[tokio::test]
571 async fn test_get_missing_blobs_all_missing() {
572 let dir = tempfile::tempdir().unwrap();
573 let blobs_path = dir.path().join("blobs");
574 tokio::fs::create_dir_all(&blobs_path).await.unwrap();
575
576 let h1 = "a".repeat(64);
577 let h2 = "b".repeat(64);
578 let manifest = make_manifest_with_hashes(&[&h1, &h2]);
579
580 let missing = get_missing_blobs(&manifest, &blobs_path).await;
581 assert_eq!(missing.len(), 2);
582 assert!(missing.contains(&h1));
583 assert!(missing.contains(&h2));
584 }
585
586 #[tokio::test]
587 async fn test_get_missing_blobs_some_present() {
588 let dir = tempfile::tempdir().unwrap();
589 let blobs_path = dir.path().join("blobs");
590 tokio::fs::create_dir_all(&blobs_path).await.unwrap();
591
592 let h1 = "a".repeat(64);
593 let h2 = "b".repeat(64);
594
595 tokio::fs::write(blobs_path.join(&h1), b"data")
597 .await
598 .unwrap();
599
600 let manifest = make_manifest_with_hashes(&[&h1, &h2]);
601 let missing = get_missing_blobs(&manifest, &blobs_path).await;
602 assert_eq!(missing.len(), 1);
603 assert!(missing.contains(&h2));
604 assert!(!missing.contains(&h1));
605 }
606
607 #[tokio::test]
608 async fn test_get_missing_blobs_empty_manifest() {
609 let dir = tempfile::tempdir().unwrap();
610 let blobs_path = dir.path().join("blobs");
611 tokio::fs::create_dir_all(&blobs_path).await.unwrap();
612
613 let manifest = PatchManifest::new();
614 let missing = get_missing_blobs(&manifest, &blobs_path).await;
615 assert!(missing.is_empty());
616 }
617
618 #[test]
619 fn test_format_fetch_result_all_present() {
620 let result = FetchMissingBlobsResult {
621 total: 0,
622 downloaded: 0,
623 failed: 0,
624 skipped: 0,
625 results: Vec::new(),
626 };
627 assert_eq!(
628 format_fetch_result(&result),
629 "All blobs are present locally."
630 );
631 }
632
633 #[test]
634 fn test_format_fetch_result_some_downloaded() {
635 let result = FetchMissingBlobsResult {
636 total: 3,
637 downloaded: 2,
638 failed: 1,
639 skipped: 0,
640 results: vec![
641 BlobFetchResult {
642 hash: "a".repeat(64),
643 success: true,
644 error: None,
645 },
646 BlobFetchResult {
647 hash: "b".repeat(64),
648 success: true,
649 error: None,
650 },
651 BlobFetchResult {
652 hash: "c".repeat(64),
653 success: false,
654 error: Some("Blob not found on server".to_string()),
655 },
656 ],
657 };
658 let output = format_fetch_result(&result);
659 assert!(output.contains("Downloaded 2 blob(s)"));
660 assert!(output.contains("Failed to download 1 blob(s)"));
661 assert!(output.contains("cccccccccccc..."));
662 assert!(output.contains("Blob not found on server"));
663 }
664
665 #[test]
666 fn test_format_fetch_result_truncates_at_5() {
667 let results: Vec<BlobFetchResult> = (0..8)
668 .map(|i| BlobFetchResult {
669 hash: format!("{:0>64}", i),
670 success: false,
671 error: Some(format!("error {}", i)),
672 })
673 .collect();
674
675 let result = FetchMissingBlobsResult {
676 total: 8,
677 downloaded: 0,
678 failed: 8,
679 skipped: 0,
680 results,
681 };
682 let output = format_fetch_result(&result);
683 assert!(output.contains("... and 3 more"));
684 }
685
686 #[test]
689 fn test_format_only_downloaded() {
690 let result = FetchMissingBlobsResult {
691 total: 3,
692 downloaded: 3,
693 failed: 0,
694 skipped: 0,
695 results: vec![
696 BlobFetchResult {
697 hash: "a".repeat(64),
698 success: true,
699 error: None,
700 },
701 BlobFetchResult {
702 hash: "b".repeat(64),
703 success: true,
704 error: None,
705 },
706 BlobFetchResult {
707 hash: "c".repeat(64),
708 success: true,
709 error: None,
710 },
711 ],
712 };
713 let output = format_fetch_result(&result);
714 assert!(output.contains("Downloaded 3 blob(s)"));
715 assert!(!output.contains("Failed"));
716 }
717
718 #[test]
719 fn test_format_short_hash() {
720 let result = FetchMissingBlobsResult {
721 total: 1,
722 downloaded: 0,
723 failed: 1,
724 skipped: 0,
725 results: vec![BlobFetchResult {
726 hash: "abc".into(),
727 success: false,
728 error: Some("not found".into()),
729 }],
730 };
731 let output = format_fetch_result(&result);
732 assert!(output.contains("abc..."));
734 }
735
736 #[test]
737 fn test_format_error_none() {
738 let result = FetchMissingBlobsResult {
739 total: 1,
740 downloaded: 0,
741 failed: 1,
742 skipped: 0,
743 results: vec![BlobFetchResult {
744 hash: "d".repeat(64),
745 success: false,
746 error: None,
747 }],
748 };
749 let output = format_fetch_result(&result);
750 assert!(output.contains("unknown error"));
751 }
752
753 #[test]
756 fn test_download_mode_parse() {
757 assert_eq!(DownloadMode::parse("diff").unwrap(), DownloadMode::Diff);
758 assert_eq!(DownloadMode::parse("DIFF").unwrap(), DownloadMode::Diff);
759 assert_eq!(
760 DownloadMode::parse("package").unwrap(),
761 DownloadMode::Package
762 );
763 assert_eq!(DownloadMode::parse("file").unwrap(), DownloadMode::File);
764 assert_eq!(DownloadMode::parse("blob").unwrap(), DownloadMode::File);
766 assert!(DownloadMode::parse("nope").is_err());
767 }
768
769 #[test]
770 fn test_download_mode_tag() {
771 assert_eq!(DownloadMode::Diff.as_tag(), "diff");
772 assert_eq!(DownloadMode::Package.as_tag(), "package");
773 assert_eq!(DownloadMode::File.as_tag(), "file");
774 }
775
776 fn make_manifest_with_uuids(uuids: &[&str]) -> PatchManifest {
777 let mut patches = HashMap::new();
778 for (i, uuid) in uuids.iter().enumerate() {
779 let key = format!("pkg:npm/test-{}@1.0.0", i);
780 patches.insert(
781 key,
782 PatchRecord {
783 uuid: (*uuid).to_string(),
784 exported_at: "2024-01-01T00:00:00Z".to_string(),
785 files: HashMap::new(),
786 vulnerabilities: HashMap::new(),
787 description: "test".to_string(),
788 license: "MIT".to_string(),
789 tier: "free".to_string(),
790 },
791 );
792 }
793 PatchManifest { patches }
794 }
795
796 #[tokio::test]
797 async fn test_get_missing_archives_all_missing() {
798 let dir = tempfile::tempdir().unwrap();
799 let archives = dir.path().join("packages");
800 tokio::fs::create_dir_all(&archives).await.unwrap();
801
802 let u1 = "11111111-1111-4111-8111-111111111111";
803 let u2 = "22222222-2222-4222-8222-222222222222";
804 let manifest = make_manifest_with_uuids(&[u1, u2]);
805
806 let missing = get_missing_archives(&manifest, &archives).await;
807 assert_eq!(missing.len(), 2);
808 assert!(missing.contains(u1));
809 assert!(missing.contains(u2));
810 }
811
812 #[tokio::test]
813 async fn test_get_missing_archives_some_present() {
814 let dir = tempfile::tempdir().unwrap();
815 let archives = dir.path().join("packages");
816 tokio::fs::create_dir_all(&archives).await.unwrap();
817
818 let u1 = "11111111-1111-4111-8111-111111111111";
819 let u2 = "22222222-2222-4222-8222-222222222222";
820
821 tokio::fs::write(archives.join(format!("{u1}.tar.gz")), b"data")
822 .await
823 .unwrap();
824
825 let manifest = make_manifest_with_uuids(&[u1, u2]);
826 let missing = get_missing_archives(&manifest, &archives).await;
827 assert_eq!(missing.len(), 1);
828 assert!(missing.contains(u2));
829 assert!(!missing.contains(u1));
830 }
831
832 #[tokio::test]
833 async fn test_fetch_missing_sources_unsupported_mode_returns_empty() {
834 let dir = tempfile::tempdir().unwrap();
837 let blobs = dir.path().join("blobs");
838 tokio::fs::create_dir_all(&blobs).await.unwrap();
839 let sources = PatchSources::blobs_only(&blobs);
840
841 let manifest = make_manifest_with_uuids(&["11111111-1111-4111-8111-111111111111"]);
842 let (client, _) = crate::api::client::get_api_client_from_env(None).await;
843
844 let res =
845 fetch_missing_sources(&manifest, &sources, DownloadMode::Diff, &client, None).await;
846 assert_eq!(res.total, 0);
847 assert_eq!(res.downloaded, 0);
848 assert_eq!(res.failed, 0);
849
850 let res =
851 fetch_missing_sources(&manifest, &sources, DownloadMode::Package, &client, None).await;
852 assert_eq!(res.total, 0);
853 }
854
855 #[test]
858 fn test_format_all_skipped_is_not_blank() {
859 let result = FetchMissingBlobsResult {
863 total: 2,
864 downloaded: 0,
865 failed: 0,
866 skipped: 2,
867 results: vec![
868 BlobFetchResult {
869 hash: "a".repeat(64),
870 success: true,
871 error: None,
872 },
873 BlobFetchResult {
874 hash: "b".repeat(64),
875 success: true,
876 error: None,
877 },
878 ],
879 };
880 let output = format_fetch_result(&result);
881 assert!(!output.trim().is_empty(), "must not be blank: {:?}", output);
882 assert!(output.contains("2 blob(s) already present"));
883 assert!(!output.contains("Downloaded"));
884 assert!(!output.contains("Failed"));
885 }
886
887 #[test]
888 fn test_format_downloaded_and_skipped_mix() {
889 let result = FetchMissingBlobsResult {
890 total: 3,
891 downloaded: 1,
892 failed: 0,
893 skipped: 2,
894 results: vec![
895 BlobFetchResult {
896 hash: "a".repeat(64),
897 success: true,
898 error: None,
899 },
900 BlobFetchResult {
901 hash: "b".repeat(64),
902 success: true,
903 error: None,
904 },
905 BlobFetchResult {
906 hash: "c".repeat(64),
907 success: true,
908 error: None,
909 },
910 ],
911 };
912 let output = format_fetch_result(&result);
913 assert!(output.contains("Downloaded 1 blob(s)"));
914 assert!(output.contains("2 blob(s) already present"));
915 }
916
917 #[test]
920 fn test_blob_hash_matches_is_case_insensitive() {
921 let lower = "abc123".to_string() + &"0".repeat(58);
926 let upper = lower.to_ascii_uppercase();
927 assert!(blob_hash_matches(&upper, &lower));
928 assert!(blob_hash_matches(&lower, &upper));
929 assert!(blob_hash_matches(&lower, &lower));
930 }
931
932 #[test]
933 fn test_blob_hash_matches_rejects_genuine_mismatch() {
934 let a = "a".repeat(64);
935 let b = "b".repeat(64);
936 assert!(!blob_hash_matches(&a, &b));
937 assert!(!blob_hash_matches(&a, "aa"));
939 }
940
941 #[test]
942 fn test_format_only_failed() {
943 let result = FetchMissingBlobsResult {
944 total: 2,
945 downloaded: 0,
946 failed: 2,
947 skipped: 0,
948 results: vec![
949 BlobFetchResult {
950 hash: "a".repeat(64),
951 success: false,
952 error: Some("timeout".into()),
953 },
954 BlobFetchResult {
955 hash: "b".repeat(64),
956 success: false,
957 error: Some("timeout".into()),
958 },
959 ],
960 };
961 let output = format_fetch_result(&result);
962 assert!(!output.contains("Downloaded"));
963 assert!(output.contains("Failed to download 2 blob(s)"));
964 }
965}