Skip to main content

valheim_mod_manager/
api.rs

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
23/// Returns the path to the last_modified file in the cache directory.
24///
25/// # Parameters
26///
27/// * `cache_dir` - The cache directory path
28///
29/// # Returns
30///
31/// The full path to the last_modified file
32fn last_modified_path(cache_dir: &str) -> PathBuf {
33  PathBuf::from(cache_dir).join(LAST_MODIFIED_FILENAME)
34}
35
36/// Returns the path to the v3 API manifest file in the cache directory.
37///
38/// # Parameters
39///
40/// * `cache_dir` - The cache directory path
41///
42/// # Returns
43///
44/// The full path to the v3 manifest file
45fn api_manifest_path_v3(cache_dir: &str) -> PathBuf {
46  PathBuf::from(cache_dir).join(API_MANIFEST_FILENAME_V3)
47}
48
49/// Returns the path to the v2 API manifest file in the cache directory.
50///
51/// # Parameters
52///
53/// * `cache_dir` - The cache directory path
54///
55/// # Returns
56///
57/// The full path to the v2 manifest file
58fn api_manifest_path_v2(cache_dir: &str) -> PathBuf {
59  PathBuf::from(cache_dir).join(API_MANIFEST_FILENAME_V2)
60}
61
62/// Returns the path to the v1 API manifest file in the cache directory.
63///
64/// # Parameters
65///
66/// * `cache_dir` - The cache directory path
67///
68/// # Returns
69///
70/// The full path to the v1 manifest file
71fn api_manifest_path_v1(cache_dir: &str) -> PathBuf {
72  PathBuf::from(cache_dir).join(API_MANIFEST_FILENAME_V1)
73}
74
75/// Retrieves the manifest of available packages.
76///
77/// This function first checks if there's a cached manifest file that is up-to-date.
78/// If a cached version exists and is current, it loads from disk.
79/// Otherwise, it downloads the manifest from the network and caches it.
80///
81/// # Parameters
82///
83/// * `cache_dir` - The directory to store cache files in
84/// * `api_url` - The API URL to use for network requests (defaults to API_URL)
85///
86/// # Returns
87///
88/// An `InternedPackageManifest` containing all available packages in SoA format with interned strings.
89///
90/// # Errors
91///
92/// Returns an error if:
93/// - Failed to check or retrieve last modified dates
94/// - Network request fails
95/// - Parsing fails
96pub 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
113/// Reads the cached API manifest from disk.
114///
115/// Attempts to read formats in order: v3 (interned), v2 (SoA), v1 (`Vec<Package>`).
116/// When older formats are detected, they are automatically migrated to v3.
117///
118/// # Returns
119///
120/// The deserialized manifest as an InternedPackageManifest.
121///
122/// # Errors
123///
124/// Returns an error if:
125/// - The file cannot be opened or read
126/// - The data cannot be decompressed or deserialized
127async 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
263/// Downloads the manifest from the API server and filters out unnecessary fields.
264///
265/// This function shows a progress bar while downloading the manifest.
266/// It parses the downloaded data to filter out fields marked with #[serde(skip_deserializing)],
267/// which helps reduce the size of stored data on disk.
268///
269/// # Parameters
270///
271/// * `api_url` - The API URL to download the manifest from
272///
273/// # Returns
274///
275/// A tuple containing:
276/// - The filtered manifest data as a vector of Packages (with unnecessary fields removed)
277/// - The last modified date from the response headers
278///
279/// # Errors
280///
281/// Returns an error if:
282/// - Network request fails
283/// - Response headers cannot be parsed
284/// - Last modified date cannot be parsed
285/// - JSON parsing or serialization fails
286async 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
319/// Downloads the manifest from the network and caches it locally.
320///
321/// This function retrieves the manifest from the API server and saves both
322/// the manifest content and the last modified date to disk.
323/// The manifest is converted to the interned V3 format before caching.
324///
325/// # Parameters
326///
327/// * `cache_dir` - The directory to store cache files in
328/// * `api_url` - The API URL to download the manifest from
329///
330/// # Returns
331///
332/// The manifest data as InternedPackageManifest.
333///
334/// # Errors
335///
336/// Returns an error if:
337/// - Network request fails
338/// - Writing cache files fails
339async 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
364/// Retrieves the last modified date from the local cache file.
365///
366/// If the file doesn't exist, returns a default date (epoch time).
367///
368/// # Returns
369///
370/// The last modified date as a `DateTime<FixedOffset>`.
371///
372/// # Errors
373///
374/// Returns an error if:
375/// - The file exists but cannot be read
376/// - The date string cannot be parsed
377async 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
398/// Retrieves the last modified date from the API server.
399///
400/// Makes a HEAD request to the API endpoint to check when the manifest was last updated.
401///
402/// # Parameters
403///
404/// * `api_url` - The API URL to check
405///
406/// # Returns
407///
408/// The last modified date from the server as a `DateTime<FixedOffset>`.
409///
410/// # Errors
411///
412/// Returns an error if:
413/// - Network request fails
414/// - Last-Modified header is missing
415/// - Date string cannot be parsed
416async 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
433/// Checks for the existence of any version of the cached manifest file (v1, v2, or v3).
434///
435/// # Parameters
436///
437/// * `cache_dir` - The cache directory path
438///
439/// # Returns
440///
441/// `true` if any api_manifest file exists, `false` otherwise.
442fn 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
448/// Writes data to a cache file on disk.
449///
450/// Creates the parent directory if it doesn't exist, then the file if it doesn't exist,
451/// then writes the provided contents to it.
452///
453/// # Parameters
454///
455/// * `path` - The path to the cache file
456/// * `contents` - The data to write to the file
457/// * `use_compression` - If true, the data will be compressed with zstd level 9
458///   (balanced compression ratio for SoA structure's repetitive patterns)
459///
460/// # Errors
461///
462/// Returns an error if:
463/// - Directory creation fails
464/// - File creation fails
465/// - Opening the file for writing fails
466/// - Writing to the file fails
467/// - Compression fails
468async 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
501/// Downloads multiple files concurrently with progress indicators.
502/// Skips files that already exist with the correct version.
503///
504/// # Parameters
505///
506/// * `urls` - A HashMap where keys are filenames and values are the URLs to download from
507/// * `cache_dir` - The app's cache directory
508///
509/// # Notes
510///
511/// - Files are downloaded in parallel with a limit of 2 concurrent downloads
512/// - Progress bars show download status for each file
513/// - Files are saved in the 'cache_dir/downloads' directory
514/// - Files that already exist with the correct version are skipped
515pub 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
574/// Downloads a single file from a URL with a progress indicator.
575/// Checks if the file already exists before downloading to avoid duplicate downloads.
576///
577/// # Parameters
578///
579/// * `client` - The HTTP client to use for the download
580/// * `url` - The URL to download from
581/// * `filename` - The name to save the file as
582/// * `multi_progress` - The multi-progress display for coordinating multiple progress bars
583/// * `progress_style` - The style to use for the progress bar
584/// * `cache_dir` - The app's cache directory
585///
586/// # Returns
587///
588/// `Ok(())` on successful download or if file already exists.
589///
590/// # Errors
591///
592/// Returns an error if:
593/// - Network request fails
594/// - Content-Length header is missing
595/// - Creating directories or files fails
596/// - Writing to the file fails
597async 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}