1use crate::error::{AppError, AppResult};
2use crate::package::{
3 InternedPackageManifest, Package, PackageManifest, SerializableInternedManifest,
4};
5
6use chrono::prelude::*;
7use futures::stream::{FuturesUnordered, StreamExt};
8use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
9use reqwest::Client;
10use reqwest::header;
11use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13use std::time::Duration;
14use tokio::fs;
15use tokio::io::{AsyncReadExt, AsyncWriteExt};
16
17const API_URL: &str = "https://stacklands.thunderstore.io/c/valheim/api/v1/package/";
18const LAST_MODIFIED_FILENAME: &str = "last_modified";
19const API_MANIFEST_FILENAME_V3: &str = "api_manifest_v3.bin.zst";
20const API_MANIFEST_FILENAME_V2: &str = "api_manifest_v2.bin.zst";
21const API_MANIFEST_FILENAME_V1: &str = "api_manifest.bin.zst";
22
23fn last_modified_path(cache_dir: &str) -> PathBuf {
33 PathBuf::from(cache_dir).join(LAST_MODIFIED_FILENAME)
34}
35
36fn api_manifest_path_v3(cache_dir: &str) -> PathBuf {
46 PathBuf::from(cache_dir).join(API_MANIFEST_FILENAME_V3)
47}
48
49fn api_manifest_path_v2(cache_dir: &str) -> PathBuf {
59 PathBuf::from(cache_dir).join(API_MANIFEST_FILENAME_V2)
60}
61
62fn api_manifest_path_v1(cache_dir: &str) -> PathBuf {
72 PathBuf::from(cache_dir).join(API_MANIFEST_FILENAME_V1)
73}
74
75pub async fn get_manifest(
97 cache_dir: &str,
98 api_url: Option<&str>,
99) -> AppResult<InternedPackageManifest> {
100 let api_url = api_url.unwrap_or(API_URL);
101 let last_modified = local_last_modified(cache_dir).await?;
102 tracing::info!("Manifest last modified: {}", last_modified);
103
104 if api_manifest_file_exists(cache_dir) && network_last_modified(api_url).await? <= last_modified {
105 tracing::info!("Loading manifest from cache");
106 get_manifest_from_disk(cache_dir).await
107 } else {
108 tracing::info!("Downloading new manifest");
109 get_manifest_from_network_and_cache(cache_dir, api_url).await
110 }
111}
112
113async fn get_manifest_from_disk(cache_dir: &str) -> AppResult<InternedPackageManifest> {
128 let path_v3 = api_manifest_path_v3(cache_dir);
129 let path_v2 = api_manifest_path_v2(cache_dir);
130 let path_v1 = api_manifest_path_v1(cache_dir);
131
132 if path_v3.exists() {
133 tracing::debug!("Loading v3 manifest format");
134
135 let mut file = fs::File::open(&path_v3).await?;
136 let mut compressed_data = Vec::new();
137 file.read_to_end(&mut compressed_data).await?;
138
139 let decompressed_data = zstd::decode_all(compressed_data.as_slice())
140 .map_err(|e| AppError::Manifest(format!("Failed to decompress v3 manifest: {}", e)))?;
141
142 let serializable: SerializableInternedManifest = bincode::deserialize(&decompressed_data)
143 .map_err(|e| AppError::Manifest(format!("Failed to deserialize v3 manifest: {}", e)))?;
144
145 let manifest: InternedPackageManifest = serializable.into();
146
147 manifest
148 .validate()
149 .map_err(|e| AppError::Manifest(format!("V3 manifest validation failed: {}", e)))?;
150
151 return Ok(manifest);
152 }
153
154 if path_v2.exists() {
155 tracing::info!("Found v2 manifest, migrating to v3 format");
156
157 let mut file = fs::File::open(&path_v2).await?;
158 let mut compressed_data = Vec::new();
159 file.read_to_end(&mut compressed_data).await?;
160
161 let decompressed_data = zstd::decode_all(compressed_data.as_slice())
162 .map_err(|e| AppError::Manifest(format!("Failed to decompress v2 manifest: {}", e)))?;
163
164 let v2_manifest: PackageManifest = bincode::deserialize(&decompressed_data)
165 .map_err(|e| AppError::Manifest(format!("Failed to deserialize v2 manifest: {}", e)))?;
166
167 let manifest: InternedPackageManifest = v2_manifest.into();
168
169 manifest.validate().map_err(|e| {
170 AppError::Manifest(format!(
171 "V3 manifest validation failed during migration: {}",
172 e
173 ))
174 })?;
175
176 let serializable: SerializableInternedManifest = (&manifest).into();
177 let binary_data = bincode::serialize(&serializable)
178 .map_err(|e| AppError::Manifest(format!("Failed to serialize v3 manifest: {}", e)))?;
179
180 write_cache_to_disk(path_v3.clone(), &binary_data, true).await?;
181
182 match tokio::fs::metadata(&path_v3).await {
183 Ok(metadata) if metadata.len() > 0 => {
184 tracing::info!("V3 manifest written successfully, removing v2");
185
186 if let Err(e) = fs::remove_file(&path_v2).await {
187 tracing::warn!(
188 "Failed to remove old v2 manifest (keeping as backup): {}",
189 e
190 );
191 }
192 }
193 Ok(_) => {
194 tracing::error!("V3 manifest written but is empty, keeping v2 as backup");
195 }
196 Err(e) => {
197 tracing::error!(
198 "Failed to verify v3 manifest write: {}, keeping v2 as backup",
199 e
200 );
201 }
202 }
203
204 return Ok(manifest);
205 }
206
207 if path_v1.exists() {
208 tracing::info!("Found v1 manifest, migrating to v3 format");
209
210 let mut file = fs::File::open(&path_v1).await?;
211 let mut compressed_data = Vec::new();
212 file.read_to_end(&mut compressed_data).await?;
213
214 let decompressed_data = zstd::decode_all(compressed_data.as_slice())
215 .map_err(|e| AppError::Manifest(format!("Failed to decompress v1 manifest: {}", e)))?;
216
217 let packages: Vec<Package> = bincode::deserialize(&decompressed_data)
218 .map_err(|e| AppError::Manifest(format!("Failed to deserialize v1 manifest: {}", e)))?;
219
220 let manifest: InternedPackageManifest = packages.into();
221
222 manifest.validate().map_err(|e| {
223 AppError::Manifest(format!(
224 "V3 manifest validation failed during migration: {}",
225 e
226 ))
227 })?;
228
229 let serializable: SerializableInternedManifest = (&manifest).into();
230 let binary_data = bincode::serialize(&serializable)
231 .map_err(|e| AppError::Manifest(format!("Failed to serialize v3 manifest: {}", e)))?;
232
233 write_cache_to_disk(path_v3.clone(), &binary_data, true).await?;
234
235 match tokio::fs::metadata(&path_v3).await {
236 Ok(metadata) if metadata.len() > 0 => {
237 tracing::info!("V3 manifest written successfully, removing v1");
238
239 if let Err(e) = fs::remove_file(&path_v1).await {
240 tracing::warn!(
241 "Failed to remove old v1 manifest (keeping as backup): {}",
242 e
243 );
244 }
245 }
246 Ok(_) => {
247 tracing::error!("V3 manifest written but is empty, keeping v1 as backup");
248 }
249 Err(e) => {
250 tracing::error!(
251 "Failed to verify v3 manifest write: {}, keeping v1 as backup",
252 e
253 );
254 }
255 }
256
257 return Ok(manifest);
258 }
259
260 Err(AppError::Manifest("No cached manifest found".to_string()))
261}
262
263async fn get_manifest_from_network(
287 api_url: &str,
288) -> AppResult<(Vec<Package>, DateTime<FixedOffset>)> {
289 let client = Client::builder().build()?;
290
291 let multi_progress = MultiProgress::new();
292 let style_template = "";
293 let progress_style = ProgressStyle::with_template(style_template)
294 .unwrap()
295 .tick_strings(&["-", "\\", "|", "/", ""]);
296
297 let progress_bar = multi_progress.add(ProgressBar::new(100000));
298 progress_bar.set_style(progress_style);
299 progress_bar.set_message("Downloading Api Manifest");
300 progress_bar.enable_steady_tick(Duration::from_millis(130));
301
302 let response = client.get(api_url).send().await?;
303 let last_modified_str = response
304 .headers()
305 .get(header::LAST_MODIFIED)
306 .ok_or(AppError::MissingHeader("Last-Modified".to_string()))?
307 .to_str()?;
308 let last_modified = DateTime::parse_from_rfc2822(last_modified_str)?;
309
310 let raw_response_data = response.bytes().await?;
311
312 let packages: Vec<Package> = serde_json::from_slice(&raw_response_data)?;
313
314 progress_bar.finish_with_message(" Downloaded Api Manifest");
315
316 Ok((packages, last_modified))
317}
318
319async fn get_manifest_from_network_and_cache(
340 cache_dir: &str,
341 api_url: &str,
342) -> AppResult<InternedPackageManifest> {
343 let results = get_manifest_from_network(api_url).await?;
344 let packages = results.0;
345 let last_modified_from_network = results.1;
346
347 write_cache_to_disk(
348 last_modified_path(cache_dir),
349 last_modified_from_network.to_rfc2822().as_bytes(),
350 false,
351 )
352 .await?;
353
354 let manifest: InternedPackageManifest = packages.into();
355 let serializable: SerializableInternedManifest = (&manifest).into();
356 let binary_data = bincode::serialize(&serializable)
357 .map_err(|e| AppError::Manifest(format!("Failed to serialize manifest: {}", e)))?;
358
359 write_cache_to_disk(api_manifest_path_v3(cache_dir), &binary_data, true).await?;
360
361 Ok(manifest)
362}
363
364async fn local_last_modified(cache_dir: &str) -> AppResult<DateTime<FixedOffset>> {
378 let path = last_modified_path(cache_dir);
379
380 if let Ok(mut file) = fs::File::open(&path).await {
381 tracing::info!("Last modified file exists and was opened.");
382 let mut contents = String::new();
383 file.read_to_string(&mut contents).await?;
384
385 let last_modified = DateTime::parse_from_rfc2822(&contents)?;
386
387 Ok(last_modified)
388 } else {
389 tracing::info!("Last modified file does not exist and was not opened.");
390 let dt = DateTime::from_timestamp(0, 0).unwrap().naive_utc();
391 let offset = FixedOffset::east_opt(0).unwrap();
392
393 let last_modified = DateTime::<FixedOffset>::from_naive_utc_and_offset(dt, offset);
394 Ok(last_modified)
395 }
396}
397
398async fn network_last_modified(api_url: &str) -> AppResult<DateTime<FixedOffset>> {
417 let client = Client::builder().build()?;
418
419 let response = client.head(api_url).send().await?;
420
421 let last_modified =
422 response
423 .headers()
424 .get(header::LAST_MODIFIED)
425 .ok_or(AppError::MissingHeader(
426 "Last-Modified for API manifest head request".to_string(),
427 ))?;
428 let last_modified_date = DateTime::parse_from_rfc2822(last_modified.to_str()?)?;
429
430 Ok(last_modified_date)
431}
432
433fn api_manifest_file_exists(cache_dir: &str) -> bool {
443 api_manifest_path_v3(cache_dir).exists()
444 || api_manifest_path_v2(cache_dir).exists()
445 || api_manifest_path_v1(cache_dir).exists()
446}
447
448async fn write_cache_to_disk<T: AsRef<Path>>(
469 path: T,
470 contents: &[u8],
471 use_compression: bool,
472) -> AppResult<()> {
473 if let Some(parent) = path.as_ref().parent() {
474 fs::create_dir_all(parent).await?;
475 }
476
477 if !path.as_ref().exists() {
478 fs::File::create(&path).await?;
479 }
480
481 let mut file = fs::OpenOptions::new()
482 .write(true)
483 .truncate(true)
484 .open(&path)
485 .await?;
486
487 if use_compression {
488 let compressed_data = zstd::encode_all(contents, 9)
489 .map_err(|e| AppError::Manifest(format!("Failed to compress data: {}", e)))?;
490
491 file.write_all(&compressed_data).await?;
492 file.flush().await?;
493 } else {
494 file.write_all(contents).await?;
495 file.flush().await?;
496 }
497
498 Ok(())
499}
500
501pub async fn download_files(urls: HashMap<String, String>, cache_dir: &str) -> AppResult<()> {
516 if urls.is_empty() {
517 tracing::debug!("No files to download");
518
519 return Ok(());
520 }
521
522 tracing::debug!("Processing {} mods", urls.len());
523
524 let client = Client::builder().timeout(Duration::from_secs(60)).build()?;
525
526 let multi_progress = MultiProgress::new();
527 let style_template = "";
528 let progress_style = ProgressStyle::with_template(style_template)
529 .unwrap()
530 .progress_chars("#>-");
531
532 let futures: FuturesUnordered<_> = urls
533 .into_iter()
534 .map(|(archive_filename, url)| {
535 let client = client.clone();
536 let multi_progress = multi_progress.clone();
537 let progress_style = progress_style.clone();
538
539 let cache_dir = cache_dir.to_string();
540 tokio::spawn(async move {
541 download_file(
542 client,
543 &url,
544 &archive_filename,
545 multi_progress,
546 progress_style,
547 &cache_dir,
548 )
549 .await
550 })
551 })
552 .collect();
553
554 let responses = futures::stream::iter(futures)
555 .buffer_unordered(2)
556 .collect::<Vec<_>>()
557 .await;
558
559 for response in responses {
560 match response {
561 Ok(Ok(_)) => {}
562 Ok(Err(err)) => {
563 tracing::error!("Download error: {:?}", err);
564 }
565 Err(err) => {
566 return Err(AppError::TaskFailed(err));
567 }
568 }
569 }
570
571 Ok(())
572}
573
574async fn download_file(
598 client: reqwest::Client,
599 url: &String,
600 filename: &String,
601 multi_progress: MultiProgress,
602 progress_style: ProgressStyle,
603 cache_dir: &str,
604) -> AppResult<()> {
605 let mut downloads_directory = PathBuf::from(cache_dir);
606 downloads_directory.push("downloads");
607 let mut file_path = downloads_directory.clone();
608 file_path.push(filename);
609
610 tokio::fs::DirBuilder::new()
611 .recursive(true)
612 .create(&downloads_directory)
613 .await?;
614
615 if file_path.exists() {
616 tracing::debug!("{} already exists, skipping download", filename);
617
618 return Ok(());
619 }
620
621 let response = client.get(url).send().await?;
622 let content_length = response
623 .headers()
624 .get(header::CONTENT_LENGTH)
625 .ok_or(AppError::MissingHeader(format!(
626 "Content-Length header for {}",
627 url
628 )))?
629 .to_str()?
630 .parse::<u64>()?;
631 let mut response_data = response.bytes_stream();
632
633 let progress_bar = multi_progress.add(ProgressBar::new(content_length));
634 progress_bar.set_style(progress_style);
635 progress_bar.set_message(filename.clone());
636
637 let mut file = tokio::fs::File::create(file_path).await?;
638
639 while let Some(result) = response_data.next().await {
640 let chunk = result?;
641 file.write_all(&chunk).await?;
642 progress_bar.inc(chunk.len() as u64);
643 }
644
645 file.flush().await?;
646 file.sync_all().await?;
647
648 progress_bar.finish();
649
650 Ok(())
651}
652
653#[cfg(test)]
654mod tests {
655 use super::*;
656 use mockito::Server;
657 use std::fs::File;
658 use std::io::Write;
659 use tempfile::tempdir;
660 use time::OffsetDateTime;
661 use tokio::runtime::Runtime;
662
663 #[test]
664 fn test_downloads_directory_construction() {
665 let temp_dir = tempdir().unwrap();
666 let cache_dir = temp_dir.path().to_str().unwrap();
667
668 let mut downloads_directory = PathBuf::from(cache_dir);
669 downloads_directory.push("downloads");
670
671 let expected_directory = PathBuf::from(cache_dir).join("downloads");
672 assert_eq!(downloads_directory, expected_directory);
673 }
674
675 #[test]
676 fn test_api_manifest_file_exists() {
677 let temp_dir = tempdir().unwrap();
678 let temp_dir_str = temp_dir.path().to_str().unwrap();
679
680 assert!(!api_manifest_file_exists(temp_dir_str));
681
682 let mut path = PathBuf::from(temp_dir_str);
683 path.push(API_MANIFEST_FILENAME_V3);
684 let _ = File::create(path).unwrap();
685
686 assert!(api_manifest_file_exists(temp_dir_str));
687 }
688
689 #[test]
690 fn test_path_construction() {
691 let cache_dir = "/some/cache/dir";
692
693 let last_modified = last_modified_path(cache_dir);
694 let api_manifest = api_manifest_path_v3(cache_dir);
695
696 assert_eq!(
697 last_modified,
698 Path::new(cache_dir).join(LAST_MODIFIED_FILENAME)
699 );
700 assert_eq!(
701 api_manifest,
702 Path::new(cache_dir).join(API_MANIFEST_FILENAME_V3)
703 );
704 }
705
706 #[test]
707 fn test_write_cache_to_disk() {
708 let temp_dir = tempdir().unwrap();
709 let cache_path = temp_dir.path().join("test_cache.txt");
710 let test_data = b"Test cache data";
711
712 let rt = Runtime::new().unwrap();
713 let result = rt.block_on(write_cache_to_disk(&cache_path, test_data, false));
714
715 assert!(result.is_ok());
716 assert!(cache_path.exists());
717
718 let content = std::fs::read(&cache_path).unwrap();
719 assert_eq!(content, test_data);
720
721 let new_data = b"Updated cache data";
722 let result = rt.block_on(write_cache_to_disk(&cache_path, new_data, false));
723
724 assert!(result.is_ok());
725 let content = std::fs::read(&cache_path).unwrap();
726
727 assert_eq!(content, new_data);
728 }
729
730 #[test]
731 fn test_write_cache_to_disk_creates_directories() {
732 let temp_dir = tempdir().unwrap();
733
734 let nonexistent_subdir = temp_dir.path().join("subdir1/subdir2");
735 let cache_path = nonexistent_subdir.join("test_cache.txt");
736
737 if nonexistent_subdir.exists() {
738 std::fs::remove_dir_all(&nonexistent_subdir).unwrap();
739 }
740
741 assert!(!nonexistent_subdir.exists());
742 assert!(!cache_path.exists());
743
744 let test_data = b"Test cache data";
745 let rt = Runtime::new().unwrap();
746
747 let result = rt.block_on(write_cache_to_disk(&cache_path, test_data, false));
748 assert!(result.is_ok());
749
750 assert!(
751 nonexistent_subdir.exists(),
752 "Directory structure should be created"
753 );
754 assert!(cache_path.exists(), "File should be created");
755
756 let content = std::fs::read(&cache_path).unwrap();
757 assert_eq!(content, test_data, "File should contain the expected data");
758 }
759
760 #[test]
761 fn test_write_cache_to_disk_with_compression() {
762 let temp_dir = tempdir().unwrap();
763 let api_manifest_path = temp_dir.path().join("test_compressed.bin");
764
765 let package = Package {
766 name: Some("TestMod".to_string()),
767 full_name: Some("TestOwner-TestMod".to_string()),
768 owner: Some("TestOwner".to_string()),
769 package_url: Some("https://example.com/TestMod".to_string()),
770 date_created: OffsetDateTime::now_utc(),
771 date_updated: OffsetDateTime::now_utc(),
772 uuid4: Some("test-uuid".to_string()),
773 rating_score: Some(5),
774 is_pinned: Some(false),
775 is_deprecated: Some(false),
776 has_nsfw_content: Some(false),
777 categories: vec!["test".to_string()],
778 versions: vec![],
779 };
780
781 let packages = vec![package];
782 let manifest: PackageManifest = packages.into();
783 let binary_data = bincode::serialize(&manifest).unwrap();
784
785 let rt = Runtime::new().unwrap();
786 let result = rt.block_on(write_cache_to_disk(&api_manifest_path, &binary_data, true));
787
788 assert!(result.is_ok());
789 assert!(api_manifest_path.exists());
790
791 let compressed_data = std::fs::read(&api_manifest_path).unwrap();
792 let decompressed_data = zstd::decode_all(compressed_data.as_slice()).unwrap();
793 let decoded_manifest: PackageManifest = bincode::deserialize(&decompressed_data).unwrap();
794
795 assert_eq!(decoded_manifest.len(), 1);
796 assert_eq!(
797 decoded_manifest.full_names[0],
798 Some("TestOwner-TestMod".to_string())
799 );
800 }
801
802 #[test]
803 fn test_local_last_modified() {
804 let temp_dir = tempdir().unwrap();
805 let temp_dir_str = temp_dir.path().to_str().unwrap();
806 let rt = Runtime::new().unwrap();
807
808 {
809 let result = rt.block_on(local_last_modified(temp_dir_str));
810
811 assert!(result.is_ok());
812 }
813
814 let test_date = "Wed, 21 Feb 2024 15:30:45 GMT";
815 let mut path = PathBuf::from(temp_dir_str);
816 path.push(LAST_MODIFIED_FILENAME);
817 let mut file = File::create(path).unwrap();
818 file.write_all(test_date.as_bytes()).unwrap();
819
820 {
821 let result = rt.block_on(local_last_modified(temp_dir_str));
822
823 assert!(result.is_ok());
824 }
825 }
826
827 #[test]
828 fn test_get_manifest_from_disk() {
829 let temp_dir = tempdir().unwrap();
830 let temp_dir_str = temp_dir.path().to_str().unwrap();
831
832 let package = Package {
833 name: Some("ModA".to_string()),
834 full_name: Some("Owner-ModA".to_string()),
835 owner: Some("Owner".to_string()),
836 package_url: Some("https://example.com/ModA".to_string()),
837 date_created: OffsetDateTime::now_utc(),
838 date_updated: OffsetDateTime::now_utc(),
839 uuid4: Some("test-uuid".to_string()),
840 rating_score: Some(5),
841 is_pinned: Some(false),
842 is_deprecated: Some(false),
843 has_nsfw_content: Some(false),
844 categories: vec!["test".to_string()],
845 versions: vec![],
846 };
847
848 let packages = vec![package];
849 let interned_manifest: InternedPackageManifest = packages.into();
850 let serializable: SerializableInternedManifest = (&interned_manifest).into();
851 let binary_data = bincode::serialize(&serializable).unwrap();
852 let compressed_data = zstd::encode_all(binary_data.as_slice(), 9).unwrap();
853 let mut path = PathBuf::from(temp_dir_str);
854 path.push(API_MANIFEST_FILENAME_V3);
855 let mut file = File::create(path).unwrap();
856 file.write_all(&compressed_data).unwrap();
857
858 let rt = Runtime::new().unwrap();
859 let manifest = rt.block_on(get_manifest_from_disk(temp_dir_str)).unwrap();
860
861 assert_eq!(manifest.len(), 1);
862 assert_eq!(
863 manifest.resolve_full_name_at(0),
864 Some("Owner-ModA".to_string())
865 );
866 }
867
868 #[test]
869 fn test_get_manifest_from_disk_error() {
870 let temp_dir = tempdir().unwrap();
871 let temp_dir_str = temp_dir.path().to_str().unwrap();
872
873 let mut path = PathBuf::from(temp_dir_str);
874 path.push(API_MANIFEST_FILENAME_V3);
875 let mut file = File::create(path).unwrap();
876 file
877 .write_all(b"This is not valid compressed data")
878 .unwrap();
879
880 let rt = Runtime::new().unwrap();
881 let result = rt.block_on(get_manifest_from_disk(temp_dir_str));
882
883 assert!(result.is_err());
884 }
885
886 #[test]
887 fn test_network_last_modified() {
888 let mut server = Server::new();
889 let test_date = "Wed, 21 Feb 2024 15:30:45 GMT";
890 let mock = server
891 .mock("HEAD", "/c/valheim/api/v1/package/")
892 .with_status(200)
893 .with_header("Last-Modified", test_date)
894 .create();
895 let api_url = format!("{}/c/valheim/api/v1/package/", server.url());
896 let rt = Runtime::new().unwrap();
897
898 let result = rt.block_on(network_last_modified(&api_url));
899
900 assert!(result.is_ok());
901 if let Ok(parsed_date) = result {
902 let expected_date = DateTime::parse_from_rfc2822(test_date).unwrap();
903 assert_eq!(parsed_date, expected_date);
904 }
905
906 mock.assert();
907 }
908
909 #[test]
910 fn test_get_manifest_from_network() {
911 let mut server = Server::new();
912
913 let test_json = r#"[
914 {
915 "name": "ModA",
916 "full_name": "Owner-ModA",
917 "owner": "Owner",
918 "package_url": "https://example.com/mods/ModA",
919 "date_created": "2024-01-01T12:00:00Z",
920 "date_updated": "2024-01-02T12:00:00Z",
921 "uuid4": "test-uuid",
922 "rating_score": 5,
923 "is_pinned": false,
924 "is_deprecated": false,
925 "has_nsfw_content": false,
926 "categories": ["category1"],
927 "versions": [
928 {
929 "name": "ModA",
930 "full_name": "Owner-ModA",
931 "description": "Test description",
932 "icon": "icon.png",
933 "version_number": "1.0.0",
934 "dependencies": [],
935 "download_url": "https://example.com/mods/ModA/download",
936 "downloads": 100,
937 "date_created": "2024-01-01T12:00:00Z",
938 "website_url": "https://example.com",
939 "is_active": true,
940 "uuid4": "test-version-uuid",
941 "file_size": 1024
942 }
943 ]
944 }
945 ]"#;
946
947 let test_date = "Wed, 21 Feb 2024 15:30:45 GMT";
948
949 let mock = server
950 .mock("GET", "/c/valheim/api/v1/package/")
951 .with_status(200)
952 .with_header("Content-Type", "application/json")
953 .with_header("Last-Modified", test_date)
954 .with_body(test_json)
955 .create();
956
957 let api_url = format!("{}/c/valheim/api/v1/package/", server.url());
958
959 let rt = Runtime::new().unwrap();
960 let result = rt.block_on(get_manifest_from_network(&api_url));
961
962 assert!(result.is_ok());
963 if let Ok((packages, last_modified)) = result {
964 assert_eq!(packages.len(), 1);
965 assert_eq!(packages[0].full_name, Some("Owner-ModA".to_string()));
966 let expected_date = DateTime::parse_from_rfc2822(test_date).unwrap();
967 assert_eq!(last_modified, expected_date);
968 }
969
970 mock.assert();
971 }
972
973 #[test]
974 fn test_get_manifest() {
975 let temp_dir = tempdir().unwrap();
976 let temp_dir_str = temp_dir.path().to_str().unwrap();
977
978 let package = Package {
979 name: Some("CachedMod".to_string()),
980 full_name: Some("CachedOwner-CachedMod".to_string()),
981 owner: Some("CachedOwner".to_string()),
982 package_url: Some("https://example.com/CachedMod".to_string()),
983 date_created: OffsetDateTime::now_utc(),
984 date_updated: OffsetDateTime::now_utc(),
985 uuid4: Some("cached-uuid".to_string()),
986 rating_score: Some(5),
987 is_pinned: Some(false),
988 is_deprecated: Some(false),
989 has_nsfw_content: Some(false),
990 categories: vec!["test".to_string()],
991 versions: vec![],
992 };
993
994 let packages = vec![package];
995 let interned_manifest: InternedPackageManifest = packages.into();
996 let serializable: SerializableInternedManifest = (&interned_manifest).into();
997 let binary_data = bincode::serialize(&serializable).unwrap();
998 let compressed_data = zstd::encode_all(binary_data.as_slice(), 9).unwrap();
999
1000 std::fs::create_dir_all(PathBuf::from(temp_dir_str)).unwrap();
1001
1002 let mut manifest_path = PathBuf::from(temp_dir_str);
1003 manifest_path.push(API_MANIFEST_FILENAME_V3);
1004 let mut file = File::create(manifest_path).unwrap();
1005 file.write_all(&compressed_data).unwrap();
1006
1007 let now = chrono::Utc::now().with_timezone(&chrono::FixedOffset::east_opt(0).unwrap());
1008 let recent_date = now.to_rfc2822();
1009 let mut last_mod_path = PathBuf::from(temp_dir_str);
1010 last_mod_path.push(LAST_MODIFIED_FILENAME);
1011 let mut file = File::create(&last_mod_path).unwrap();
1012 file.write_all(recent_date.as_bytes()).unwrap();
1013
1014 let rt = Runtime::new().unwrap();
1015
1016 let result = rt.block_on(get_manifest(temp_dir_str, None));
1017
1018 assert!(result.is_ok());
1019 }
1020
1021 #[test]
1022 fn test_manifest_v1_to_v2_migration() {
1023 let temp_dir = tempdir().unwrap();
1024 let temp_dir_str = temp_dir.path().to_str().unwrap();
1025
1026 let package = Package {
1027 name: Some("OldMod".to_string()),
1028 full_name: Some("OldOwner-OldMod".to_string()),
1029 owner: Some("OldOwner".to_string()),
1030 package_url: Some("https://example.com/OldMod".to_string()),
1031 date_created: OffsetDateTime::now_utc(),
1032 date_updated: OffsetDateTime::now_utc(),
1033 uuid4: Some("old-uuid".to_string()),
1034 rating_score: Some(4),
1035 is_pinned: Some(false),
1036 is_deprecated: Some(false),
1037 has_nsfw_content: Some(false),
1038 categories: vec!["legacy".to_string()],
1039 versions: vec![],
1040 };
1041
1042 let packages = vec![package];
1043 let binary_data = bincode::serialize(&packages).unwrap();
1044 let compressed_data = zstd::encode_all(binary_data.as_slice(), 9).unwrap();
1045
1046 std::fs::create_dir_all(PathBuf::from(temp_dir_str)).unwrap();
1047
1048 let mut v1_path = PathBuf::from(temp_dir_str);
1049 v1_path.push(API_MANIFEST_FILENAME_V1);
1050 let mut file = File::create(&v1_path).unwrap();
1051 file.write_all(&compressed_data).unwrap();
1052
1053 let v2_path = PathBuf::from(temp_dir_str).join(API_MANIFEST_FILENAME_V3);
1054 assert!(!v2_path.exists());
1055 assert!(v1_path.exists());
1056
1057 let rt = Runtime::new().unwrap();
1058 let result = rt.block_on(get_manifest_from_disk(temp_dir_str));
1059
1060 assert!(result.is_ok());
1061 let manifest = result.unwrap();
1062 assert_eq!(manifest.len(), 1);
1063 assert_eq!(
1064 manifest.resolve_full_name_at(0),
1065 Some("OldOwner-OldMod".to_string())
1066 );
1067
1068 assert!(v2_path.exists(), "v3 manifest should be created");
1069 assert!(!v1_path.exists(), "v1 manifest should be removed");
1070 }
1071
1072 #[test]
1073 fn test_get_manifest_from_network_and_cache() {
1074 let mut server = Server::new();
1075
1076 let test_json = r#"[
1077 {
1078 "name": "ModA",
1079 "full_name": "Owner-ModA",
1080 "owner": "Owner",
1081 "package_url": "https://example.com/mods/ModA",
1082 "date_created": "2024-01-01T12:00:00Z",
1083 "date_updated": "2024-01-02T12:00:00Z",
1084 "uuid4": "test-uuid",
1085 "rating_score": 5,
1086 "is_pinned": false,
1087 "is_deprecated": false,
1088 "has_nsfw_content": false,
1089 "categories": ["category1"],
1090 "versions": [
1091 {
1092 "name": "ModA",
1093 "full_name": "Owner-ModA",
1094 "description": "Test description",
1095 "icon": "icon.png",
1096 "version_number": "1.0.0",
1097 "dependencies": [],
1098 "download_url": "https://example.com/mods/ModA/download",
1099 "downloads": 100,
1100 "date_created": "2024-01-01T12:00:00Z",
1101 "website_url": "https://example.com",
1102 "is_active": true,
1103 "uuid4": "test-version-uuid",
1104 "file_size": 1024
1105 }
1106 ]
1107 }
1108 ]"#;
1109
1110 let test_date = "Wed, 21 Feb 2024 15:30:45 GMT";
1111
1112 let mock = server
1113 .mock("GET", "/c/valheim/api/v1/package/")
1114 .with_status(200)
1115 .with_header("Content-Type", "application/json")
1116 .with_header("Last-Modified", test_date)
1117 .with_body(test_json)
1118 .create();
1119
1120 let api_url = format!("{}/c/valheim/api/v1/package/", server.url());
1121 let temp_dir = tempdir().unwrap();
1122 let temp_dir_str = temp_dir.path().to_str().unwrap();
1123
1124 let rt = Runtime::new().unwrap();
1125 let result = rt.block_on(get_manifest_from_network_and_cache(temp_dir_str, &api_url));
1126
1127 assert!(result.is_ok());
1128 if let Ok(manifest) = result {
1129 assert_eq!(manifest.len(), 1);
1130 assert_eq!(
1131 manifest.resolve_full_name_at(0),
1132 Some("Owner-ModA".to_string())
1133 );
1134
1135 assert!(last_modified_path(temp_dir_str).exists());
1136 assert!(api_manifest_path_v3(temp_dir_str).exists());
1137
1138 let last_mod_content = std::fs::read_to_string(last_modified_path(temp_dir_str)).unwrap();
1139 assert!(last_mod_content.contains("21 Feb 2024 15:30:45"));
1140 }
1141
1142 mock.assert();
1143 }
1144
1145 #[test]
1146 fn test_download_file() {
1147 let mut server = Server::new();
1148 let temp_dir = tempdir().unwrap();
1149 let temp_dir_str = temp_dir.path().to_str().unwrap();
1150
1151 let test_data = b"This is test file content";
1152 let content_length = test_data.len();
1153
1154 let _mock = server
1155 .mock("GET", "/test-file.zip")
1156 .with_status(200)
1157 .with_header("Content-Type", "application/zip")
1158 .with_header("Content-Length", &content_length.to_string())
1159 .with_body(test_data)
1160 .create();
1161
1162 let file_url = format!("{}/test-file.zip", server.url());
1163 let filename = "test-file.zip".to_string();
1164
1165 let multi_progress = MultiProgress::new();
1166 let style_template = "";
1167 let progress_style = ProgressStyle::with_template(style_template)
1168 .unwrap()
1169 .progress_chars("#>-");
1170
1171 let rt = Runtime::new().unwrap();
1172 let client = rt.block_on(async {
1173 reqwest::Client::builder()
1174 .timeout(Duration::from_secs(60))
1175 .build()
1176 .unwrap()
1177 });
1178
1179 let result = rt.block_on(download_file(
1180 client.clone(),
1181 &file_url,
1182 &filename,
1183 multi_progress.clone(),
1184 progress_style.clone(),
1185 temp_dir_str,
1186 ));
1187
1188 assert!(result.is_ok());
1189
1190 let downloads_dir = PathBuf::from(temp_dir_str).join("downloads");
1191 assert!(downloads_dir.exists());
1192
1193 let downloaded_file = downloads_dir.join(&filename);
1194 assert!(downloaded_file.exists());
1195
1196 let file_content = std::fs::read(&downloaded_file).unwrap();
1197 assert_eq!(file_content, test_data);
1198
1199 let result2 = rt.block_on(download_file(
1200 client.clone(),
1201 &file_url,
1202 &filename,
1203 multi_progress.clone(),
1204 progress_style.clone(),
1205 temp_dir_str,
1206 ));
1207
1208 assert!(result2.is_ok());
1209 }
1210
1211 #[test]
1212 fn test_download_file_missing_header() {
1213 let mut server = Server::new();
1214 let temp_dir = tempdir().unwrap();
1215 let temp_dir_str = temp_dir.path().to_str().unwrap();
1216
1217 let test_data = b"This is test file content";
1218
1219 let mock = server
1220 .mock("GET", "/test-file.zip")
1221 .with_status(200)
1222 .with_header("Content-Type", "application/zip")
1223 .with_body(test_data)
1224 .create();
1225
1226 let file_url = format!("{}/test-file.zip", server.url());
1227 let filename = "test-file-no-header.zip".to_string();
1228
1229 let multi_progress = MultiProgress::new();
1230 let style_template = "";
1231 let progress_style = ProgressStyle::with_template(style_template)
1232 .unwrap()
1233 .progress_chars("#>-");
1234
1235 let rt = Runtime::new().unwrap();
1236 let client = rt.block_on(async {
1237 reqwest::Client::builder()
1238 .timeout(Duration::from_secs(60))
1239 .build()
1240 .unwrap()
1241 });
1242
1243 let _result = rt.block_on(download_file(
1244 client.clone(),
1245 &file_url,
1246 &filename,
1247 multi_progress.clone(),
1248 progress_style.clone(),
1249 temp_dir_str,
1250 ));
1251
1252 mock.assert();
1253 }
1254
1255 #[test]
1256 fn test_download_files() {
1257 let mut server = Server::new();
1258 let temp_dir = tempdir().unwrap();
1259 let temp_dir_str = temp_dir.path().to_str().unwrap();
1260
1261 let test_data1 = b"This is test file 1 content";
1262 let content_length1 = test_data1.len();
1263
1264 let test_data2 = b"This is test file 2 content - longer content";
1265 let content_length2 = test_data2.len();
1266
1267 let mock1 = server
1268 .mock("GET", "/file1.zip")
1269 .with_status(200)
1270 .with_header("Content-Type", "application/zip")
1271 .with_header("Content-Length", &content_length1.to_string())
1272 .with_body(test_data1)
1273 .create();
1274
1275 let mock2 = server
1276 .mock("GET", "/file2.zip")
1277 .with_status(200)
1278 .with_header("Content-Type", "application/zip")
1279 .with_header("Content-Length", &content_length2.to_string())
1280 .with_body(test_data2)
1281 .create();
1282
1283 let mut urls = HashMap::new();
1284 urls.insert(
1285 "file1.zip".to_string(),
1286 format!("{}/file1.zip", server.url()),
1287 );
1288 urls.insert(
1289 "file2.zip".to_string(),
1290 format!("{}/file2.zip", server.url()),
1291 );
1292
1293 let rt = Runtime::new().unwrap();
1294 let result = rt.block_on(download_files(urls, temp_dir_str));
1295
1296 assert!(result.is_ok());
1297
1298 let downloads_dir = PathBuf::from(temp_dir_str).join("downloads");
1299 assert!(downloads_dir.exists());
1300
1301 let file1 = downloads_dir.join("file1.zip");
1302 let file2 = downloads_dir.join("file2.zip");
1303 assert!(file1.exists());
1304 assert!(file2.exists());
1305
1306 let file1_content = std::fs::read(&file1).unwrap();
1307 let file2_content = std::fs::read(&file2).unwrap();
1308 assert_eq!(file1_content, test_data1);
1309 assert_eq!(file2_content, test_data2);
1310
1311 mock1.assert();
1312 mock2.assert();
1313 }
1314
1315 #[test]
1316 fn test_download_files_empty() {
1317 let temp_dir = tempdir().unwrap();
1318 let temp_dir_str = temp_dir.path().to_str().unwrap();
1319
1320 let urls = HashMap::new();
1321
1322 let rt = Runtime::new().unwrap();
1323 let result = rt.block_on(download_files(urls, temp_dir_str));
1324
1325 assert!(result.is_ok());
1326 }
1327}