• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

endoze / valheim-mod-manager / 19020500514

03 Nov 2025 12:31AM UTC coverage: 92.43% (+5.5%) from 86.932%
19020500514

push

github

web-flow
Merge pull request #10 from endoze/feat-improve-manifest-performance-and-reduce-size

Improve manifest performance and reduce storage footprint

250 of 269 new or added lines in 3 files covered. (92.94%)

1 existing line in 1 file now uncovered.

525 of 568 relevant lines covered (92.43%)

3.89 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.21
/src/api.rs
1
use crate::error::{AppError, AppResult};
2
use crate::package::{Package, PackageManifest};
3

4
use chrono::prelude::*;
5
use futures::stream::{FuturesUnordered, StreamExt};
6
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
7
use reqwest::Client;
8
use reqwest::header;
9
use std::collections::HashMap;
10
use std::path::{Path, PathBuf};
11
use std::time::Duration;
12
use tokio::fs;
13
use tokio::io::{AsyncReadExt, AsyncWriteExt};
14

15
const API_URL: &str = "https://stacklands.thunderstore.io/c/valheim/api/v1/package/";
16
const LAST_MODIFIED_FILENAME: &str = "last_modified";
17
const API_MANIFEST_FILENAME: &str = "api_manifest_v2.bin.zst";
18
const API_MANIFEST_FILENAME_V1: &str = "api_manifest.bin.zst";
19

20
/// Returns the path to the last_modified file in the cache directory.
21
fn last_modified_path(cache_dir: &str) -> PathBuf {
2✔
22
  let expanded_path = shellexpand::tilde(cache_dir);
2✔
23
  let mut path = PathBuf::from(expanded_path.as_ref());
4✔
24
  path.push(LAST_MODIFIED_FILENAME);
2✔
25
  path
2✔
26
}
27

28
/// Returns the path to the api_manifest file in the cache directory.
29
fn api_manifest_path(cache_dir: &str) -> PathBuf {
2✔
30
  let expanded_path = shellexpand::tilde(cache_dir);
2✔
31
  let mut path = PathBuf::from(expanded_path.as_ref());
4✔
32
  path.push(API_MANIFEST_FILENAME);
2✔
33
  path
2✔
34
}
35

36
/// Returns the path to the old v1 api_manifest file in the cache directory.
37
fn api_manifest_path_v1(cache_dir: &str) -> PathBuf {
2✔
38
  let expanded_path = shellexpand::tilde(cache_dir);
2✔
39
  let mut path = PathBuf::from(expanded_path.as_ref());
4✔
40
  path.push(API_MANIFEST_FILENAME_V1);
2✔
41
  path
2✔
42
}
43

44
/// Retrieves the manifest of available packages.
45
///
46
/// This function first checks if there's a cached manifest file that is up-to-date.
47
/// If a cached version exists and is current, it loads from disk.
48
/// Otherwise, it downloads the manifest from the network and caches it.
49
///
50
/// # Parameters
51
///
52
/// * `cache_dir` - The directory to store cache files in
53
/// * `api_url` - The API URL to use for network requests (defaults to API_URL)
54
///
55
/// # Returns
56
///
57
/// A `PackageManifest` containing all available packages in SoA format.
58
///
59
/// # Errors
60
///
61
/// Returns an error if:
62
/// - Failed to check or retrieve last modified dates
63
/// - Network request fails
64
/// - Parsing fails
65
pub async fn get_manifest(cache_dir: &str, api_url: Option<&str>) -> AppResult<PackageManifest> {
8✔
66
  let api_url = api_url.unwrap_or(API_URL);
4✔
67
  let last_modified = local_last_modified(cache_dir).await?;
4✔
68
  tracing::info!("Manifest last modified: {}", last_modified);
6✔
69

70
  if api_manifest_file_exists(cache_dir) && network_last_modified(api_url).await? <= last_modified {
8✔
71
    tracing::info!("Loading manifest from cache");
6✔
72
    get_manifest_from_disk(cache_dir).await
5✔
73
  } else {
74
    tracing::info!("Downloading new manifest");
×
NEW
75
    get_manifest_from_network_and_cache(cache_dir, api_url).await
×
76
  }
77
}
78

79
/// Reads the cached API manifest from disk.
80
///
81
/// Attempts to read the new v2 format first, falls back to v1 format if needed.
82
/// When v1 format is detected, it automatically migrates to v2.
83
///
84
/// # Returns
85
///
86
/// The deserialized manifest as a PackageManifest.
87
///
88
/// # Errors
89
///
90
/// Returns an error if:
91
/// - The file cannot be opened or read
92
/// - The data cannot be decompressed or deserialized
93
async fn get_manifest_from_disk(cache_dir: &str) -> AppResult<PackageManifest> {
8✔
94
  let path_v2 = api_manifest_path(cache_dir);
2✔
95
  let path_v1 = api_manifest_path_v1(cache_dir);
2✔
96

97
  if path_v2.exists() {
4✔
98
    tracing::debug!("Loading v2 manifest format");
9✔
99

100
    let mut file = fs::File::open(&path_v2).await?;
8✔
101
    let mut compressed_data = Vec::new();
3✔
102
    file.read_to_end(&mut compressed_data).await?;
12✔
103

104
    let decompressed_data = zstd::decode_all(compressed_data.as_slice())
10✔
105
      .map_err(|e| AppError::Manifest(format!("Failed to decompress v2 manifest: {}", e)))?;
8✔
106

107
    let manifest: PackageManifest = bincode::deserialize(&decompressed_data)
4✔
NEW
108
      .map_err(|e| AppError::Manifest(format!("Failed to deserialize v2 manifest: {}", e)))?;
×
109

110
    manifest
4✔
111
      .validate()
NEW
112
      .map_err(|e| AppError::Manifest(format!("V2 manifest validation failed: {}", e)))?;
×
113

114
    Ok(manifest)
2✔
115
  } else {
116
    if path_v1.exists() {
4✔
117
      tracing::info!("Found v1 manifest, migrating to v2 format");
6✔
118

119
      let mut file = fs::File::open(&path_v1).await?;
6✔
120
      let mut compressed_data = Vec::new();
2✔
121
      file.read_to_end(&mut compressed_data).await?;
8✔
122

123
      let decompressed_data = zstd::decode_all(compressed_data.as_slice())
4✔
NEW
124
        .map_err(|e| AppError::Manifest(format!("Failed to decompress v1 manifest: {}", e)))?;
×
125

126
      let packages: Vec<Package> = bincode::deserialize(&decompressed_data)
4✔
NEW
127
        .map_err(|e| AppError::Manifest(format!("Failed to deserialize v1 manifest: {}", e)))?;
×
128

129
      let manifest: PackageManifest = packages.into();
4✔
130

131
      manifest.validate().map_err(|e| {
4✔
NEW
132
        AppError::Manifest(format!(
×
133
          "V2 manifest validation failed during migration: {}",
134
          e
135
        ))
136
      })?;
137

138
      let binary_data = bincode::serialize(&manifest)
4✔
NEW
139
        .map_err(|e| AppError::Manifest(format!("Failed to serialize v2 manifest: {}", e)))?;
×
140

141
      write_cache_to_disk(path_v2.clone(), &binary_data, true).await?;
6✔
142

143
      match tokio::fs::metadata(&path_v2).await {
6✔
144
        Ok(metadata) if metadata.len() > 0 => {
6✔
145
          tracing::info!("V2 manifest written successfully, removing v1");
6✔
146

147
          if let Err(e) = fs::remove_file(&path_v1).await {
6✔
NEW
148
            tracing::warn!(
×
149
              "Failed to remove old v1 manifest (keeping as backup): {}",
150
              e
151
            );
152
          }
153
        }
154
        Ok(_) => {
NEW
155
          tracing::error!("V2 manifest written but is empty, keeping v1 as backup");
×
156
        }
NEW
157
        Err(e) => {
×
NEW
158
          tracing::error!(
×
159
            "Failed to verify v2 manifest write: {}, keeping v1 as backup",
160
            e
161
          );
162
        }
163
      }
164

165
      return Ok(manifest);
2✔
166
    }
167

NEW
168
    Err(AppError::Manifest("No cached manifest found".to_string()))
×
169
  }
170
}
171

172
/// Downloads the manifest from the API server and filters out unnecessary fields.
173
///
174
/// This function shows a progress bar while downloading the manifest.
175
/// It parses the downloaded data to filter out fields marked with #[serde(skip_deserializing)],
176
/// which helps reduce the size of stored data on disk.
177
///
178
/// # Parameters
179
///
180
/// * `api_url` - The API URL to download the manifest from
181
///
182
/// # Returns
183
///
184
/// A tuple containing:
185
/// - The filtered manifest data as a vector of Packages (with unnecessary fields removed)
186
/// - The last modified date from the response headers
187
///
188
/// # Errors
189
///
190
/// Returns an error if:
191
/// - Network request fails
192
/// - Response headers cannot be parsed
193
/// - Last modified date cannot be parsed
194
/// - JSON parsing or serialization fails
195
async fn get_manifest_from_network(
2✔
196
  api_url: &str,
197
) -> AppResult<(Vec<Package>, DateTime<FixedOffset>)> {
198
  let client = Client::builder().build()?;
4✔
199

200
  let multi_progress = MultiProgress::new();
4✔
201
  let style_template = "";
2✔
202
  let progress_style = ProgressStyle::with_template(style_template)
4✔
203
    .unwrap()
204
    .tick_strings(&["-", "\\", "|", "/", ""]);
2✔
205

206
  let progress_bar = multi_progress.add(ProgressBar::new(100000));
4✔
207
  progress_bar.set_style(progress_style);
2✔
208
  progress_bar.set_message("Downloading Api Manifest");
2✔
209
  progress_bar.enable_steady_tick(Duration::from_millis(130));
2✔
210

211
  let response = client.get(api_url).send().await?;
5✔
212
  let last_modified_str = response
6✔
213
    .headers()
214
    .get(header::LAST_MODIFIED)
215
    .ok_or(AppError::MissingHeader("Last-Modified".to_string()))?
3✔
216
    .to_str()?;
217
  let last_modified = DateTime::parse_from_rfc2822(last_modified_str)?;
4✔
218

219
  let raw_response_data = response.bytes().await?;
4✔
220

221
  let packages: Vec<Package> = serde_json::from_slice(&raw_response_data)?;
4✔
222

223
  progress_bar.finish_with_message(" Downloaded Api Manifest");
3✔
224

225
  Ok((packages, last_modified))
4✔
226
}
227

228
/// Downloads the manifest from the network and caches it locally.
229
///
230
/// This function retrieves the manifest from the API server and saves both
231
/// the manifest content and the last modified date to disk.
232
/// The manifest is converted to the SoA format before caching.
233
///
234
/// # Parameters
235
///
236
/// * `cache_dir` - The directory to store cache files in
237
/// * `api_url` - The API URL to download the manifest from
238
///
239
/// # Returns
240
///
241
/// The manifest data as PackageManifest.
242
///
243
/// # Errors
244
///
245
/// Returns an error if:
246
/// - Network request fails
247
/// - Writing cache files fails
248
async fn get_manifest_from_network_and_cache(
2✔
249
  cache_dir: &str,
250
  api_url: &str,
251
) -> AppResult<PackageManifest> {
252
  let results = get_manifest_from_network(api_url).await?;
5✔
253
  let packages = results.0;
2✔
254
  let last_modified_from_network = results.1;
2✔
255

256
  write_cache_to_disk(
257
    last_modified_path(cache_dir),
4✔
258
    last_modified_from_network.to_rfc2822().as_bytes(),
4✔
259
    false,
260
  )
261
  .await?;
10✔
262

263
  let manifest: PackageManifest = packages.into();
4✔
264
  let binary_data = bincode::serialize(&manifest)
4✔
UNCOV
265
    .map_err(|e| AppError::Manifest(format!("Failed to serialize manifest: {}", e)))?;
×
266

267
  write_cache_to_disk(api_manifest_path(cache_dir), &binary_data, true).await?;
6✔
268

269
  Ok(manifest)
2✔
270
}
271

272
/// Retrieves the last modified date from the local cache file.
273
///
274
/// If the file doesn't exist, returns a default date (epoch time).
275
///
276
/// # Returns
277
///
278
/// The last modified date as a `DateTime<FixedOffset>`.
279
///
280
/// # Errors
281
///
282
/// Returns an error if:
283
/// - The file exists but cannot be read
284
/// - The date string cannot be parsed
285
async fn local_last_modified(cache_dir: &str) -> AppResult<DateTime<FixedOffset>> {
8✔
286
  let path = last_modified_path(cache_dir);
2✔
287

288
  if let Ok(mut file) = fs::File::open(&path).await {
6✔
289
    tracing::info!("Last modified file exists and was opened.");
6✔
290
    let mut contents = String::new();
2✔
291
    file.read_to_string(&mut contents).await?;
6✔
292

293
    let last_modified = DateTime::parse_from_rfc2822(&contents)?;
4✔
294

295
    Ok(last_modified)
2✔
296
  } else {
297
    tracing::info!("Last modified file does not exist and was not opened.");
6✔
298
    let dt = DateTime::from_timestamp(0, 0).unwrap().naive_utc();
4✔
299
    let offset = FixedOffset::east_opt(0).unwrap();
4✔
300

301
    let last_modified = DateTime::<FixedOffset>::from_naive_utc_and_offset(dt, offset);
2✔
302
    Ok(last_modified)
2✔
303
  }
304
}
305

306
/// Retrieves the last modified date from the API server.
307
///
308
/// Makes a HEAD request to the API endpoint to check when the manifest was last updated.
309
///
310
/// # Parameters
311
///
312
/// * `api_url` - The API URL to check
313
///
314
/// # Returns
315
///
316
/// The last modified date from the server as a `DateTime<FixedOffset>`.
317
///
318
/// # Errors
319
///
320
/// Returns an error if:
321
/// - Network request fails
322
/// - Last-Modified header is missing
323
/// - Date string cannot be parsed
324
async fn network_last_modified(api_url: &str) -> AppResult<DateTime<FixedOffset>> {
8✔
325
  let client = Client::builder().build()?;
4✔
326

327
  let response = client.head(api_url).send().await?;
6✔
328

329
  let last_modified =
6✔
330
    response
331
      .headers()
332
      .get(header::LAST_MODIFIED)
333
      .ok_or(AppError::MissingHeader(
2✔
334
        "Last-Modified for API manifest head request".to_string(),
2✔
335
      ))?;
336
  let last_modified_date = DateTime::parse_from_rfc2822(last_modified.to_str()?)?;
4✔
337

338
  Ok(last_modified_date)
2✔
339
}
340

341
/// Checks if the cached manifest file exists.
342
///
343
/// # Returns
344
///
345
/// `true` if the api_manifest file exists, `false` otherwise.
346
fn api_manifest_file_exists(cache_dir: &str) -> bool {
2✔
347
  api_manifest_path(cache_dir).exists()
2✔
348
}
349

350
/// Writes data to a cache file on disk.
351
///
352
/// Creates the parent directory if it doesn't exist, then the file if it doesn't exist,
353
/// then writes the provided contents to it.
354
///
355
/// # Parameters
356
///
357
/// * `path` - The path to the cache file
358
/// * `contents` - The data to write to the file
359
/// * `use_compression` - If true, the data will be compressed with zstd level 9
360
///   (balanced compression ratio for SoA structure's repetitive patterns)
361
///
362
/// # Errors
363
///
364
/// Returns an error if:
365
/// - Directory creation fails
366
/// - File creation fails
367
/// - Opening the file for writing fails
368
/// - Writing to the file fails
369
/// - Compression fails
370
async fn write_cache_to_disk<T: AsRef<Path>>(
4✔
371
  path: T,
372
  contents: &[u8],
373
  use_compression: bool,
374
) -> AppResult<()> {
375
  if let Some(parent) = path.as_ref().parent() {
12✔
376
    fs::create_dir_all(parent).await?;
12✔
377
  }
378

379
  if !path.as_ref().exists() {
8✔
380
    fs::File::create(&path).await?;
12✔
381
  }
382

383
  let mut file = fs::OpenOptions::new()
24✔
384
    .write(true)
385
    .truncate(true)
386
    .open(&path)
4✔
387
    .await?;
16✔
388

389
  if use_compression {
8✔
390
    let compressed_data = zstd::encode_all(contents, 9)
8✔
391
      .map_err(|e| AppError::Manifest(format!("Failed to compress data: {}", e)))?;
×
392

393
    file.write_all(&compressed_data).await?;
12✔
394
    file.flush().await?;
12✔
395
  } else {
396
    file.write_all(contents).await?;
12✔
397
    file.flush().await?;
14✔
398
  }
399

400
  Ok(())
4✔
401
}
402

403
/// Downloads multiple files concurrently with progress indicators.
404
/// Skips files that already exist with the correct version.
405
///
406
/// # Parameters
407
///
408
/// * `urls` - A HashMap where keys are filenames and values are the URLs to download from
409
/// * `cache_dir` - The app's cache directory
410
///
411
/// # Notes
412
///
413
/// - Files are downloaded in parallel with a limit of 2 concurrent downloads
414
/// - Progress bars show download status for each file
415
/// - Files are saved in the 'cache_dir/downloads' directory
416
/// - Files that already exist with the correct version are skipped
417
pub async fn download_files(urls: HashMap<String, String>, cache_dir: &str) -> AppResult<()> {
8✔
418
  if urls.is_empty() {
4✔
419
    tracing::debug!("No files to download");
6✔
420

421
    return Ok(());
2✔
422
  }
423

424
  tracing::debug!("Processing {} mods", urls.len());
6✔
425

426
  let client = Client::builder().timeout(Duration::from_secs(60)).build()?;
2✔
427

428
  let multi_progress = MultiProgress::new();
4✔
429
  let style_template = "";
2✔
430
  let progress_style = ProgressStyle::with_template(style_template)
4✔
431
    .unwrap()
432
    .progress_chars("#>-");
433

434
  let futures: FuturesUnordered<_> = urls
4✔
435
    .into_iter()
436
    .map(|(archive_filename, url)| {
6✔
437
      let client = client.clone();
4✔
438
      let multi_progress = multi_progress.clone();
4✔
439
      let progress_style = progress_style.clone();
4✔
440

441
      let cache_dir = cache_dir.to_string();
2✔
442
      tokio::spawn(async move {
6✔
443
        download_file(
6✔
444
          client,
2✔
445
          &url,
2✔
446
          &archive_filename,
2✔
447
          multi_progress,
2✔
448
          progress_style,
2✔
449
          &cache_dir,
2✔
450
        )
451
        .await
10✔
452
      })
453
    })
454
    .collect();
455

456
  let responses = futures::stream::iter(futures)
6✔
457
    .buffer_unordered(2)
458
    .collect::<Vec<_>>()
459
    .await;
8✔
460

461
  for response in responses {
6✔
462
    match response {
4✔
463
      Ok(Ok(_)) => {}
464
      Ok(Err(err)) => {
×
465
        tracing::error!("Download error: {:?}", err);
×
466
      }
467
      Err(err) => {
×
468
        return Err(AppError::TaskFailed(err));
×
469
      }
470
    }
471
  }
472

473
  Ok(())
2✔
474
}
475

476
/// Downloads a single file from a URL with a progress indicator.
477
/// Checks if the file already exists before downloading to avoid duplicate downloads.
478
///
479
/// # Parameters
480
///
481
/// * `client` - The HTTP client to use for the download
482
/// * `url` - The URL to download from
483
/// * `filename` - The name to save the file as
484
/// * `multi_progress` - The multi-progress display for coordinating multiple progress bars
485
/// * `progress_style` - The style to use for the progress bar
486
/// * `cache_dir` - The app's cache directory
487
///
488
/// # Returns
489
///
490
/// `Ok(())` on successful download or if file already exists.
491
///
492
/// # Errors
493
///
494
/// Returns an error if:
495
/// - Network request fails
496
/// - Content-Length header is missing
497
/// - Creating directories or files fails
498
/// - Writing to the file fails
499
async fn download_file(
2✔
500
  client: reqwest::Client,
501
  url: &String,
502
  filename: &String,
503
  multi_progress: MultiProgress,
504
  progress_style: ProgressStyle,
505
  cache_dir: &str,
506
) -> AppResult<()> {
507
  let expanded_path = shellexpand::tilde(cache_dir);
2✔
508
  let mut downloads_directory = PathBuf::from(expanded_path.as_ref());
5✔
509
  downloads_directory.push("downloads");
3✔
510
  let mut file_path = downloads_directory.clone();
6✔
511
  file_path.push(filename);
2✔
512

513
  tokio::fs::DirBuilder::new()
13✔
514
    .recursive(true)
515
    .create(&downloads_directory)
2✔
516
    .await?;
9✔
517

518
  if file_path.exists() {
7✔
519
    tracing::debug!("{} already exists, skipping download", filename);
6✔
520

521
    return Ok(());
2✔
522
  }
523

524
  let response = client.get(url).send().await?;
9✔
525
  let content_length = response
11✔
526
    .headers()
527
    .get(header::CONTENT_LENGTH)
528
    .ok_or(AppError::MissingHeader(format!(
8✔
529
      "Content-Length header for {}",
530
      url
531
    )))?
532
    .to_str()?
533
    .parse::<u64>()?;
534
  let mut response_data = response.bytes_stream();
6✔
535

536
  let progress_bar = multi_progress.add(ProgressBar::new(content_length));
6✔
537
  progress_bar.set_style(progress_style);
2✔
538
  progress_bar.set_message(filename.clone());
2✔
539

540
  let mut file = tokio::fs::File::create(file_path).await?;
5✔
541

542
  while let Some(result) = response_data.next().await {
9✔
543
    let chunk = result?;
4✔
544
    file.write_all(&chunk).await?;
10✔
545
    progress_bar.inc(chunk.len() as u64);
5✔
546
  }
547

548
  file.flush().await?;
9✔
549
  file.sync_all().await?;
10✔
550

551
  progress_bar.finish();
2✔
552

553
  Ok(())
5✔
554
}
555

556
#[cfg(test)]
557
mod tests {
558
  use super::*;
559
  use mockito::Server;
560
  use std::fs::File;
561
  use std::io::Write;
562
  use tempfile::tempdir;
563
  use time::OffsetDateTime;
564
  use tokio::runtime::Runtime;
565

566
  #[test]
567
  fn test_downloads_directory_construction() {
568
    let temp_dir = tempdir().unwrap();
569
    let cache_dir = temp_dir.path().to_str().unwrap();
570

571
    let expanded_path = shellexpand::tilde(cache_dir);
572
    let mut downloads_directory = PathBuf::from(expanded_path.as_ref());
573
    downloads_directory.push("downloads");
574

575
    let expected_directory = PathBuf::from(cache_dir).join("downloads");
576
    assert_eq!(downloads_directory, expected_directory);
577
  }
578

579
  #[test]
580
  fn test_api_manifest_file_exists() {
581
    let temp_dir = tempdir().unwrap();
582
    let temp_dir_str = temp_dir.path().to_str().unwrap();
583

584
    assert!(!api_manifest_file_exists(temp_dir_str));
585

586
    let mut path = PathBuf::from(temp_dir_str);
587
    path.push(API_MANIFEST_FILENAME);
588
    let _ = File::create(path).unwrap();
589

590
    assert!(api_manifest_file_exists(temp_dir_str));
591
  }
592

593
  #[test]
594
  fn test_path_with_tilde() {
595
    let home_path = "~/some_test_dir";
596
    let home_dir = std::env::var("HOME").unwrap_or_else(|_| "/home/user".to_string());
597
    let expected_path = Path::new(&home_dir).join("some_test_dir");
598

599
    let last_modified = last_modified_path(home_path);
600
    let api_manifest = api_manifest_path(home_path);
601

602
    assert_eq!(last_modified.parent().unwrap(), expected_path);
603
    assert_eq!(api_manifest.parent().unwrap(), expected_path);
604
  }
605

606
  #[test]
607
  fn test_write_cache_to_disk() {
608
    let temp_dir = tempdir().unwrap();
609
    let cache_path = temp_dir.path().join("test_cache.txt");
610
    let test_data = b"Test cache data";
611

612
    let rt = Runtime::new().unwrap();
613
    let result = rt.block_on(write_cache_to_disk(&cache_path, test_data, false));
614

615
    assert!(result.is_ok());
616
    assert!(cache_path.exists());
617

618
    let content = std::fs::read(&cache_path).unwrap();
619
    assert_eq!(content, test_data);
620

621
    let new_data = b"Updated cache data";
622
    let result = rt.block_on(write_cache_to_disk(&cache_path, new_data, false));
623

624
    assert!(result.is_ok());
625
    let content = std::fs::read(&cache_path).unwrap();
626

627
    assert_eq!(content, new_data);
628
  }
629

630
  #[test]
631
  fn test_write_cache_to_disk_creates_directories() {
632
    let temp_dir = tempdir().unwrap();
633

634
    let nonexistent_subdir = temp_dir.path().join("subdir1/subdir2");
635
    let cache_path = nonexistent_subdir.join("test_cache.txt");
636

637
    if nonexistent_subdir.exists() {
638
      std::fs::remove_dir_all(&nonexistent_subdir).unwrap();
639
    }
640

641
    assert!(!nonexistent_subdir.exists());
642
    assert!(!cache_path.exists());
643

644
    let test_data = b"Test cache data";
645
    let rt = Runtime::new().unwrap();
646

647
    let result = rt.block_on(write_cache_to_disk(&cache_path, test_data, false));
648
    assert!(result.is_ok());
649

650
    assert!(
651
      nonexistent_subdir.exists(),
652
      "Directory structure should be created"
653
    );
654
    assert!(cache_path.exists(), "File should be created");
655

656
    let content = std::fs::read(&cache_path).unwrap();
657
    assert_eq!(content, test_data, "File should contain the expected data");
658
  }
659

660
  #[test]
661
  fn test_write_cache_to_disk_with_compression() {
662
    let temp_dir = tempdir().unwrap();
663
    let api_manifest_path = temp_dir.path().join("test_compressed.bin");
664

665
    let package = Package {
666
      name: Some("TestMod".to_string()),
667
      full_name: Some("TestOwner-TestMod".to_string()),
668
      owner: Some("TestOwner".to_string()),
669
      package_url: Some("https://example.com/TestMod".to_string()),
670
      date_created: OffsetDateTime::now_utc(),
671
      date_updated: OffsetDateTime::now_utc(),
672
      uuid4: Some("test-uuid".to_string()),
673
      rating_score: Some(5),
674
      is_pinned: Some(false),
675
      is_deprecated: Some(false),
676
      has_nsfw_content: Some(false),
677
      categories: vec!["test".to_string()],
678
      versions: vec![],
679
    };
680

681
    let packages = vec![package];
682
    let manifest: PackageManifest = packages.into();
683
    let binary_data = bincode::serialize(&manifest).unwrap();
684

685
    let rt = Runtime::new().unwrap();
686
    let result = rt.block_on(write_cache_to_disk(&api_manifest_path, &binary_data, true));
687

688
    assert!(result.is_ok());
689
    assert!(api_manifest_path.exists());
690

691
    let compressed_data = std::fs::read(&api_manifest_path).unwrap();
692
    let decompressed_data = zstd::decode_all(compressed_data.as_slice()).unwrap();
693
    let decoded_manifest: PackageManifest = bincode::deserialize(&decompressed_data).unwrap();
694

695
    assert_eq!(decoded_manifest.len(), 1);
696
    assert_eq!(
697
      decoded_manifest.full_names[0],
698
      Some("TestOwner-TestMod".to_string())
699
    );
700
  }
701

702
  #[test]
703
  fn test_local_last_modified() {
704
    let temp_dir = tempdir().unwrap();
705
    let temp_dir_str = temp_dir.path().to_str().unwrap();
706
    let rt = Runtime::new().unwrap();
707

708
    {
709
      let result = rt.block_on(local_last_modified(temp_dir_str));
710

711
      assert!(result.is_ok());
712
    }
713

714
    let test_date = "Wed, 21 Feb 2024 15:30:45 GMT";
715
    let mut path = PathBuf::from(temp_dir_str);
716
    path.push(LAST_MODIFIED_FILENAME);
717
    let mut file = File::create(path).unwrap();
718
    file.write_all(test_date.as_bytes()).unwrap();
719

720
    {
721
      let result = rt.block_on(local_last_modified(temp_dir_str));
722

723
      assert!(result.is_ok());
724
    }
725
  }
726

727
  #[test]
728
  fn test_get_manifest_from_disk() {
729
    let temp_dir = tempdir().unwrap();
730
    let temp_dir_str = temp_dir.path().to_str().unwrap();
731

732
    let package = Package {
733
      name: Some("ModA".to_string()),
734
      full_name: Some("Owner-ModA".to_string()),
735
      owner: Some("Owner".to_string()),
736
      package_url: Some("https://example.com/ModA".to_string()),
737
      date_created: OffsetDateTime::now_utc(),
738
      date_updated: OffsetDateTime::now_utc(),
739
      uuid4: Some("test-uuid".to_string()),
740
      rating_score: Some(5),
741
      is_pinned: Some(false),
742
      is_deprecated: Some(false),
743
      has_nsfw_content: Some(false),
744
      categories: vec!["test".to_string()],
745
      versions: vec![],
746
    };
747

748
    let packages = vec![package];
749
    let manifest: PackageManifest = packages.into();
750
    let binary_data = bincode::serialize(&manifest).unwrap();
751
    let compressed_data = zstd::encode_all(binary_data.as_slice(), 9).unwrap();
752
    let mut path = PathBuf::from(temp_dir_str);
753
    path.push(API_MANIFEST_FILENAME);
754
    let mut file = File::create(path).unwrap();
755
    file.write_all(&compressed_data).unwrap();
756

757
    let rt = Runtime::new().unwrap();
758
    let manifest = rt.block_on(get_manifest_from_disk(temp_dir_str)).unwrap();
759

760
    assert_eq!(manifest.len(), 1);
761
    assert_eq!(manifest.full_names[0], Some("Owner-ModA".to_string()));
762
  }
763

764
  #[test]
765
  fn test_get_manifest_from_disk_error() {
766
    let temp_dir = tempdir().unwrap();
767
    let temp_dir_str = temp_dir.path().to_str().unwrap();
768

769
    let mut path = PathBuf::from(temp_dir_str);
770
    path.push(API_MANIFEST_FILENAME);
771
    let mut file = File::create(path).unwrap();
772
    file
773
      .write_all(b"This is not valid compressed data")
774
      .unwrap();
775

776
    let rt = Runtime::new().unwrap();
777
    let result = rt.block_on(get_manifest_from_disk(temp_dir_str));
778

779
    assert!(result.is_err());
780
  }
781

782
  #[test]
783
  fn test_network_last_modified() {
784
    let mut server = Server::new();
785
    let test_date = "Wed, 21 Feb 2024 15:30:45 GMT";
786
    let mock = server
787
      .mock("HEAD", "/c/valheim/api/v1/package/")
788
      .with_status(200)
789
      .with_header("Last-Modified", test_date)
790
      .create();
791
    let api_url = format!("{}/c/valheim/api/v1/package/", server.url());
792
    let rt = Runtime::new().unwrap();
793

794
    let result = rt.block_on(network_last_modified(&api_url));
795

796
    assert!(result.is_ok());
797
    if let Ok(parsed_date) = result {
798
      let expected_date = DateTime::parse_from_rfc2822(test_date).unwrap();
799
      assert_eq!(parsed_date, expected_date);
800
    }
801

802
    mock.assert();
803
  }
804

805
  #[test]
806
  fn test_get_manifest_from_network() {
807
    let mut server = Server::new();
808

809
    let test_json = r#"[
810
      {
811
        "name": "ModA",
812
        "full_name": "Owner-ModA",
813
        "owner": "Owner",
814
        "package_url": "https://example.com/mods/ModA",
815
        "date_created": "2024-01-01T12:00:00Z",
816
        "date_updated": "2024-01-02T12:00:00Z",
817
        "uuid4": "test-uuid",
818
        "rating_score": 5,
819
        "is_pinned": false,
820
        "is_deprecated": false,
821
        "has_nsfw_content": false,
822
        "categories": ["category1"],
823
        "versions": [
824
          {
825
            "name": "ModA",
826
            "full_name": "Owner-ModA",
827
            "description": "Test description",
828
            "icon": "icon.png",
829
            "version_number": "1.0.0",
830
            "dependencies": [],
831
            "download_url": "https://example.com/mods/ModA/download",
832
            "downloads": 100,
833
            "date_created": "2024-01-01T12:00:00Z",
834
            "website_url": "https://example.com",
835
            "is_active": true,
836
            "uuid4": "test-version-uuid",
837
            "file_size": 1024
838
          }
839
        ]
840
      }
841
    ]"#;
842

843
    let test_date = "Wed, 21 Feb 2024 15:30:45 GMT";
844

845
    let mock = server
846
      .mock("GET", "/c/valheim/api/v1/package/")
847
      .with_status(200)
848
      .with_header("Content-Type", "application/json")
849
      .with_header("Last-Modified", test_date)
850
      .with_body(test_json)
851
      .create();
852

853
    let api_url = format!("{}/c/valheim/api/v1/package/", server.url());
854

855
    let rt = Runtime::new().unwrap();
856
    let result = rt.block_on(get_manifest_from_network(&api_url));
857

858
    assert!(result.is_ok());
859
    if let Ok((packages, last_modified)) = result {
860
      assert_eq!(packages.len(), 1);
861
      assert_eq!(packages[0].full_name, Some("Owner-ModA".to_string()));
862
      let expected_date = DateTime::parse_from_rfc2822(test_date).unwrap();
863
      assert_eq!(last_modified, expected_date);
864
    }
865

866
    mock.assert();
867
  }
868

869
  #[test]
870
  fn test_get_manifest() {
871
    let temp_dir = tempdir().unwrap();
872
    let temp_dir_str = temp_dir.path().to_str().unwrap();
873

874
    let package = Package {
875
      name: Some("CachedMod".to_string()),
876
      full_name: Some("CachedOwner-CachedMod".to_string()),
877
      owner: Some("CachedOwner".to_string()),
878
      package_url: Some("https://example.com/CachedMod".to_string()),
879
      date_created: OffsetDateTime::now_utc(),
880
      date_updated: OffsetDateTime::now_utc(),
881
      uuid4: Some("cached-uuid".to_string()),
882
      rating_score: Some(5),
883
      is_pinned: Some(false),
884
      is_deprecated: Some(false),
885
      has_nsfw_content: Some(false),
886
      categories: vec!["test".to_string()],
887
      versions: vec![],
888
    };
889

890
    let packages = vec![package];
891
    let manifest: PackageManifest = packages.into();
892
    let binary_data = bincode::serialize(&manifest).unwrap();
893
    let compressed_data = zstd::encode_all(binary_data.as_slice(), 9).unwrap();
894

895
    std::fs::create_dir_all(PathBuf::from(temp_dir_str)).unwrap();
896

897
    let mut manifest_path = PathBuf::from(temp_dir_str);
898
    manifest_path.push(API_MANIFEST_FILENAME);
899
    let mut file = File::create(manifest_path).unwrap();
900
    file.write_all(&compressed_data).unwrap();
901

902
    let now = chrono::Utc::now().with_timezone(&chrono::FixedOffset::east_opt(0).unwrap());
903
    let recent_date = now.to_rfc2822();
904
    let mut last_mod_path = PathBuf::from(temp_dir_str);
905
    last_mod_path.push(LAST_MODIFIED_FILENAME);
906
    let mut file = File::create(&last_mod_path).unwrap();
907
    file.write_all(recent_date.as_bytes()).unwrap();
908

909
    let rt = Runtime::new().unwrap();
910

911
    let result = rt.block_on(get_manifest(temp_dir_str, None));
912

913
    assert!(result.is_ok());
914
  }
915

916
  #[test]
917
  fn test_manifest_v1_to_v2_migration() {
918
    let temp_dir = tempdir().unwrap();
919
    let temp_dir_str = temp_dir.path().to_str().unwrap();
920

921
    let package = Package {
922
      name: Some("OldMod".to_string()),
923
      full_name: Some("OldOwner-OldMod".to_string()),
924
      owner: Some("OldOwner".to_string()),
925
      package_url: Some("https://example.com/OldMod".to_string()),
926
      date_created: OffsetDateTime::now_utc(),
927
      date_updated: OffsetDateTime::now_utc(),
928
      uuid4: Some("old-uuid".to_string()),
929
      rating_score: Some(4),
930
      is_pinned: Some(false),
931
      is_deprecated: Some(false),
932
      has_nsfw_content: Some(false),
933
      categories: vec!["legacy".to_string()],
934
      versions: vec![],
935
    };
936

937
    let packages = vec![package];
938
    let binary_data = bincode::serialize(&packages).unwrap();
939
    let compressed_data = zstd::encode_all(binary_data.as_slice(), 9).unwrap();
940

941
    std::fs::create_dir_all(PathBuf::from(temp_dir_str)).unwrap();
942

943
    let mut v1_path = PathBuf::from(temp_dir_str);
944
    v1_path.push(API_MANIFEST_FILENAME_V1);
945
    let mut file = File::create(&v1_path).unwrap();
946
    file.write_all(&compressed_data).unwrap();
947

948
    let v2_path = PathBuf::from(temp_dir_str).join(API_MANIFEST_FILENAME);
949
    assert!(!v2_path.exists());
950
    assert!(v1_path.exists());
951

952
    let rt = Runtime::new().unwrap();
953
    let result = rt.block_on(get_manifest_from_disk(temp_dir_str));
954

955
    assert!(result.is_ok());
956
    let manifest = result.unwrap();
957
    assert_eq!(manifest.len(), 1);
958
    assert_eq!(manifest.full_names[0], Some("OldOwner-OldMod".to_string()));
959

960
    assert!(v2_path.exists(), "v2 manifest should be created");
961
    assert!(!v1_path.exists(), "v1 manifest should be removed");
962
  }
963

964
  #[test]
965
  fn test_get_manifest_from_network_and_cache() {
966
    let mut server = Server::new();
967

968
    let test_json = r#"[
969
      {
970
        "name": "ModA",
971
        "full_name": "Owner-ModA",
972
        "owner": "Owner",
973
        "package_url": "https://example.com/mods/ModA",
974
        "date_created": "2024-01-01T12:00:00Z",
975
        "date_updated": "2024-01-02T12:00:00Z",
976
        "uuid4": "test-uuid",
977
        "rating_score": 5,
978
        "is_pinned": false,
979
        "is_deprecated": false,
980
        "has_nsfw_content": false,
981
        "categories": ["category1"],
982
        "versions": [
983
          {
984
            "name": "ModA",
985
            "full_name": "Owner-ModA",
986
            "description": "Test description",
987
            "icon": "icon.png",
988
            "version_number": "1.0.0",
989
            "dependencies": [],
990
            "download_url": "https://example.com/mods/ModA/download",
991
            "downloads": 100,
992
            "date_created": "2024-01-01T12:00:00Z",
993
            "website_url": "https://example.com",
994
            "is_active": true,
995
            "uuid4": "test-version-uuid",
996
            "file_size": 1024
997
          }
998
        ]
999
      }
1000
    ]"#;
1001

1002
    let test_date = "Wed, 21 Feb 2024 15:30:45 GMT";
1003

1004
    let mock = server
1005
      .mock("GET", "/c/valheim/api/v1/package/")
1006
      .with_status(200)
1007
      .with_header("Content-Type", "application/json")
1008
      .with_header("Last-Modified", test_date)
1009
      .with_body(test_json)
1010
      .create();
1011

1012
    let api_url = format!("{}/c/valheim/api/v1/package/", server.url());
1013
    let temp_dir = tempdir().unwrap();
1014
    let temp_dir_str = temp_dir.path().to_str().unwrap();
1015

1016
    let rt = Runtime::new().unwrap();
1017
    let result = rt.block_on(get_manifest_from_network_and_cache(temp_dir_str, &api_url));
1018

1019
    assert!(result.is_ok());
1020
    if let Ok(manifest) = result {
1021
      assert_eq!(manifest.len(), 1);
1022
      assert_eq!(manifest.full_names[0], Some("Owner-ModA".to_string()));
1023

1024
      assert!(last_modified_path(temp_dir_str).exists());
1025
      assert!(api_manifest_path(temp_dir_str).exists());
1026

1027
      let last_mod_content = std::fs::read_to_string(last_modified_path(temp_dir_str)).unwrap();
1028
      assert!(last_mod_content.contains("21 Feb 2024 15:30:45"));
1029
    }
1030

1031
    mock.assert();
1032
  }
1033

1034
  #[test]
1035
  fn test_download_file() {
1036
    let mut server = Server::new();
1037
    let temp_dir = tempdir().unwrap();
1038
    let temp_dir_str = temp_dir.path().to_str().unwrap();
1039

1040
    let test_data = b"This is test file content";
1041
    let content_length = test_data.len();
1042

1043
    let _mock = server
1044
      .mock("GET", "/test-file.zip")
1045
      .with_status(200)
1046
      .with_header("Content-Type", "application/zip")
1047
      .with_header("Content-Length", &content_length.to_string())
1048
      .with_body(test_data)
1049
      .create();
1050

1051
    let file_url = format!("{}/test-file.zip", server.url());
1052
    let filename = "test-file.zip".to_string();
1053

1054
    let multi_progress = MultiProgress::new();
1055
    let style_template = "";
1056
    let progress_style = ProgressStyle::with_template(style_template)
1057
      .unwrap()
1058
      .progress_chars("#>-");
1059

1060
    let rt = Runtime::new().unwrap();
1061
    let client = rt.block_on(async {
1062
      reqwest::Client::builder()
1063
        .timeout(Duration::from_secs(60))
1064
        .build()
1065
        .unwrap()
1066
    });
1067

1068
    let result = rt.block_on(download_file(
1069
      client.clone(),
1070
      &file_url,
1071
      &filename,
1072
      multi_progress.clone(),
1073
      progress_style.clone(),
1074
      temp_dir_str,
1075
    ));
1076

1077
    assert!(result.is_ok());
1078

1079
    let downloads_dir = PathBuf::from(temp_dir_str).join("downloads");
1080
    assert!(downloads_dir.exists());
1081

1082
    let downloaded_file = downloads_dir.join(&filename);
1083
    assert!(downloaded_file.exists());
1084

1085
    let file_content = std::fs::read(&downloaded_file).unwrap();
1086
    assert_eq!(file_content, test_data);
1087

1088
    let result2 = rt.block_on(download_file(
1089
      client.clone(),
1090
      &file_url,
1091
      &filename,
1092
      multi_progress.clone(),
1093
      progress_style.clone(),
1094
      temp_dir_str,
1095
    ));
1096

1097
    assert!(result2.is_ok());
1098
  }
1099

1100
  #[test]
1101
  fn test_download_file_missing_header() {
1102
    let mut server = Server::new();
1103
    let temp_dir = tempdir().unwrap();
1104
    let temp_dir_str = temp_dir.path().to_str().unwrap();
1105

1106
    let test_data = b"This is test file content";
1107

1108
    let mock = server
1109
      .mock("GET", "/test-file.zip")
1110
      .with_status(200)
1111
      .with_header("Content-Type", "application/zip")
1112
      .with_body(test_data)
1113
      .create();
1114

1115
    let file_url = format!("{}/test-file.zip", server.url());
1116
    let filename = "test-file-no-header.zip".to_string();
1117

1118
    let multi_progress = MultiProgress::new();
1119
    let style_template = "";
1120
    let progress_style = ProgressStyle::with_template(style_template)
1121
      .unwrap()
1122
      .progress_chars("#>-");
1123

1124
    let rt = Runtime::new().unwrap();
1125
    let client = rt.block_on(async {
1126
      reqwest::Client::builder()
1127
        .timeout(Duration::from_secs(60))
1128
        .build()
1129
        .unwrap()
1130
    });
1131

1132
    let _result = rt.block_on(download_file(
1133
      client.clone(),
1134
      &file_url,
1135
      &filename,
1136
      multi_progress.clone(),
1137
      progress_style.clone(),
1138
      temp_dir_str,
1139
    ));
1140

1141
    mock.assert();
1142
  }
1143

1144
  #[test]
1145
  fn test_download_files() {
1146
    let mut server = Server::new();
1147
    let temp_dir = tempdir().unwrap();
1148
    let temp_dir_str = temp_dir.path().to_str().unwrap();
1149

1150
    let test_data1 = b"This is test file 1 content";
1151
    let content_length1 = test_data1.len();
1152

1153
    let test_data2 = b"This is test file 2 content - longer content";
1154
    let content_length2 = test_data2.len();
1155

1156
    let mock1 = server
1157
      .mock("GET", "/file1.zip")
1158
      .with_status(200)
1159
      .with_header("Content-Type", "application/zip")
1160
      .with_header("Content-Length", &content_length1.to_string())
1161
      .with_body(test_data1)
1162
      .create();
1163

1164
    let mock2 = server
1165
      .mock("GET", "/file2.zip")
1166
      .with_status(200)
1167
      .with_header("Content-Type", "application/zip")
1168
      .with_header("Content-Length", &content_length2.to_string())
1169
      .with_body(test_data2)
1170
      .create();
1171

1172
    let mut urls = HashMap::new();
1173
    urls.insert(
1174
      "file1.zip".to_string(),
1175
      format!("{}/file1.zip", server.url()),
1176
    );
1177
    urls.insert(
1178
      "file2.zip".to_string(),
1179
      format!("{}/file2.zip", server.url()),
1180
    );
1181

1182
    let rt = Runtime::new().unwrap();
1183
    let result = rt.block_on(download_files(urls, temp_dir_str));
1184

1185
    assert!(result.is_ok());
1186

1187
    let downloads_dir = PathBuf::from(temp_dir_str).join("downloads");
1188
    assert!(downloads_dir.exists());
1189

1190
    let file1 = downloads_dir.join("file1.zip");
1191
    let file2 = downloads_dir.join("file2.zip");
1192
    assert!(file1.exists());
1193
    assert!(file2.exists());
1194

1195
    let file1_content = std::fs::read(&file1).unwrap();
1196
    let file2_content = std::fs::read(&file2).unwrap();
1197
    assert_eq!(file1_content, test_data1);
1198
    assert_eq!(file2_content, test_data2);
1199

1200
    mock1.assert();
1201
    mock2.assert();
1202
  }
1203

1204
  #[test]
1205
  fn test_download_files_empty() {
1206
    let temp_dir = tempdir().unwrap();
1207
    let temp_dir_str = temp_dir.path().to_str().unwrap();
1208

1209
    let urls = HashMap::new();
1210

1211
    let rt = Runtime::new().unwrap();
1212
    let result = rt.block_on(download_files(urls, temp_dir_str));
1213

1214
    assert!(result.is_ok());
1215
  }
1216
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc