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

endoze / valheim-mod-manager / 20000209321

07 Dec 2025 06:25AM UTC coverage: 92.678% (+0.2%) from 92.43%
20000209321

Pull #15

github

endoze
Implement string interning for package manifest

Reduce memory usage and improve serialization efficiency by
deduplicating repeated strings across package data. The manifest
contains thousands of packages with highly repetitive data like author
names, categories, and version numbers. String interning allows these
values to be stored once and referenced by key, significantly reducing
both memory footprint and serialized size.

This optimization is particularly beneficial for the Valheim mod
ecosystem where the same authors maintain multiple packages and common
patterns appear frequently in package metadata.
Pull Request #15: Implement string interning for package manifest

497 of 527 new or added lines in 3 files covered. (94.31%)

9 existing lines in 3 files now uncovered.

886 of 956 relevant lines covered (92.68%)

3.67 hits per line

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

81.3
/src/api.rs
1
use crate::error::{AppError, AppResult};
2
use crate::package::{InternedPackageManifest, Package, PackageManifest, SerializableInternedManifest};
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_V3: &str = "api_manifest_v3.bin.zst";
18
const API_MANIFEST_FILENAME_V2: &str = "api_manifest_v2.bin.zst";
19
const API_MANIFEST_FILENAME_V1: &str = "api_manifest.bin.zst";
20

21
/// Returns the path to the last_modified file in the cache directory.
22
///
23
/// # Parameters
24
///
25
/// * `cache_dir` - The cache directory path (supports tilde expansion)
26
///
27
/// # Returns
28
///
29
/// The full path to the last_modified file
30
fn last_modified_path(cache_dir: &str) -> PathBuf {
2✔
31
  let expanded_path = shellexpand::tilde(cache_dir);
3✔
32
  let mut path = PathBuf::from(expanded_path.as_ref());
9✔
33
  path.push(LAST_MODIFIED_FILENAME);
4✔
34
  path
5✔
35
}
36

37
/// Returns the path to the v3 API manifest file in the cache directory.
38
///
39
/// # Parameters
40
///
41
/// * `cache_dir` - The cache directory path (supports tilde expansion)
42
///
43
/// # Returns
44
///
45
/// The full path to the v3 manifest file
46
fn api_manifest_path_v3(cache_dir: &str) -> PathBuf {
2✔
47
  let expanded_path = shellexpand::tilde(cache_dir);
2✔
48
  let mut path = PathBuf::from(expanded_path.as_ref());
4✔
49
  path.push(API_MANIFEST_FILENAME_V3);
2✔
50
  path
2✔
51
}
52

53
/// Returns the path to the v2 API manifest file in the cache directory.
54
///
55
/// # Parameters
56
///
57
/// * `cache_dir` - The cache directory path (supports tilde expansion)
58
///
59
/// # Returns
60
///
61
/// The full path to the v2 manifest file
62
fn api_manifest_path_v2(cache_dir: &str) -> PathBuf {
2✔
63
  let expanded_path = shellexpand::tilde(cache_dir);
2✔
64
  let mut path = PathBuf::from(expanded_path.as_ref());
4✔
65
  path.push(API_MANIFEST_FILENAME_V2);
2✔
66
  path
2✔
67
}
68

69
/// Returns the path to the v1 API manifest file in the cache directory.
70
///
71
/// # Parameters
72
///
73
/// * `cache_dir` - The cache directory path (supports tilde expansion)
74
///
75
/// # Returns
76
///
77
/// The full path to the v1 manifest file
78
fn api_manifest_path_v1(cache_dir: &str) -> PathBuf {
2✔
79
  let expanded_path = shellexpand::tilde(cache_dir);
2✔
80
  let mut path = PathBuf::from(expanded_path.as_ref());
4✔
81
  path.push(API_MANIFEST_FILENAME_V1);
2✔
82
  path
2✔
83
}
84

85
/// Retrieves the manifest of available packages.
86
///
87
/// This function first checks if there's a cached manifest file that is up-to-date.
88
/// If a cached version exists and is current, it loads from disk.
89
/// Otherwise, it downloads the manifest from the network and caches it.
90
///
91
/// # Parameters
92
///
93
/// * `cache_dir` - The directory to store cache files in
94
/// * `api_url` - The API URL to use for network requests (defaults to API_URL)
95
///
96
/// # Returns
97
///
98
/// An `InternedPackageManifest` containing all available packages in SoA format with interned strings.
99
///
100
/// # Errors
101
///
102
/// Returns an error if:
103
/// - Failed to check or retrieve last modified dates
104
/// - Network request fails
105
/// - Parsing fails
106
pub async fn get_manifest(
2✔
107
  cache_dir: &str,
108
  api_url: Option<&str>,
109
) -> AppResult<InternedPackageManifest> {
110
  let api_url = api_url.unwrap_or(API_URL);
4✔
111
  let last_modified = local_last_modified(cache_dir).await?;
4✔
112
  tracing::info!("Manifest last modified: {}", last_modified);
6✔
113

114
  if api_manifest_file_exists(cache_dir) && network_last_modified(api_url).await? <= last_modified {
8✔
115
    tracing::info!("Loading manifest from cache");
6✔
116
    get_manifest_from_disk(cache_dir).await
6✔
117
  } else {
118
    tracing::info!("Downloading new manifest");
×
119
    get_manifest_from_network_and_cache(cache_dir, api_url).await
×
120
  }
121
}
122

123
/// Reads the cached API manifest from disk.
124
///
125
/// Attempts to read formats in order: v3 (interned), v2 (SoA), v1 (`Vec<Package>`).
126
/// When older formats are detected, they are automatically migrated to v3.
127
///
128
/// # Returns
129
///
130
/// The deserialized manifest as an InternedPackageManifest.
131
///
132
/// # Errors
133
///
134
/// Returns an error if:
135
/// - The file cannot be opened or read
136
/// - The data cannot be decompressed or deserialized
137
async fn get_manifest_from_disk(cache_dir: &str) -> AppResult<InternedPackageManifest> {
8✔
138
  let path_v3 = api_manifest_path_v3(cache_dir);
2✔
139
  let path_v2 = api_manifest_path_v2(cache_dir);
2✔
140
  let path_v1 = api_manifest_path_v1(cache_dir);
2✔
141

142
  if path_v3.exists() {
4✔
143
    tracing::debug!("Loading v3 manifest format");
6✔
144

145
    let mut file = fs::File::open(&path_v3).await?;
6✔
146
    let mut compressed_data = Vec::new();
2✔
147
    file.read_to_end(&mut compressed_data).await?;
8✔
148

149
    let decompressed_data = zstd::decode_all(compressed_data.as_slice())
8✔
150
      .map_err(|e| AppError::Manifest(format!("Failed to decompress v3 manifest: {}", e)))?;
8✔
151

152
    let serializable: SerializableInternedManifest = bincode::deserialize(&decompressed_data)
4✔
NEW
153
      .map_err(|e| AppError::Manifest(format!("Failed to deserialize v3 manifest: {}", e)))?;
×
154

155
    let manifest: InternedPackageManifest = serializable.into();
4✔
156

157
    manifest
4✔
158
      .validate()
NEW
159
      .map_err(|e| AppError::Manifest(format!("V3 manifest validation failed: {}", e)))?;
×
160

161
    return Ok(manifest);
2✔
162
  }
163

164
  if path_v2.exists() {
4✔
NEW
165
    tracing::info!("Found v2 manifest, migrating to v3 format");
×
166

UNCOV
167
    let mut file = fs::File::open(&path_v2).await?;
×
UNCOV
168
    let mut compressed_data = Vec::new();
×
UNCOV
169
    file.read_to_end(&mut compressed_data).await?;
×
170

UNCOV
171
    let decompressed_data = zstd::decode_all(compressed_data.as_slice())
×
UNCOV
172
      .map_err(|e| AppError::Manifest(format!("Failed to decompress v2 manifest: {}", e)))?;
×
173

NEW
174
    let v2_manifest: PackageManifest = bincode::deserialize(&decompressed_data)
×
175
      .map_err(|e| AppError::Manifest(format!("Failed to deserialize v2 manifest: {}", e)))?;
×
176

NEW
177
    let manifest: InternedPackageManifest = v2_manifest.into();
×
178

NEW
179
    manifest.validate().map_err(|e| {
×
NEW
180
      AppError::Manifest(format!(
×
181
        "V3 manifest validation failed during migration: {}",
182
        e
183
      ))
184
    })?;
185

NEW
186
    let serializable: SerializableInternedManifest = (&manifest).into();
×
NEW
187
    let binary_data = bincode::serialize(&serializable)
×
NEW
188
      .map_err(|e| AppError::Manifest(format!("Failed to serialize v3 manifest: {}", e)))?;
×
189

NEW
190
    write_cache_to_disk(path_v3.clone(), &binary_data, true).await?;
×
191

NEW
192
    match tokio::fs::metadata(&path_v3).await {
×
NEW
193
      Ok(metadata) if metadata.len() > 0 => {
×
NEW
194
        tracing::info!("V3 manifest written successfully, removing v2");
×
195

NEW
196
        if let Err(e) = fs::remove_file(&path_v2).await {
×
NEW
197
          tracing::warn!(
×
198
            "Failed to remove old v2 manifest (keeping as backup): {}",
199
            e
200
          );
201
        }
202
      }
203
      Ok(_) => {
NEW
204
        tracing::error!("V3 manifest written but is empty, keeping v2 as backup");
×
205
      }
NEW
206
      Err(e) => {
×
NEW
207
        tracing::error!(
×
208
          "Failed to verify v3 manifest write: {}, keeping v2 as backup",
209
          e
210
        );
211
      }
212
    }
213

NEW
214
    return Ok(manifest);
×
215
  }
216

217
  if path_v1.exists() {
4✔
218
    tracing::info!("Found v1 manifest, migrating to v3 format");
6✔
219

220
    let mut file = fs::File::open(&path_v1).await?;
6✔
221
    let mut compressed_data = Vec::new();
2✔
222
    file.read_to_end(&mut compressed_data).await?;
8✔
223

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

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

230
    let manifest: InternedPackageManifest = packages.into();
4✔
231

232
    manifest.validate().map_err(|e| {
4✔
NEW
233
      AppError::Manifest(format!(
×
234
        "V3 manifest validation failed during migration: {}",
235
        e
236
      ))
237
    })?;
238

239
    let serializable: SerializableInternedManifest = (&manifest).into();
2✔
240
    let binary_data = bincode::serialize(&serializable)
4✔
NEW
241
      .map_err(|e| AppError::Manifest(format!("Failed to serialize v3 manifest: {}", e)))?;
×
242

243
    write_cache_to_disk(path_v3.clone(), &binary_data, true).await?;
6✔
244

245
    match tokio::fs::metadata(&path_v3).await {
6✔
246
      Ok(metadata) if metadata.len() > 0 => {
6✔
247
        tracing::info!("V3 manifest written successfully, removing v1");
6✔
248

249
        if let Err(e) = fs::remove_file(&path_v1).await {
6✔
NEW
250
          tracing::warn!(
×
251
            "Failed to remove old v1 manifest (keeping as backup): {}",
252
            e
253
          );
254
        }
255
      }
256
      Ok(_) => {
NEW
257
        tracing::error!("V3 manifest written but is empty, keeping v1 as backup");
×
258
      }
NEW
259
      Err(e) => {
×
NEW
260
        tracing::error!(
×
261
          "Failed to verify v3 manifest write: {}, keeping v1 as backup",
262
          e
263
        );
264
      }
265
    }
266

267
    return Ok(manifest);
2✔
268
  }
269

NEW
270
  Err(AppError::Manifest("No cached manifest found".to_string()))
×
271
}
272

273
/// Downloads the manifest from the API server and filters out unnecessary fields.
274
///
275
/// This function shows a progress bar while downloading the manifest.
276
/// It parses the downloaded data to filter out fields marked with #[serde(skip_deserializing)],
277
/// which helps reduce the size of stored data on disk.
278
///
279
/// # Parameters
280
///
281
/// * `api_url` - The API URL to download the manifest from
282
///
283
/// # Returns
284
///
285
/// A tuple containing:
286
/// - The filtered manifest data as a vector of Packages (with unnecessary fields removed)
287
/// - The last modified date from the response headers
288
///
289
/// # Errors
290
///
291
/// Returns an error if:
292
/// - Network request fails
293
/// - Response headers cannot be parsed
294
/// - Last modified date cannot be parsed
295
/// - JSON parsing or serialization fails
296
async fn get_manifest_from_network(
2✔
297
  api_url: &str,
298
) -> AppResult<(Vec<Package>, DateTime<FixedOffset>)> {
299
  let client = Client::builder().build()?;
4✔
300

301
  let multi_progress = MultiProgress::new();
4✔
302
  let style_template = "";
2✔
303
  let progress_style = ProgressStyle::with_template(style_template)
4✔
304
    .unwrap()
305
    .tick_strings(&["-", "\\", "|", "/", ""]);
2✔
306

307
  let progress_bar = multi_progress.add(ProgressBar::new(100000));
4✔
308
  progress_bar.set_style(progress_style);
2✔
309
  progress_bar.set_message("Downloading Api Manifest");
2✔
310
  progress_bar.enable_steady_tick(Duration::from_millis(130));
2✔
311

312
  let response = client.get(api_url).send().await?;
6✔
313
  let last_modified_str = response
9✔
314
    .headers()
315
    .get(header::LAST_MODIFIED)
316
    .ok_or(AppError::MissingHeader("Last-Modified".to_string()))?
3✔
317
    .to_str()?;
318
  let last_modified = DateTime::parse_from_rfc2822(last_modified_str)?;
6✔
319

320
  let raw_response_data = response.bytes().await?;
6✔
321

322
  let packages: Vec<Package> = serde_json::from_slice(&raw_response_data)?;
6✔
323

324
  progress_bar.finish_with_message(" Downloaded Api Manifest");
3✔
325

326
  Ok((packages, last_modified))
3✔
327
}
328

329
/// Downloads the manifest from the network and caches it locally.
330
///
331
/// This function retrieves the manifest from the API server and saves both
332
/// the manifest content and the last modified date to disk.
333
/// The manifest is converted to the interned V3 format before caching.
334
///
335
/// # Parameters
336
///
337
/// * `cache_dir` - The directory to store cache files in
338
/// * `api_url` - The API URL to download the manifest from
339
///
340
/// # Returns
341
///
342
/// The manifest data as InternedPackageManifest.
343
///
344
/// # Errors
345
///
346
/// Returns an error if:
347
/// - Network request fails
348
/// - Writing cache files fails
349
async fn get_manifest_from_network_and_cache(
2✔
350
  cache_dir: &str,
351
  api_url: &str,
352
) -> AppResult<InternedPackageManifest> {
353
  let results = get_manifest_from_network(api_url).await?;
6✔
354
  let packages = results.0;
2✔
355
  let last_modified_from_network = results.1;
2✔
356

357
  write_cache_to_disk(
358
    last_modified_path(cache_dir),
4✔
359
    last_modified_from_network.to_rfc2822().as_bytes(),
4✔
360
    false,
361
  )
362
  .await?;
10✔
363

364
  let manifest: InternedPackageManifest = packages.into();
4✔
365
  let serializable: SerializableInternedManifest = (&manifest).into();
2✔
366
  let binary_data = bincode::serialize(&serializable)
4✔
UNCOV
367
    .map_err(|e| AppError::Manifest(format!("Failed to serialize manifest: {}", e)))?;
×
368

369
  write_cache_to_disk(api_manifest_path_v3(cache_dir), &binary_data, true).await?;
6✔
370

371
  Ok(manifest)
2✔
372
}
373

374
/// Retrieves the last modified date from the local cache file.
375
///
376
/// If the file doesn't exist, returns a default date (epoch time).
377
///
378
/// # Returns
379
///
380
/// The last modified date as a `DateTime<FixedOffset>`.
381
///
382
/// # Errors
383
///
384
/// Returns an error if:
385
/// - The file exists but cannot be read
386
/// - The date string cannot be parsed
387
async fn local_last_modified(cache_dir: &str) -> AppResult<DateTime<FixedOffset>> {
8✔
388
  let path = last_modified_path(cache_dir);
2✔
389

390
  if let Ok(mut file) = fs::File::open(&path).await {
8✔
391
    tracing::info!("Last modified file exists and was opened.");
6✔
392
    let mut contents = String::new();
2✔
393
    file.read_to_string(&mut contents).await?;
6✔
394

395
    let last_modified = DateTime::parse_from_rfc2822(&contents)?;
5✔
396

397
    Ok(last_modified)
3✔
398
  } else {
399
    tracing::info!("Last modified file does not exist and was not opened.");
6✔
400
    let dt = DateTime::from_timestamp(0, 0).unwrap().naive_utc();
4✔
401
    let offset = FixedOffset::east_opt(0).unwrap();
4✔
402

403
    let last_modified = DateTime::<FixedOffset>::from_naive_utc_and_offset(dt, offset);
2✔
404
    Ok(last_modified)
2✔
405
  }
406
}
407

408
/// Retrieves the last modified date from the API server.
409
///
410
/// Makes a HEAD request to the API endpoint to check when the manifest was last updated.
411
///
412
/// # Parameters
413
///
414
/// * `api_url` - The API URL to check
415
///
416
/// # Returns
417
///
418
/// The last modified date from the server as a `DateTime<FixedOffset>`.
419
///
420
/// # Errors
421
///
422
/// Returns an error if:
423
/// - Network request fails
424
/// - Last-Modified header is missing
425
/// - Date string cannot be parsed
426
async fn network_last_modified(api_url: &str) -> AppResult<DateTime<FixedOffset>> {
8✔
427
  let client = Client::builder().build()?;
4✔
428

429
  let response = client.head(api_url).send().await?;
6✔
430

431
  let last_modified =
6✔
432
    response
433
      .headers()
434
      .get(header::LAST_MODIFIED)
435
      .ok_or(AppError::MissingHeader(
2✔
436
        "Last-Modified for API manifest head request".to_string(),
2✔
437
      ))?;
438
  let last_modified_date = DateTime::parse_from_rfc2822(last_modified.to_str()?)?;
4✔
439

440
  Ok(last_modified_date)
2✔
441
}
442

443
/// Checks if the cached manifest file exists.
444
///
445
/// Checks for the existence of any version of the cached manifest file (v1, v2, or v3).
446
///
447
/// # Parameters
448
///
449
/// * `cache_dir` - The cache directory path
450
///
451
/// # Returns
452
///
453
/// `true` if any api_manifest file exists, `false` otherwise.
454
fn api_manifest_file_exists(cache_dir: &str) -> bool {
2✔
455
  api_manifest_path_v3(cache_dir).exists()
6✔
456
    || api_manifest_path_v2(cache_dir).exists()
2✔
457
    || api_manifest_path_v1(cache_dir).exists()
2✔
458
}
459

460
/// Writes data to a cache file on disk.
461
///
462
/// Creates the parent directory if it doesn't exist, then the file if it doesn't exist,
463
/// then writes the provided contents to it.
464
///
465
/// # Parameters
466
///
467
/// * `path` - The path to the cache file
468
/// * `contents` - The data to write to the file
469
/// * `use_compression` - If true, the data will be compressed with zstd level 9
470
///   (balanced compression ratio for SoA structure's repetitive patterns)
471
///
472
/// # Errors
473
///
474
/// Returns an error if:
475
/// - Directory creation fails
476
/// - File creation fails
477
/// - Opening the file for writing fails
478
/// - Writing to the file fails
479
/// - Compression fails
480
async fn write_cache_to_disk<T: AsRef<Path>>(
4✔
481
  path: T,
482
  contents: &[u8],
483
  use_compression: bool,
484
) -> AppResult<()> {
485
  if let Some(parent) = path.as_ref().parent() {
14✔
486
    fs::create_dir_all(parent).await?;
13✔
487
  }
488

489
  if !path.as_ref().exists() {
12✔
490
    fs::File::create(&path).await?;
18✔
491
  }
492

493
  let mut file = fs::OpenOptions::new()
33✔
494
    .write(true)
495
    .truncate(true)
496
    .open(&path)
6✔
497
    .await?;
23✔
498

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

503
    file.write_all(&compressed_data).await?;
12✔
504
    file.flush().await?;
12✔
505
  } else {
506
    file.write_all(contents).await?;
17✔
507
    file.flush().await?;
14✔
508
  }
509

510
  Ok(())
4✔
511
}
512

513
/// Downloads multiple files concurrently with progress indicators.
514
/// Skips files that already exist with the correct version.
515
///
516
/// # Parameters
517
///
518
/// * `urls` - A HashMap where keys are filenames and values are the URLs to download from
519
/// * `cache_dir` - The app's cache directory
520
///
521
/// # Notes
522
///
523
/// - Files are downloaded in parallel with a limit of 2 concurrent downloads
524
/// - Progress bars show download status for each file
525
/// - Files are saved in the 'cache_dir/downloads' directory
526
/// - Files that already exist with the correct version are skipped
527
pub async fn download_files(urls: HashMap<String, String>, cache_dir: &str) -> AppResult<()> {
8✔
528
  if urls.is_empty() {
4✔
529
    tracing::debug!("No files to download");
6✔
530

531
    return Ok(());
2✔
532
  }
533

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

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

538
  let multi_progress = MultiProgress::new();
4✔
539
  let style_template = "";
2✔
540
  let progress_style = ProgressStyle::with_template(style_template)
4✔
541
    .unwrap()
542
    .progress_chars("#>-");
543

544
  let futures: FuturesUnordered<_> = urls
4✔
545
    .into_iter()
546
    .map(|(archive_filename, url)| {
6✔
547
      let client = client.clone();
4✔
548
      let multi_progress = multi_progress.clone();
4✔
549
      let progress_style = progress_style.clone();
4✔
550

551
      let cache_dir = cache_dir.to_string();
2✔
552
      tokio::spawn(async move {
6✔
553
        download_file(
6✔
554
          client,
2✔
555
          &url,
2✔
556
          &archive_filename,
2✔
557
          multi_progress,
2✔
558
          progress_style,
2✔
559
          &cache_dir,
2✔
560
        )
561
        .await
10✔
562
      })
563
    })
564
    .collect();
565

566
  let responses = futures::stream::iter(futures)
6✔
567
    .buffer_unordered(2)
568
    .collect::<Vec<_>>()
569
    .await;
8✔
570

571
  for response in responses {
6✔
572
    match response {
4✔
573
      Ok(Ok(_)) => {}
574
      Ok(Err(err)) => {
×
575
        tracing::error!("Download error: {:?}", err);
×
576
      }
577
      Err(err) => {
×
578
        return Err(AppError::TaskFailed(err));
×
579
      }
580
    }
581
  }
582

583
  Ok(())
2✔
584
}
585

586
/// Downloads a single file from a URL with a progress indicator.
587
/// Checks if the file already exists before downloading to avoid duplicate downloads.
588
///
589
/// # Parameters
590
///
591
/// * `client` - The HTTP client to use for the download
592
/// * `url` - The URL to download from
593
/// * `filename` - The name to save the file as
594
/// * `multi_progress` - The multi-progress display for coordinating multiple progress bars
595
/// * `progress_style` - The style to use for the progress bar
596
/// * `cache_dir` - The app's cache directory
597
///
598
/// # Returns
599
///
600
/// `Ok(())` on successful download or if file already exists.
601
///
602
/// # Errors
603
///
604
/// Returns an error if:
605
/// - Network request fails
606
/// - Content-Length header is missing
607
/// - Creating directories or files fails
608
/// - Writing to the file fails
609
async fn download_file(
2✔
610
  client: reqwest::Client,
611
  url: &String,
612
  filename: &String,
613
  multi_progress: MultiProgress,
614
  progress_style: ProgressStyle,
615
  cache_dir: &str,
616
) -> AppResult<()> {
617
  let expanded_path = shellexpand::tilde(cache_dir);
2✔
618
  let mut downloads_directory = PathBuf::from(expanded_path.as_ref());
4✔
619
  downloads_directory.push("downloads");
4✔
620
  let mut file_path = downloads_directory.clone();
6✔
621
  file_path.push(filename);
3✔
622

623
  tokio::fs::DirBuilder::new()
17✔
624
    .recursive(true)
625
    .create(&downloads_directory)
4✔
626
    .await?;
12✔
627

628
  if file_path.exists() {
8✔
629
    tracing::debug!("{} already exists, skipping download", filename);
6✔
630

631
    return Ok(());
2✔
632
  }
633

634
  let response = client.get(url).send().await?;
11✔
635
  let content_length = response
8✔
636
    .headers()
637
    .get(header::CONTENT_LENGTH)
638
    .ok_or(AppError::MissingHeader(format!(
4✔
639
      "Content-Length header for {}",
640
      url
641
    )))?
642
    .to_str()?
643
    .parse::<u64>()?;
644
  let mut response_data = response.bytes_stream();
4✔
645

646
  let progress_bar = multi_progress.add(ProgressBar::new(content_length));
4✔
647
  progress_bar.set_style(progress_style);
2✔
648
  progress_bar.set_message(filename.clone());
2✔
649

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

652
  while let Some(result) = response_data.next().await {
10✔
653
    let chunk = result?;
6✔
654
    file.write_all(&chunk).await?;
13✔
655
    progress_bar.inc(chunk.len() as u64);
5✔
656
  }
657

658
  file.flush().await?;
6✔
659
  file.sync_all().await?;
7✔
660

661
  progress_bar.finish();
3✔
662

663
  Ok(())
3✔
664
}
665

666
#[cfg(test)]
667
mod tests {
668
  use super::*;
669
  use mockito::Server;
670
  use std::fs::File;
671
  use std::io::Write;
672
  use tempfile::tempdir;
673
  use time::OffsetDateTime;
674
  use tokio::runtime::Runtime;
675

676
  #[test]
677
  fn test_downloads_directory_construction() {
678
    let temp_dir = tempdir().unwrap();
679
    let cache_dir = temp_dir.path().to_str().unwrap();
680

681
    let expanded_path = shellexpand::tilde(cache_dir);
682
    let mut downloads_directory = PathBuf::from(expanded_path.as_ref());
683
    downloads_directory.push("downloads");
684

685
    let expected_directory = PathBuf::from(cache_dir).join("downloads");
686
    assert_eq!(downloads_directory, expected_directory);
687
  }
688

689
  #[test]
690
  fn test_api_manifest_file_exists() {
691
    let temp_dir = tempdir().unwrap();
692
    let temp_dir_str = temp_dir.path().to_str().unwrap();
693

694
    assert!(!api_manifest_file_exists(temp_dir_str));
695

696
    let mut path = PathBuf::from(temp_dir_str);
697
    path.push(API_MANIFEST_FILENAME_V3);
698
    let _ = File::create(path).unwrap();
699

700
    assert!(api_manifest_file_exists(temp_dir_str));
701
  }
702

703
  #[test]
704
  fn test_path_with_tilde() {
705
    let home_path = "~/some_test_dir";
706
    let home_dir = std::env::var("HOME").unwrap_or_else(|_| "/home/user".to_string());
707
    let expected_path = Path::new(&home_dir).join("some_test_dir");
708

709
    let last_modified = last_modified_path(home_path);
710
    let api_manifest = api_manifest_path_v3(home_path);
711

712
    assert_eq!(last_modified.parent().unwrap(), expected_path);
713
    assert_eq!(api_manifest.parent().unwrap(), expected_path);
714
  }
715

716
  #[test]
717
  fn test_write_cache_to_disk() {
718
    let temp_dir = tempdir().unwrap();
719
    let cache_path = temp_dir.path().join("test_cache.txt");
720
    let test_data = b"Test cache data";
721

722
    let rt = Runtime::new().unwrap();
723
    let result = rt.block_on(write_cache_to_disk(&cache_path, test_data, false));
724

725
    assert!(result.is_ok());
726
    assert!(cache_path.exists());
727

728
    let content = std::fs::read(&cache_path).unwrap();
729
    assert_eq!(content, test_data);
730

731
    let new_data = b"Updated cache data";
732
    let result = rt.block_on(write_cache_to_disk(&cache_path, new_data, false));
733

734
    assert!(result.is_ok());
735
    let content = std::fs::read(&cache_path).unwrap();
736

737
    assert_eq!(content, new_data);
738
  }
739

740
  #[test]
741
  fn test_write_cache_to_disk_creates_directories() {
742
    let temp_dir = tempdir().unwrap();
743

744
    let nonexistent_subdir = temp_dir.path().join("subdir1/subdir2");
745
    let cache_path = nonexistent_subdir.join("test_cache.txt");
746

747
    if nonexistent_subdir.exists() {
748
      std::fs::remove_dir_all(&nonexistent_subdir).unwrap();
749
    }
750

751
    assert!(!nonexistent_subdir.exists());
752
    assert!(!cache_path.exists());
753

754
    let test_data = b"Test cache data";
755
    let rt = Runtime::new().unwrap();
756

757
    let result = rt.block_on(write_cache_to_disk(&cache_path, test_data, false));
758
    assert!(result.is_ok());
759

760
    assert!(
761
      nonexistent_subdir.exists(),
762
      "Directory structure should be created"
763
    );
764
    assert!(cache_path.exists(), "File should be created");
765

766
    let content = std::fs::read(&cache_path).unwrap();
767
    assert_eq!(content, test_data, "File should contain the expected data");
768
  }
769

770
  #[test]
771
  fn test_write_cache_to_disk_with_compression() {
772
    let temp_dir = tempdir().unwrap();
773
    let api_manifest_path = temp_dir.path().join("test_compressed.bin");
774

775
    let package = Package {
776
      name: Some("TestMod".to_string()),
777
      full_name: Some("TestOwner-TestMod".to_string()),
778
      owner: Some("TestOwner".to_string()),
779
      package_url: Some("https://example.com/TestMod".to_string()),
780
      date_created: OffsetDateTime::now_utc(),
781
      date_updated: OffsetDateTime::now_utc(),
782
      uuid4: Some("test-uuid".to_string()),
783
      rating_score: Some(5),
784
      is_pinned: Some(false),
785
      is_deprecated: Some(false),
786
      has_nsfw_content: Some(false),
787
      categories: vec!["test".to_string()],
788
      versions: vec![],
789
    };
790

791
    let packages = vec![package];
792
    let manifest: PackageManifest = packages.into();
793
    let binary_data = bincode::serialize(&manifest).unwrap();
794

795
    let rt = Runtime::new().unwrap();
796
    let result = rt.block_on(write_cache_to_disk(&api_manifest_path, &binary_data, true));
797

798
    assert!(result.is_ok());
799
    assert!(api_manifest_path.exists());
800

801
    let compressed_data = std::fs::read(&api_manifest_path).unwrap();
802
    let decompressed_data = zstd::decode_all(compressed_data.as_slice()).unwrap();
803
    let decoded_manifest: PackageManifest = bincode::deserialize(&decompressed_data).unwrap();
804

805
    assert_eq!(decoded_manifest.len(), 1);
806
    assert_eq!(
807
      decoded_manifest.full_names[0],
808
      Some("TestOwner-TestMod".to_string())
809
    );
810
  }
811

812
  #[test]
813
  fn test_local_last_modified() {
814
    let temp_dir = tempdir().unwrap();
815
    let temp_dir_str = temp_dir.path().to_str().unwrap();
816
    let rt = Runtime::new().unwrap();
817

818
    {
819
      let result = rt.block_on(local_last_modified(temp_dir_str));
820

821
      assert!(result.is_ok());
822
    }
823

824
    let test_date = "Wed, 21 Feb 2024 15:30:45 GMT";
825
    let mut path = PathBuf::from(temp_dir_str);
826
    path.push(LAST_MODIFIED_FILENAME);
827
    let mut file = File::create(path).unwrap();
828
    file.write_all(test_date.as_bytes()).unwrap();
829

830
    {
831
      let result = rt.block_on(local_last_modified(temp_dir_str));
832

833
      assert!(result.is_ok());
834
    }
835
  }
836

837
  #[test]
838
  fn test_get_manifest_from_disk() {
839
    let temp_dir = tempdir().unwrap();
840
    let temp_dir_str = temp_dir.path().to_str().unwrap();
841

842
    let package = Package {
843
      name: Some("ModA".to_string()),
844
      full_name: Some("Owner-ModA".to_string()),
845
      owner: Some("Owner".to_string()),
846
      package_url: Some("https://example.com/ModA".to_string()),
847
      date_created: OffsetDateTime::now_utc(),
848
      date_updated: OffsetDateTime::now_utc(),
849
      uuid4: Some("test-uuid".to_string()),
850
      rating_score: Some(5),
851
      is_pinned: Some(false),
852
      is_deprecated: Some(false),
853
      has_nsfw_content: Some(false),
854
      categories: vec!["test".to_string()],
855
      versions: vec![],
856
    };
857

858
    let packages = vec![package];
859
    let interned_manifest: InternedPackageManifest = packages.into();
860
    let serializable: SerializableInternedManifest = (&interned_manifest).into();
861
    let binary_data = bincode::serialize(&serializable).unwrap();
862
    let compressed_data = zstd::encode_all(binary_data.as_slice(), 9).unwrap();
863
    let mut path = PathBuf::from(temp_dir_str);
864
    path.push(API_MANIFEST_FILENAME_V3);
865
    let mut file = File::create(path).unwrap();
866
    file.write_all(&compressed_data).unwrap();
867

868
    let rt = Runtime::new().unwrap();
869
    let manifest = rt.block_on(get_manifest_from_disk(temp_dir_str)).unwrap();
870

871
    assert_eq!(manifest.len(), 1);
872
    assert_eq!(manifest.resolve_full_name_at(0), Some("Owner-ModA".to_string()));
873
  }
874

875
  #[test]
876
  fn test_get_manifest_from_disk_error() {
877
    let temp_dir = tempdir().unwrap();
878
    let temp_dir_str = temp_dir.path().to_str().unwrap();
879

880
    let mut path = PathBuf::from(temp_dir_str);
881
    path.push(API_MANIFEST_FILENAME_V3);
882
    let mut file = File::create(path).unwrap();
883
    file
884
      .write_all(b"This is not valid compressed data")
885
      .unwrap();
886

887
    let rt = Runtime::new().unwrap();
888
    let result = rt.block_on(get_manifest_from_disk(temp_dir_str));
889

890
    assert!(result.is_err());
891
  }
892

893
  #[test]
894
  fn test_network_last_modified() {
895
    let mut server = Server::new();
896
    let test_date = "Wed, 21 Feb 2024 15:30:45 GMT";
897
    let mock = server
898
      .mock("HEAD", "/c/valheim/api/v1/package/")
899
      .with_status(200)
900
      .with_header("Last-Modified", test_date)
901
      .create();
902
    let api_url = format!("{}/c/valheim/api/v1/package/", server.url());
903
    let rt = Runtime::new().unwrap();
904

905
    let result = rt.block_on(network_last_modified(&api_url));
906

907
    assert!(result.is_ok());
908
    if let Ok(parsed_date) = result {
909
      let expected_date = DateTime::parse_from_rfc2822(test_date).unwrap();
910
      assert_eq!(parsed_date, expected_date);
911
    }
912

913
    mock.assert();
914
  }
915

916
  #[test]
917
  fn test_get_manifest_from_network() {
918
    let mut server = Server::new();
919

920
    let test_json = r#"[
921
      {
922
        "name": "ModA",
923
        "full_name": "Owner-ModA",
924
        "owner": "Owner",
925
        "package_url": "https://example.com/mods/ModA",
926
        "date_created": "2024-01-01T12:00:00Z",
927
        "date_updated": "2024-01-02T12:00:00Z",
928
        "uuid4": "test-uuid",
929
        "rating_score": 5,
930
        "is_pinned": false,
931
        "is_deprecated": false,
932
        "has_nsfw_content": false,
933
        "categories": ["category1"],
934
        "versions": [
935
          {
936
            "name": "ModA",
937
            "full_name": "Owner-ModA",
938
            "description": "Test description",
939
            "icon": "icon.png",
940
            "version_number": "1.0.0",
941
            "dependencies": [],
942
            "download_url": "https://example.com/mods/ModA/download",
943
            "downloads": 100,
944
            "date_created": "2024-01-01T12:00:00Z",
945
            "website_url": "https://example.com",
946
            "is_active": true,
947
            "uuid4": "test-version-uuid",
948
            "file_size": 1024
949
          }
950
        ]
951
      }
952
    ]"#;
953

954
    let test_date = "Wed, 21 Feb 2024 15:30:45 GMT";
955

956
    let mock = server
957
      .mock("GET", "/c/valheim/api/v1/package/")
958
      .with_status(200)
959
      .with_header("Content-Type", "application/json")
960
      .with_header("Last-Modified", test_date)
961
      .with_body(test_json)
962
      .create();
963

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

966
    let rt = Runtime::new().unwrap();
967
    let result = rt.block_on(get_manifest_from_network(&api_url));
968

969
    assert!(result.is_ok());
970
    if let Ok((packages, last_modified)) = result {
971
      assert_eq!(packages.len(), 1);
972
      assert_eq!(packages[0].full_name, Some("Owner-ModA".to_string()));
973
      let expected_date = DateTime::parse_from_rfc2822(test_date).unwrap();
974
      assert_eq!(last_modified, expected_date);
975
    }
976

977
    mock.assert();
978
  }
979

980
  #[test]
981
  fn test_get_manifest() {
982
    let temp_dir = tempdir().unwrap();
983
    let temp_dir_str = temp_dir.path().to_str().unwrap();
984

985
    let package = Package {
986
      name: Some("CachedMod".to_string()),
987
      full_name: Some("CachedOwner-CachedMod".to_string()),
988
      owner: Some("CachedOwner".to_string()),
989
      package_url: Some("https://example.com/CachedMod".to_string()),
990
      date_created: OffsetDateTime::now_utc(),
991
      date_updated: OffsetDateTime::now_utc(),
992
      uuid4: Some("cached-uuid".to_string()),
993
      rating_score: Some(5),
994
      is_pinned: Some(false),
995
      is_deprecated: Some(false),
996
      has_nsfw_content: Some(false),
997
      categories: vec!["test".to_string()],
998
      versions: vec![],
999
    };
1000

1001
    let packages = vec![package];
1002
    let interned_manifest: InternedPackageManifest = packages.into();
1003
    let serializable: SerializableInternedManifest = (&interned_manifest).into();
1004
    let binary_data = bincode::serialize(&serializable).unwrap();
1005
    let compressed_data = zstd::encode_all(binary_data.as_slice(), 9).unwrap();
1006

1007
    std::fs::create_dir_all(PathBuf::from(temp_dir_str)).unwrap();
1008

1009
    let mut manifest_path = PathBuf::from(temp_dir_str);
1010
    manifest_path.push(API_MANIFEST_FILENAME_V3);
1011
    let mut file = File::create(manifest_path).unwrap();
1012
    file.write_all(&compressed_data).unwrap();
1013

1014
    let now = chrono::Utc::now().with_timezone(&chrono::FixedOffset::east_opt(0).unwrap());
1015
    let recent_date = now.to_rfc2822();
1016
    let mut last_mod_path = PathBuf::from(temp_dir_str);
1017
    last_mod_path.push(LAST_MODIFIED_FILENAME);
1018
    let mut file = File::create(&last_mod_path).unwrap();
1019
    file.write_all(recent_date.as_bytes()).unwrap();
1020

1021
    let rt = Runtime::new().unwrap();
1022

1023
    let result = rt.block_on(get_manifest(temp_dir_str, None));
1024

1025
    assert!(result.is_ok());
1026
  }
1027

1028
  #[test]
1029
  fn test_manifest_v1_to_v2_migration() {
1030
    let temp_dir = tempdir().unwrap();
1031
    let temp_dir_str = temp_dir.path().to_str().unwrap();
1032

1033
    let package = Package {
1034
      name: Some("OldMod".to_string()),
1035
      full_name: Some("OldOwner-OldMod".to_string()),
1036
      owner: Some("OldOwner".to_string()),
1037
      package_url: Some("https://example.com/OldMod".to_string()),
1038
      date_created: OffsetDateTime::now_utc(),
1039
      date_updated: OffsetDateTime::now_utc(),
1040
      uuid4: Some("old-uuid".to_string()),
1041
      rating_score: Some(4),
1042
      is_pinned: Some(false),
1043
      is_deprecated: Some(false),
1044
      has_nsfw_content: Some(false),
1045
      categories: vec!["legacy".to_string()],
1046
      versions: vec![],
1047
    };
1048

1049
    let packages = vec![package];
1050
    let binary_data = bincode::serialize(&packages).unwrap();
1051
    let compressed_data = zstd::encode_all(binary_data.as_slice(), 9).unwrap();
1052

1053
    std::fs::create_dir_all(PathBuf::from(temp_dir_str)).unwrap();
1054

1055
    let mut v1_path = PathBuf::from(temp_dir_str);
1056
    v1_path.push(API_MANIFEST_FILENAME_V1);
1057
    let mut file = File::create(&v1_path).unwrap();
1058
    file.write_all(&compressed_data).unwrap();
1059

1060
    let v2_path = PathBuf::from(temp_dir_str).join(API_MANIFEST_FILENAME_V3);
1061
    assert!(!v2_path.exists());
1062
    assert!(v1_path.exists());
1063

1064
    let rt = Runtime::new().unwrap();
1065
    let result = rt.block_on(get_manifest_from_disk(temp_dir_str));
1066

1067
    assert!(result.is_ok());
1068
    let manifest = result.unwrap();
1069
    assert_eq!(manifest.len(), 1);
1070
    assert_eq!(manifest.resolve_full_name_at(0), Some("OldOwner-OldMod".to_string()));
1071

1072
    assert!(v2_path.exists(), "v3 manifest should be created");
1073
    assert!(!v1_path.exists(), "v1 manifest should be removed");
1074
  }
1075

1076
  #[test]
1077
  fn test_get_manifest_from_network_and_cache() {
1078
    let mut server = Server::new();
1079

1080
    let test_json = r#"[
1081
      {
1082
        "name": "ModA",
1083
        "full_name": "Owner-ModA",
1084
        "owner": "Owner",
1085
        "package_url": "https://example.com/mods/ModA",
1086
        "date_created": "2024-01-01T12:00:00Z",
1087
        "date_updated": "2024-01-02T12:00:00Z",
1088
        "uuid4": "test-uuid",
1089
        "rating_score": 5,
1090
        "is_pinned": false,
1091
        "is_deprecated": false,
1092
        "has_nsfw_content": false,
1093
        "categories": ["category1"],
1094
        "versions": [
1095
          {
1096
            "name": "ModA",
1097
            "full_name": "Owner-ModA",
1098
            "description": "Test description",
1099
            "icon": "icon.png",
1100
            "version_number": "1.0.0",
1101
            "dependencies": [],
1102
            "download_url": "https://example.com/mods/ModA/download",
1103
            "downloads": 100,
1104
            "date_created": "2024-01-01T12:00:00Z",
1105
            "website_url": "https://example.com",
1106
            "is_active": true,
1107
            "uuid4": "test-version-uuid",
1108
            "file_size": 1024
1109
          }
1110
        ]
1111
      }
1112
    ]"#;
1113

1114
    let test_date = "Wed, 21 Feb 2024 15:30:45 GMT";
1115

1116
    let mock = server
1117
      .mock("GET", "/c/valheim/api/v1/package/")
1118
      .with_status(200)
1119
      .with_header("Content-Type", "application/json")
1120
      .with_header("Last-Modified", test_date)
1121
      .with_body(test_json)
1122
      .create();
1123

1124
    let api_url = format!("{}/c/valheim/api/v1/package/", server.url());
1125
    let temp_dir = tempdir().unwrap();
1126
    let temp_dir_str = temp_dir.path().to_str().unwrap();
1127

1128
    let rt = Runtime::new().unwrap();
1129
    let result = rt.block_on(get_manifest_from_network_and_cache(temp_dir_str, &api_url));
1130

1131
    assert!(result.is_ok());
1132
    if let Ok(manifest) = result {
1133
      assert_eq!(manifest.len(), 1);
1134
      assert_eq!(manifest.resolve_full_name_at(0), Some("Owner-ModA".to_string()));
1135

1136
      assert!(last_modified_path(temp_dir_str).exists());
1137
      assert!(api_manifest_path_v3(temp_dir_str).exists());
1138

1139
      let last_mod_content = std::fs::read_to_string(last_modified_path(temp_dir_str)).unwrap();
1140
      assert!(last_mod_content.contains("21 Feb 2024 15:30:45"));
1141
    }
1142

1143
    mock.assert();
1144
  }
1145

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

1152
    let test_data = b"This is test file content";
1153
    let content_length = test_data.len();
1154

1155
    let _mock = server
1156
      .mock("GET", "/test-file.zip")
1157
      .with_status(200)
1158
      .with_header("Content-Type", "application/zip")
1159
      .with_header("Content-Length", &content_length.to_string())
1160
      .with_body(test_data)
1161
      .create();
1162

1163
    let file_url = format!("{}/test-file.zip", server.url());
1164
    let filename = "test-file.zip".to_string();
1165

1166
    let multi_progress = MultiProgress::new();
1167
    let style_template = "";
1168
    let progress_style = ProgressStyle::with_template(style_template)
1169
      .unwrap()
1170
      .progress_chars("#>-");
1171

1172
    let rt = Runtime::new().unwrap();
1173
    let client = rt.block_on(async {
1174
      reqwest::Client::builder()
1175
        .timeout(Duration::from_secs(60))
1176
        .build()
1177
        .unwrap()
1178
    });
1179

1180
    let result = rt.block_on(download_file(
1181
      client.clone(),
1182
      &file_url,
1183
      &filename,
1184
      multi_progress.clone(),
1185
      progress_style.clone(),
1186
      temp_dir_str,
1187
    ));
1188

1189
    assert!(result.is_ok());
1190

1191
    let downloads_dir = PathBuf::from(temp_dir_str).join("downloads");
1192
    assert!(downloads_dir.exists());
1193

1194
    let downloaded_file = downloads_dir.join(&filename);
1195
    assert!(downloaded_file.exists());
1196

1197
    let file_content = std::fs::read(&downloaded_file).unwrap();
1198
    assert_eq!(file_content, test_data);
1199

1200
    let result2 = rt.block_on(download_file(
1201
      client.clone(),
1202
      &file_url,
1203
      &filename,
1204
      multi_progress.clone(),
1205
      progress_style.clone(),
1206
      temp_dir_str,
1207
    ));
1208

1209
    assert!(result2.is_ok());
1210
  }
1211

1212
  #[test]
1213
  fn test_download_file_missing_header() {
1214
    let mut server = Server::new();
1215
    let temp_dir = tempdir().unwrap();
1216
    let temp_dir_str = temp_dir.path().to_str().unwrap();
1217

1218
    let test_data = b"This is test file content";
1219

1220
    let mock = server
1221
      .mock("GET", "/test-file.zip")
1222
      .with_status(200)
1223
      .with_header("Content-Type", "application/zip")
1224
      .with_body(test_data)
1225
      .create();
1226

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

1230
    let multi_progress = MultiProgress::new();
1231
    let style_template = "";
1232
    let progress_style = ProgressStyle::with_template(style_template)
1233
      .unwrap()
1234
      .progress_chars("#>-");
1235

1236
    let rt = Runtime::new().unwrap();
1237
    let client = rt.block_on(async {
1238
      reqwest::Client::builder()
1239
        .timeout(Duration::from_secs(60))
1240
        .build()
1241
        .unwrap()
1242
    });
1243

1244
    let _result = rt.block_on(download_file(
1245
      client.clone(),
1246
      &file_url,
1247
      &filename,
1248
      multi_progress.clone(),
1249
      progress_style.clone(),
1250
      temp_dir_str,
1251
    ));
1252

1253
    mock.assert();
1254
  }
1255

1256
  #[test]
1257
  fn test_download_files() {
1258
    let mut server = Server::new();
1259
    let temp_dir = tempdir().unwrap();
1260
    let temp_dir_str = temp_dir.path().to_str().unwrap();
1261

1262
    let test_data1 = b"This is test file 1 content";
1263
    let content_length1 = test_data1.len();
1264

1265
    let test_data2 = b"This is test file 2 content - longer content";
1266
    let content_length2 = test_data2.len();
1267

1268
    let mock1 = server
1269
      .mock("GET", "/file1.zip")
1270
      .with_status(200)
1271
      .with_header("Content-Type", "application/zip")
1272
      .with_header("Content-Length", &content_length1.to_string())
1273
      .with_body(test_data1)
1274
      .create();
1275

1276
    let mock2 = server
1277
      .mock("GET", "/file2.zip")
1278
      .with_status(200)
1279
      .with_header("Content-Type", "application/zip")
1280
      .with_header("Content-Length", &content_length2.to_string())
1281
      .with_body(test_data2)
1282
      .create();
1283

1284
    let mut urls = HashMap::new();
1285
    urls.insert(
1286
      "file1.zip".to_string(),
1287
      format!("{}/file1.zip", server.url()),
1288
    );
1289
    urls.insert(
1290
      "file2.zip".to_string(),
1291
      format!("{}/file2.zip", server.url()),
1292
    );
1293

1294
    let rt = Runtime::new().unwrap();
1295
    let result = rt.block_on(download_files(urls, temp_dir_str));
1296

1297
    assert!(result.is_ok());
1298

1299
    let downloads_dir = PathBuf::from(temp_dir_str).join("downloads");
1300
    assert!(downloads_dir.exists());
1301

1302
    let file1 = downloads_dir.join("file1.zip");
1303
    let file2 = downloads_dir.join("file2.zip");
1304
    assert!(file1.exists());
1305
    assert!(file2.exists());
1306

1307
    let file1_content = std::fs::read(&file1).unwrap();
1308
    let file2_content = std::fs::read(&file2).unwrap();
1309
    assert_eq!(file1_content, test_data1);
1310
    assert_eq!(file2_content, test_data2);
1311

1312
    mock1.assert();
1313
    mock2.assert();
1314
  }
1315

1316
  #[test]
1317
  fn test_download_files_empty() {
1318
    let temp_dir = tempdir().unwrap();
1319
    let temp_dir_str = temp_dir.path().to_str().unwrap();
1320

1321
    let urls = HashMap::new();
1322

1323
    let rt = Runtime::new().unwrap();
1324
    let result = rt.block_on(download_files(urls, temp_dir_str));
1325

1326
    assert!(result.is_ok());
1327
  }
1328
}
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