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

endoze / valheim-mod-manager / 19995428889

06 Dec 2025 10:55PM UTC coverage: 92.977% (+0.5%) from 92.43%
19995428889

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%)

6 existing lines in 1 file now uncovered.

887 of 954 relevant lines covered (92.98%)

3.82 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
fn last_modified_path(cache_dir: &str) -> PathBuf {
2✔
23
  let expanded_path = shellexpand::tilde(cache_dir);
2✔
24
  let mut path = PathBuf::from(expanded_path.as_ref());
4✔
25
  path.push(LAST_MODIFIED_FILENAME);
4✔
26
  path
4✔
27
}
28

29
fn api_manifest_path_v3(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_V3);
2✔
33
  path
2✔
34
}
35

36
fn api_manifest_path_v2(cache_dir: &str) -> PathBuf {
2✔
37
  let expanded_path = shellexpand::tilde(cache_dir);
2✔
38
  let mut path = PathBuf::from(expanded_path.as_ref());
4✔
39
  path.push(API_MANIFEST_FILENAME_V2);
2✔
40
  path
2✔
41
}
42

43
fn api_manifest_path_v1(cache_dir: &str) -> PathBuf {
2✔
44
  let expanded_path = shellexpand::tilde(cache_dir);
2✔
45
  let mut path = PathBuf::from(expanded_path.as_ref());
4✔
46
  path.push(API_MANIFEST_FILENAME_V1);
2✔
47
  path
2✔
48
}
49

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

79
  if api_manifest_file_exists(cache_dir) && network_last_modified(api_url).await? <= last_modified {
8✔
80
    tracing::info!("Loading manifest from cache");
6✔
81
    get_manifest_from_disk(cache_dir).await
6✔
82
  } else {
83
    tracing::info!("Downloading new manifest");
×
84
    get_manifest_from_network_and_cache(cache_dir, api_url).await
×
85
  }
86
}
87

88
/// Reads the cached API manifest from disk.
89
///
90
/// Attempts to read formats in order: v3 (interned), v2 (SoA), v1 (Vec<Package>).
91
/// When older formats are detected, they are automatically migrated to v3.
92
///
93
/// # Returns
94
///
95
/// The deserialized manifest as an InternedPackageManifest.
96
///
97
/// # Errors
98
///
99
/// Returns an error if:
100
/// - The file cannot be opened or read
101
/// - The data cannot be decompressed or deserialized
102
async fn get_manifest_from_disk(cache_dir: &str) -> AppResult<InternedPackageManifest> {
8✔
103
  let path_v3 = api_manifest_path_v3(cache_dir);
2✔
104
  let path_v2 = api_manifest_path_v2(cache_dir);
2✔
105
  let path_v1 = api_manifest_path_v1(cache_dir);
2✔
106

107
  if path_v3.exists() {
4✔
108
    tracing::debug!("Loading v3 manifest format");
6✔
109

110
    let mut file = fs::File::open(&path_v3).await?;
6✔
111
    let mut compressed_data = Vec::new();
2✔
112
    file.read_to_end(&mut compressed_data).await?;
8✔
113

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

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

120
    let manifest: InternedPackageManifest = serializable.into();
4✔
121

122
    manifest
4✔
123
      .validate()
NEW
124
      .map_err(|e| AppError::Manifest(format!("V3 manifest validation failed: {}", e)))?;
×
125

126
    return Ok(manifest);
2✔
127
  }
128

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

UNCOV
132
    let mut file = fs::File::open(&path_v2).await?;
×
UNCOV
133
    let mut compressed_data = Vec::new();
×
UNCOV
134
    file.read_to_end(&mut compressed_data).await?;
×
135

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

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

NEW
142
    let manifest: InternedPackageManifest = v2_manifest.into();
×
143

NEW
144
    manifest.validate().map_err(|e| {
×
NEW
145
      AppError::Manifest(format!(
×
146
        "V3 manifest validation failed during migration: {}",
147
        e
148
      ))
149
    })?;
150

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

NEW
155
    write_cache_to_disk(path_v3.clone(), &binary_data, true).await?;
×
156

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

NEW
161
        if let Err(e) = fs::remove_file(&path_v2).await {
×
NEW
162
          tracing::warn!(
×
163
            "Failed to remove old v2 manifest (keeping as backup): {}",
164
            e
165
          );
166
        }
167
      }
168
      Ok(_) => {
NEW
169
        tracing::error!("V3 manifest written but is empty, keeping v2 as backup");
×
170
      }
NEW
171
      Err(e) => {
×
NEW
172
        tracing::error!(
×
173
          "Failed to verify v3 manifest write: {}, keeping v2 as backup",
174
          e
175
        );
176
      }
177
    }
178

NEW
179
    return Ok(manifest);
×
180
  }
181

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

185
    let mut file = fs::File::open(&path_v1).await?;
6✔
186
    let mut compressed_data = Vec::new();
2✔
187
    file.read_to_end(&mut compressed_data).await?;
8✔
188

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

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

195
    let manifest: InternedPackageManifest = packages.into();
4✔
196

197
    manifest.validate().map_err(|e| {
4✔
NEW
198
      AppError::Manifest(format!(
×
199
        "V3 manifest validation failed during migration: {}",
200
        e
201
      ))
202
    })?;
203

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

208
    write_cache_to_disk(path_v3.clone(), &binary_data, true).await?;
5✔
209

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

214
        if let Err(e) = fs::remove_file(&path_v1).await {
6✔
NEW
215
          tracing::warn!(
×
216
            "Failed to remove old v1 manifest (keeping as backup): {}",
217
            e
218
          );
219
        }
220
      }
221
      Ok(_) => {
NEW
222
        tracing::error!("V3 manifest written but is empty, keeping v1 as backup");
×
223
      }
NEW
224
      Err(e) => {
×
NEW
225
        tracing::error!(
×
226
          "Failed to verify v3 manifest write: {}, keeping v1 as backup",
227
          e
228
        );
229
      }
230
    }
231

232
    return Ok(manifest);
2✔
233
  }
234

NEW
235
  Err(AppError::Manifest("No cached manifest found".to_string()))
×
236
}
237

238
/// Downloads the manifest from the API server and filters out unnecessary fields.
239
///
240
/// This function shows a progress bar while downloading the manifest.
241
/// It parses the downloaded data to filter out fields marked with #[serde(skip_deserializing)],
242
/// which helps reduce the size of stored data on disk.
243
///
244
/// # Parameters
245
///
246
/// * `api_url` - The API URL to download the manifest from
247
///
248
/// # Returns
249
///
250
/// A tuple containing:
251
/// - The filtered manifest data as a vector of Packages (with unnecessary fields removed)
252
/// - The last modified date from the response headers
253
///
254
/// # Errors
255
///
256
/// Returns an error if:
257
/// - Network request fails
258
/// - Response headers cannot be parsed
259
/// - Last modified date cannot be parsed
260
/// - JSON parsing or serialization fails
261
async fn get_manifest_from_network(
2✔
262
  api_url: &str,
263
) -> AppResult<(Vec<Package>, DateTime<FixedOffset>)> {
264
  let client = Client::builder().build()?;
4✔
265

266
  let multi_progress = MultiProgress::new();
4✔
267
  let style_template = "";
2✔
268
  let progress_style = ProgressStyle::with_template(style_template)
4✔
269
    .unwrap()
270
    .tick_strings(&["-", "\\", "|", "/", ""]);
2✔
271

272
  let progress_bar = multi_progress.add(ProgressBar::new(100000));
4✔
273
  progress_bar.set_style(progress_style);
2✔
274
  progress_bar.set_message("Downloading Api Manifest");
2✔
275
  progress_bar.enable_steady_tick(Duration::from_millis(130));
2✔
276

277
  let response = client.get(api_url).send().await?;
6✔
278
  let last_modified_str = response
9✔
279
    .headers()
280
    .get(header::LAST_MODIFIED)
281
    .ok_or(AppError::MissingHeader("Last-Modified".to_string()))?
3✔
282
    .to_str()?;
283
  let last_modified = DateTime::parse_from_rfc2822(last_modified_str)?;
6✔
284

285
  let raw_response_data = response.bytes().await?;
6✔
286

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

289
  progress_bar.finish_with_message(" Downloaded Api Manifest");
3✔
290

291
  Ok((packages, last_modified))
3✔
292
}
293

294
/// Downloads the manifest from the network and caches it locally.
295
///
296
/// This function retrieves the manifest from the API server and saves both
297
/// the manifest content and the last modified date to disk.
298
/// The manifest is converted to the interned V3 format before caching.
299
///
300
/// # Parameters
301
///
302
/// * `cache_dir` - The directory to store cache files in
303
/// * `api_url` - The API URL to download the manifest from
304
///
305
/// # Returns
306
///
307
/// The manifest data as InternedPackageManifest.
308
///
309
/// # Errors
310
///
311
/// Returns an error if:
312
/// - Network request fails
313
/// - Writing cache files fails
314
async fn get_manifest_from_network_and_cache(
2✔
315
  cache_dir: &str,
316
  api_url: &str,
317
) -> AppResult<InternedPackageManifest> {
318
  let results = get_manifest_from_network(api_url).await?;
6✔
319
  let packages = results.0;
2✔
320
  let last_modified_from_network = results.1;
2✔
321

322
  write_cache_to_disk(
323
    last_modified_path(cache_dir),
4✔
324
    last_modified_from_network.to_rfc2822().as_bytes(),
4✔
325
    false,
326
  )
327
  .await?;
10✔
328

329
  let manifest: InternedPackageManifest = packages.into();
4✔
330
  let serializable: SerializableInternedManifest = (&manifest).into();
2✔
331
  let binary_data = bincode::serialize(&serializable)
4✔
UNCOV
332
    .map_err(|e| AppError::Manifest(format!("Failed to serialize manifest: {}", e)))?;
×
333

334
  write_cache_to_disk(api_manifest_path_v3(cache_dir), &binary_data, true).await?;
6✔
335

336
  Ok(manifest)
2✔
337
}
338

339
/// Retrieves the last modified date from the local cache file.
340
///
341
/// If the file doesn't exist, returns a default date (epoch time).
342
///
343
/// # Returns
344
///
345
/// The last modified date as a `DateTime<FixedOffset>`.
346
///
347
/// # Errors
348
///
349
/// Returns an error if:
350
/// - The file exists but cannot be read
351
/// - The date string cannot be parsed
352
async fn local_last_modified(cache_dir: &str) -> AppResult<DateTime<FixedOffset>> {
8✔
353
  let path = last_modified_path(cache_dir);
2✔
354

355
  if let Ok(mut file) = fs::File::open(&path).await {
6✔
356
    tracing::info!("Last modified file exists and was opened.");
6✔
357
    let mut contents = String::new();
2✔
358
    file.read_to_string(&mut contents).await?;
6✔
359

360
    let last_modified = DateTime::parse_from_rfc2822(&contents)?;
4✔
361

362
    Ok(last_modified)
2✔
363
  } else {
364
    tracing::info!("Last modified file does not exist and was not opened.");
6✔
365
    let dt = DateTime::from_timestamp(0, 0).unwrap().naive_utc();
4✔
366
    let offset = FixedOffset::east_opt(0).unwrap();
4✔
367

368
    let last_modified = DateTime::<FixedOffset>::from_naive_utc_and_offset(dt, offset);
2✔
369
    Ok(last_modified)
2✔
370
  }
371
}
372

373
/// Retrieves the last modified date from the API server.
374
///
375
/// Makes a HEAD request to the API endpoint to check when the manifest was last updated.
376
///
377
/// # Parameters
378
///
379
/// * `api_url` - The API URL to check
380
///
381
/// # Returns
382
///
383
/// The last modified date from the server as a `DateTime<FixedOffset>`.
384
///
385
/// # Errors
386
///
387
/// Returns an error if:
388
/// - Network request fails
389
/// - Last-Modified header is missing
390
/// - Date string cannot be parsed
391
async fn network_last_modified(api_url: &str) -> AppResult<DateTime<FixedOffset>> {
8✔
392
  let client = Client::builder().build()?;
4✔
393

394
  let response = client.head(api_url).send().await?;
6✔
395

396
  let last_modified =
6✔
397
    response
398
      .headers()
399
      .get(header::LAST_MODIFIED)
400
      .ok_or(AppError::MissingHeader(
2✔
401
        "Last-Modified for API manifest head request".to_string(),
2✔
402
      ))?;
403
  let last_modified_date = DateTime::parse_from_rfc2822(last_modified.to_str()?)?;
4✔
404

405
  Ok(last_modified_date)
2✔
406
}
407

408
/// Checks if the cached manifest file exists.
409
///
410
/// # Returns
411
///
412
/// `true` if any api_manifest file exists, `false` otherwise.
413
fn api_manifest_file_exists(cache_dir: &str) -> bool {
2✔
414
  api_manifest_path_v3(cache_dir).exists()
6✔
415
    || api_manifest_path_v2(cache_dir).exists()
2✔
416
    || api_manifest_path_v1(cache_dir).exists()
2✔
417
}
418

419
/// Writes data to a cache file on disk.
420
///
421
/// Creates the parent directory if it doesn't exist, then the file if it doesn't exist,
422
/// then writes the provided contents to it.
423
///
424
/// # Parameters
425
///
426
/// * `path` - The path to the cache file
427
/// * `contents` - The data to write to the file
428
/// * `use_compression` - If true, the data will be compressed with zstd level 9
429
///   (balanced compression ratio for SoA structure's repetitive patterns)
430
///
431
/// # Errors
432
///
433
/// Returns an error if:
434
/// - Directory creation fails
435
/// - File creation fails
436
/// - Opening the file for writing fails
437
/// - Writing to the file fails
438
/// - Compression fails
439
async fn write_cache_to_disk<T: AsRef<Path>>(
4✔
440
  path: T,
441
  contents: &[u8],
442
  use_compression: bool,
443
) -> AppResult<()> {
444
  if let Some(parent) = path.as_ref().parent() {
13✔
445
    fs::create_dir_all(parent).await?;
13✔
446
  }
447

448
  if !path.as_ref().exists() {
10✔
449
    fs::File::create(&path).await?;
17✔
450
  }
451

452
  let mut file = fs::OpenOptions::new()
33✔
453
    .write(true)
454
    .truncate(true)
455
    .open(&path)
5✔
456
    .await?;
21✔
457

458
  if use_compression {
11✔
459
    let compressed_data = zstd::encode_all(contents, 9)
8✔
460
      .map_err(|e| AppError::Manifest(format!("Failed to compress data: {}", e)))?;
×
461

462
    file.write_all(&compressed_data).await?;
14✔
463
    file.flush().await?;
15✔
464
  } else {
465
    file.write_all(contents).await?;
16✔
466
    file.flush().await?;
18✔
467
  }
468

469
  Ok(())
5✔
470
}
471

472
/// Downloads multiple files concurrently with progress indicators.
473
/// Skips files that already exist with the correct version.
474
///
475
/// # Parameters
476
///
477
/// * `urls` - A HashMap where keys are filenames and values are the URLs to download from
478
/// * `cache_dir` - The app's cache directory
479
///
480
/// # Notes
481
///
482
/// - Files are downloaded in parallel with a limit of 2 concurrent downloads
483
/// - Progress bars show download status for each file
484
/// - Files are saved in the 'cache_dir/downloads' directory
485
/// - Files that already exist with the correct version are skipped
486
pub async fn download_files(urls: HashMap<String, String>, cache_dir: &str) -> AppResult<()> {
8✔
487
  if urls.is_empty() {
4✔
488
    tracing::debug!("No files to download");
6✔
489

490
    return Ok(());
2✔
491
  }
492

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

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

497
  let multi_progress = MultiProgress::new();
4✔
498
  let style_template = "";
2✔
499
  let progress_style = ProgressStyle::with_template(style_template)
4✔
500
    .unwrap()
501
    .progress_chars("#>-");
502

503
  let futures: FuturesUnordered<_> = urls
4✔
504
    .into_iter()
505
    .map(|(archive_filename, url)| {
6✔
506
      let client = client.clone();
4✔
507
      let multi_progress = multi_progress.clone();
4✔
508
      let progress_style = progress_style.clone();
4✔
509

510
      let cache_dir = cache_dir.to_string();
2✔
511
      tokio::spawn(async move {
6✔
512
        download_file(
6✔
513
          client,
2✔
514
          &url,
2✔
515
          &archive_filename,
2✔
516
          multi_progress,
2✔
517
          progress_style,
2✔
518
          &cache_dir,
2✔
519
        )
520
        .await
10✔
521
      })
522
    })
523
    .collect();
524

525
  let responses = futures::stream::iter(futures)
6✔
526
    .buffer_unordered(2)
527
    .collect::<Vec<_>>()
528
    .await;
8✔
529

530
  for response in responses {
6✔
531
    match response {
4✔
532
      Ok(Ok(_)) => {}
533
      Ok(Err(err)) => {
×
534
        tracing::error!("Download error: {:?}", err);
×
535
      }
536
      Err(err) => {
×
537
        return Err(AppError::TaskFailed(err));
×
538
      }
539
    }
540
  }
541

542
  Ok(())
2✔
543
}
544

545
/// Downloads a single file from a URL with a progress indicator.
546
/// Checks if the file already exists before downloading to avoid duplicate downloads.
547
///
548
/// # Parameters
549
///
550
/// * `client` - The HTTP client to use for the download
551
/// * `url` - The URL to download from
552
/// * `filename` - The name to save the file as
553
/// * `multi_progress` - The multi-progress display for coordinating multiple progress bars
554
/// * `progress_style` - The style to use for the progress bar
555
/// * `cache_dir` - The app's cache directory
556
///
557
/// # Returns
558
///
559
/// `Ok(())` on successful download or if file already exists.
560
///
561
/// # Errors
562
///
563
/// Returns an error if:
564
/// - Network request fails
565
/// - Content-Length header is missing
566
/// - Creating directories or files fails
567
/// - Writing to the file fails
568
async fn download_file(
2✔
569
  client: reqwest::Client,
570
  url: &String,
571
  filename: &String,
572
  multi_progress: MultiProgress,
573
  progress_style: ProgressStyle,
574
  cache_dir: &str,
575
) -> AppResult<()> {
576
  let expanded_path = shellexpand::tilde(cache_dir);
3✔
577
  let mut downloads_directory = PathBuf::from(expanded_path.as_ref());
7✔
578
  downloads_directory.push("downloads");
3✔
579
  let mut file_path = downloads_directory.clone();
6✔
580
  file_path.push(filename);
3✔
581

582
  tokio::fs::DirBuilder::new()
15✔
583
    .recursive(true)
584
    .create(&downloads_directory)
3✔
585
    .await?;
12✔
586

587
  if file_path.exists() {
5✔
588
    tracing::debug!("{} already exists, skipping download", filename);
6✔
589

590
    return Ok(());
2✔
591
  }
592

593
  let response = client.get(url).send().await?;
7✔
594
  let content_length = response
7✔
595
    .headers()
596
    .get(header::CONTENT_LENGTH)
597
    .ok_or(AppError::MissingHeader(format!(
6✔
598
      "Content-Length header for {}",
599
      url
600
    )))?
601
    .to_str()?
602
    .parse::<u64>()?;
603
  let mut response_data = response.bytes_stream();
6✔
604

605
  let progress_bar = multi_progress.add(ProgressBar::new(content_length));
6✔
606
  progress_bar.set_style(progress_style);
3✔
607
  progress_bar.set_message(filename.clone());
3✔
608

609
  let mut file = tokio::fs::File::create(file_path).await?;
6✔
610

611
  while let Some(result) = response_data.next().await {
12✔
612
    let chunk = result?;
6✔
613
    file.write_all(&chunk).await?;
12✔
614
    progress_bar.inc(chunk.len() as u64);
6✔
615
  }
616

617
  file.flush().await?;
8✔
618
  file.sync_all().await?;
9✔
619

620
  progress_bar.finish();
3✔
621

622
  Ok(())
3✔
623
}
624

625
#[cfg(test)]
626
mod tests {
627
  use super::*;
628
  use mockito::Server;
629
  use std::fs::File;
630
  use std::io::Write;
631
  use tempfile::tempdir;
632
  use time::OffsetDateTime;
633
  use tokio::runtime::Runtime;
634

635
  #[test]
636
  fn test_downloads_directory_construction() {
637
    let temp_dir = tempdir().unwrap();
638
    let cache_dir = temp_dir.path().to_str().unwrap();
639

640
    let expanded_path = shellexpand::tilde(cache_dir);
641
    let mut downloads_directory = PathBuf::from(expanded_path.as_ref());
642
    downloads_directory.push("downloads");
643

644
    let expected_directory = PathBuf::from(cache_dir).join("downloads");
645
    assert_eq!(downloads_directory, expected_directory);
646
  }
647

648
  #[test]
649
  fn test_api_manifest_file_exists() {
650
    let temp_dir = tempdir().unwrap();
651
    let temp_dir_str = temp_dir.path().to_str().unwrap();
652

653
    assert!(!api_manifest_file_exists(temp_dir_str));
654

655
    let mut path = PathBuf::from(temp_dir_str);
656
    path.push(API_MANIFEST_FILENAME_V3);
657
    let _ = File::create(path).unwrap();
658

659
    assert!(api_manifest_file_exists(temp_dir_str));
660
  }
661

662
  #[test]
663
  fn test_path_with_tilde() {
664
    let home_path = "~/some_test_dir";
665
    let home_dir = std::env::var("HOME").unwrap_or_else(|_| "/home/user".to_string());
666
    let expected_path = Path::new(&home_dir).join("some_test_dir");
667

668
    let last_modified = last_modified_path(home_path);
669
    let api_manifest = api_manifest_path_v3(home_path);
670

671
    assert_eq!(last_modified.parent().unwrap(), expected_path);
672
    assert_eq!(api_manifest.parent().unwrap(), expected_path);
673
  }
674

675
  #[test]
676
  fn test_write_cache_to_disk() {
677
    let temp_dir = tempdir().unwrap();
678
    let cache_path = temp_dir.path().join("test_cache.txt");
679
    let test_data = b"Test cache data";
680

681
    let rt = Runtime::new().unwrap();
682
    let result = rt.block_on(write_cache_to_disk(&cache_path, test_data, false));
683

684
    assert!(result.is_ok());
685
    assert!(cache_path.exists());
686

687
    let content = std::fs::read(&cache_path).unwrap();
688
    assert_eq!(content, test_data);
689

690
    let new_data = b"Updated cache data";
691
    let result = rt.block_on(write_cache_to_disk(&cache_path, new_data, false));
692

693
    assert!(result.is_ok());
694
    let content = std::fs::read(&cache_path).unwrap();
695

696
    assert_eq!(content, new_data);
697
  }
698

699
  #[test]
700
  fn test_write_cache_to_disk_creates_directories() {
701
    let temp_dir = tempdir().unwrap();
702

703
    let nonexistent_subdir = temp_dir.path().join("subdir1/subdir2");
704
    let cache_path = nonexistent_subdir.join("test_cache.txt");
705

706
    if nonexistent_subdir.exists() {
707
      std::fs::remove_dir_all(&nonexistent_subdir).unwrap();
708
    }
709

710
    assert!(!nonexistent_subdir.exists());
711
    assert!(!cache_path.exists());
712

713
    let test_data = b"Test cache data";
714
    let rt = Runtime::new().unwrap();
715

716
    let result = rt.block_on(write_cache_to_disk(&cache_path, test_data, false));
717
    assert!(result.is_ok());
718

719
    assert!(
720
      nonexistent_subdir.exists(),
721
      "Directory structure should be created"
722
    );
723
    assert!(cache_path.exists(), "File should be created");
724

725
    let content = std::fs::read(&cache_path).unwrap();
726
    assert_eq!(content, test_data, "File should contain the expected data");
727
  }
728

729
  #[test]
730
  fn test_write_cache_to_disk_with_compression() {
731
    let temp_dir = tempdir().unwrap();
732
    let api_manifest_path = temp_dir.path().join("test_compressed.bin");
733

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

750
    let packages = vec![package];
751
    let manifest: PackageManifest = packages.into();
752
    let binary_data = bincode::serialize(&manifest).unwrap();
753

754
    let rt = Runtime::new().unwrap();
755
    let result = rt.block_on(write_cache_to_disk(&api_manifest_path, &binary_data, true));
756

757
    assert!(result.is_ok());
758
    assert!(api_manifest_path.exists());
759

760
    let compressed_data = std::fs::read(&api_manifest_path).unwrap();
761
    let decompressed_data = zstd::decode_all(compressed_data.as_slice()).unwrap();
762
    let decoded_manifest: PackageManifest = bincode::deserialize(&decompressed_data).unwrap();
763

764
    assert_eq!(decoded_manifest.len(), 1);
765
    assert_eq!(
766
      decoded_manifest.full_names[0],
767
      Some("TestOwner-TestMod".to_string())
768
    );
769
  }
770

771
  #[test]
772
  fn test_local_last_modified() {
773
    let temp_dir = tempdir().unwrap();
774
    let temp_dir_str = temp_dir.path().to_str().unwrap();
775
    let rt = Runtime::new().unwrap();
776

777
    {
778
      let result = rt.block_on(local_last_modified(temp_dir_str));
779

780
      assert!(result.is_ok());
781
    }
782

783
    let test_date = "Wed, 21 Feb 2024 15:30:45 GMT";
784
    let mut path = PathBuf::from(temp_dir_str);
785
    path.push(LAST_MODIFIED_FILENAME);
786
    let mut file = File::create(path).unwrap();
787
    file.write_all(test_date.as_bytes()).unwrap();
788

789
    {
790
      let result = rt.block_on(local_last_modified(temp_dir_str));
791

792
      assert!(result.is_ok());
793
    }
794
  }
795

796
  #[test]
797
  fn test_get_manifest_from_disk() {
798
    let temp_dir = tempdir().unwrap();
799
    let temp_dir_str = temp_dir.path().to_str().unwrap();
800

801
    let package = Package {
802
      name: Some("ModA".to_string()),
803
      full_name: Some("Owner-ModA".to_string()),
804
      owner: Some("Owner".to_string()),
805
      package_url: Some("https://example.com/ModA".to_string()),
806
      date_created: OffsetDateTime::now_utc(),
807
      date_updated: OffsetDateTime::now_utc(),
808
      uuid4: Some("test-uuid".to_string()),
809
      rating_score: Some(5),
810
      is_pinned: Some(false),
811
      is_deprecated: Some(false),
812
      has_nsfw_content: Some(false),
813
      categories: vec!["test".to_string()],
814
      versions: vec![],
815
    };
816

817
    let packages = vec![package];
818
    let interned_manifest: InternedPackageManifest = packages.into();
819
    let serializable: SerializableInternedManifest = (&interned_manifest).into();
820
    let binary_data = bincode::serialize(&serializable).unwrap();
821
    let compressed_data = zstd::encode_all(binary_data.as_slice(), 9).unwrap();
822
    let mut path = PathBuf::from(temp_dir_str);
823
    path.push(API_MANIFEST_FILENAME_V3);
824
    let mut file = File::create(path).unwrap();
825
    file.write_all(&compressed_data).unwrap();
826

827
    let rt = Runtime::new().unwrap();
828
    let manifest = rt.block_on(get_manifest_from_disk(temp_dir_str)).unwrap();
829

830
    assert_eq!(manifest.len(), 1);
831
    assert_eq!(manifest.resolve_full_name_at(0), Some("Owner-ModA".to_string()));
832
  }
833

834
  #[test]
835
  fn test_get_manifest_from_disk_error() {
836
    let temp_dir = tempdir().unwrap();
837
    let temp_dir_str = temp_dir.path().to_str().unwrap();
838

839
    let mut path = PathBuf::from(temp_dir_str);
840
    path.push(API_MANIFEST_FILENAME_V3);
841
    let mut file = File::create(path).unwrap();
842
    file
843
      .write_all(b"This is not valid compressed data")
844
      .unwrap();
845

846
    let rt = Runtime::new().unwrap();
847
    let result = rt.block_on(get_manifest_from_disk(temp_dir_str));
848

849
    assert!(result.is_err());
850
  }
851

852
  #[test]
853
  fn test_network_last_modified() {
854
    let mut server = Server::new();
855
    let test_date = "Wed, 21 Feb 2024 15:30:45 GMT";
856
    let mock = server
857
      .mock("HEAD", "/c/valheim/api/v1/package/")
858
      .with_status(200)
859
      .with_header("Last-Modified", test_date)
860
      .create();
861
    let api_url = format!("{}/c/valheim/api/v1/package/", server.url());
862
    let rt = Runtime::new().unwrap();
863

864
    let result = rt.block_on(network_last_modified(&api_url));
865

866
    assert!(result.is_ok());
867
    if let Ok(parsed_date) = result {
868
      let expected_date = DateTime::parse_from_rfc2822(test_date).unwrap();
869
      assert_eq!(parsed_date, expected_date);
870
    }
871

872
    mock.assert();
873
  }
874

875
  #[test]
876
  fn test_get_manifest_from_network() {
877
    let mut server = Server::new();
878

879
    let test_json = r#"[
880
      {
881
        "name": "ModA",
882
        "full_name": "Owner-ModA",
883
        "owner": "Owner",
884
        "package_url": "https://example.com/mods/ModA",
885
        "date_created": "2024-01-01T12:00:00Z",
886
        "date_updated": "2024-01-02T12:00:00Z",
887
        "uuid4": "test-uuid",
888
        "rating_score": 5,
889
        "is_pinned": false,
890
        "is_deprecated": false,
891
        "has_nsfw_content": false,
892
        "categories": ["category1"],
893
        "versions": [
894
          {
895
            "name": "ModA",
896
            "full_name": "Owner-ModA",
897
            "description": "Test description",
898
            "icon": "icon.png",
899
            "version_number": "1.0.0",
900
            "dependencies": [],
901
            "download_url": "https://example.com/mods/ModA/download",
902
            "downloads": 100,
903
            "date_created": "2024-01-01T12:00:00Z",
904
            "website_url": "https://example.com",
905
            "is_active": true,
906
            "uuid4": "test-version-uuid",
907
            "file_size": 1024
908
          }
909
        ]
910
      }
911
    ]"#;
912

913
    let test_date = "Wed, 21 Feb 2024 15:30:45 GMT";
914

915
    let mock = server
916
      .mock("GET", "/c/valheim/api/v1/package/")
917
      .with_status(200)
918
      .with_header("Content-Type", "application/json")
919
      .with_header("Last-Modified", test_date)
920
      .with_body(test_json)
921
      .create();
922

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

925
    let rt = Runtime::new().unwrap();
926
    let result = rt.block_on(get_manifest_from_network(&api_url));
927

928
    assert!(result.is_ok());
929
    if let Ok((packages, last_modified)) = result {
930
      assert_eq!(packages.len(), 1);
931
      assert_eq!(packages[0].full_name, Some("Owner-ModA".to_string()));
932
      let expected_date = DateTime::parse_from_rfc2822(test_date).unwrap();
933
      assert_eq!(last_modified, expected_date);
934
    }
935

936
    mock.assert();
937
  }
938

939
  #[test]
940
  fn test_get_manifest() {
941
    let temp_dir = tempdir().unwrap();
942
    let temp_dir_str = temp_dir.path().to_str().unwrap();
943

944
    let package = Package {
945
      name: Some("CachedMod".to_string()),
946
      full_name: Some("CachedOwner-CachedMod".to_string()),
947
      owner: Some("CachedOwner".to_string()),
948
      package_url: Some("https://example.com/CachedMod".to_string()),
949
      date_created: OffsetDateTime::now_utc(),
950
      date_updated: OffsetDateTime::now_utc(),
951
      uuid4: Some("cached-uuid".to_string()),
952
      rating_score: Some(5),
953
      is_pinned: Some(false),
954
      is_deprecated: Some(false),
955
      has_nsfw_content: Some(false),
956
      categories: vec!["test".to_string()],
957
      versions: vec![],
958
    };
959

960
    let packages = vec![package];
961
    let interned_manifest: InternedPackageManifest = packages.into();
962
    let serializable: SerializableInternedManifest = (&interned_manifest).into();
963
    let binary_data = bincode::serialize(&serializable).unwrap();
964
    let compressed_data = zstd::encode_all(binary_data.as_slice(), 9).unwrap();
965

966
    std::fs::create_dir_all(PathBuf::from(temp_dir_str)).unwrap();
967

968
    let mut manifest_path = PathBuf::from(temp_dir_str);
969
    manifest_path.push(API_MANIFEST_FILENAME_V3);
970
    let mut file = File::create(manifest_path).unwrap();
971
    file.write_all(&compressed_data).unwrap();
972

973
    let now = chrono::Utc::now().with_timezone(&chrono::FixedOffset::east_opt(0).unwrap());
974
    let recent_date = now.to_rfc2822();
975
    let mut last_mod_path = PathBuf::from(temp_dir_str);
976
    last_mod_path.push(LAST_MODIFIED_FILENAME);
977
    let mut file = File::create(&last_mod_path).unwrap();
978
    file.write_all(recent_date.as_bytes()).unwrap();
979

980
    let rt = Runtime::new().unwrap();
981

982
    let result = rt.block_on(get_manifest(temp_dir_str, None));
983

984
    assert!(result.is_ok());
985
  }
986

987
  #[test]
988
  fn test_manifest_v1_to_v2_migration() {
989
    let temp_dir = tempdir().unwrap();
990
    let temp_dir_str = temp_dir.path().to_str().unwrap();
991

992
    let package = Package {
993
      name: Some("OldMod".to_string()),
994
      full_name: Some("OldOwner-OldMod".to_string()),
995
      owner: Some("OldOwner".to_string()),
996
      package_url: Some("https://example.com/OldMod".to_string()),
997
      date_created: OffsetDateTime::now_utc(),
998
      date_updated: OffsetDateTime::now_utc(),
999
      uuid4: Some("old-uuid".to_string()),
1000
      rating_score: Some(4),
1001
      is_pinned: Some(false),
1002
      is_deprecated: Some(false),
1003
      has_nsfw_content: Some(false),
1004
      categories: vec!["legacy".to_string()],
1005
      versions: vec![],
1006
    };
1007

1008
    let packages = vec![package];
1009
    let binary_data = bincode::serialize(&packages).unwrap();
1010
    let compressed_data = zstd::encode_all(binary_data.as_slice(), 9).unwrap();
1011

1012
    std::fs::create_dir_all(PathBuf::from(temp_dir_str)).unwrap();
1013

1014
    let mut v1_path = PathBuf::from(temp_dir_str);
1015
    v1_path.push(API_MANIFEST_FILENAME_V1);
1016
    let mut file = File::create(&v1_path).unwrap();
1017
    file.write_all(&compressed_data).unwrap();
1018

1019
    let v2_path = PathBuf::from(temp_dir_str).join(API_MANIFEST_FILENAME_V3);
1020
    assert!(!v2_path.exists());
1021
    assert!(v1_path.exists());
1022

1023
    let rt = Runtime::new().unwrap();
1024
    let result = rt.block_on(get_manifest_from_disk(temp_dir_str));
1025

1026
    assert!(result.is_ok());
1027
    let manifest = result.unwrap();
1028
    assert_eq!(manifest.len(), 1);
1029
    assert_eq!(manifest.resolve_full_name_at(0), Some("OldOwner-OldMod".to_string()));
1030

1031
    assert!(v2_path.exists(), "v3 manifest should be created");
1032
    assert!(!v1_path.exists(), "v1 manifest should be removed");
1033
  }
1034

1035
  #[test]
1036
  fn test_get_manifest_from_network_and_cache() {
1037
    let mut server = Server::new();
1038

1039
    let test_json = r#"[
1040
      {
1041
        "name": "ModA",
1042
        "full_name": "Owner-ModA",
1043
        "owner": "Owner",
1044
        "package_url": "https://example.com/mods/ModA",
1045
        "date_created": "2024-01-01T12:00:00Z",
1046
        "date_updated": "2024-01-02T12:00:00Z",
1047
        "uuid4": "test-uuid",
1048
        "rating_score": 5,
1049
        "is_pinned": false,
1050
        "is_deprecated": false,
1051
        "has_nsfw_content": false,
1052
        "categories": ["category1"],
1053
        "versions": [
1054
          {
1055
            "name": "ModA",
1056
            "full_name": "Owner-ModA",
1057
            "description": "Test description",
1058
            "icon": "icon.png",
1059
            "version_number": "1.0.0",
1060
            "dependencies": [],
1061
            "download_url": "https://example.com/mods/ModA/download",
1062
            "downloads": 100,
1063
            "date_created": "2024-01-01T12:00:00Z",
1064
            "website_url": "https://example.com",
1065
            "is_active": true,
1066
            "uuid4": "test-version-uuid",
1067
            "file_size": 1024
1068
          }
1069
        ]
1070
      }
1071
    ]"#;
1072

1073
    let test_date = "Wed, 21 Feb 2024 15:30:45 GMT";
1074

1075
    let mock = server
1076
      .mock("GET", "/c/valheim/api/v1/package/")
1077
      .with_status(200)
1078
      .with_header("Content-Type", "application/json")
1079
      .with_header("Last-Modified", test_date)
1080
      .with_body(test_json)
1081
      .create();
1082

1083
    let api_url = format!("{}/c/valheim/api/v1/package/", server.url());
1084
    let temp_dir = tempdir().unwrap();
1085
    let temp_dir_str = temp_dir.path().to_str().unwrap();
1086

1087
    let rt = Runtime::new().unwrap();
1088
    let result = rt.block_on(get_manifest_from_network_and_cache(temp_dir_str, &api_url));
1089

1090
    assert!(result.is_ok());
1091
    if let Ok(manifest) = result {
1092
      assert_eq!(manifest.len(), 1);
1093
      assert_eq!(manifest.resolve_full_name_at(0), Some("Owner-ModA".to_string()));
1094

1095
      assert!(last_modified_path(temp_dir_str).exists());
1096
      assert!(api_manifest_path_v3(temp_dir_str).exists());
1097

1098
      let last_mod_content = std::fs::read_to_string(last_modified_path(temp_dir_str)).unwrap();
1099
      assert!(last_mod_content.contains("21 Feb 2024 15:30:45"));
1100
    }
1101

1102
    mock.assert();
1103
  }
1104

1105
  #[test]
1106
  fn test_download_file() {
1107
    let mut server = Server::new();
1108
    let temp_dir = tempdir().unwrap();
1109
    let temp_dir_str = temp_dir.path().to_str().unwrap();
1110

1111
    let test_data = b"This is test file content";
1112
    let content_length = test_data.len();
1113

1114
    let _mock = server
1115
      .mock("GET", "/test-file.zip")
1116
      .with_status(200)
1117
      .with_header("Content-Type", "application/zip")
1118
      .with_header("Content-Length", &content_length.to_string())
1119
      .with_body(test_data)
1120
      .create();
1121

1122
    let file_url = format!("{}/test-file.zip", server.url());
1123
    let filename = "test-file.zip".to_string();
1124

1125
    let multi_progress = MultiProgress::new();
1126
    let style_template = "";
1127
    let progress_style = ProgressStyle::with_template(style_template)
1128
      .unwrap()
1129
      .progress_chars("#>-");
1130

1131
    let rt = Runtime::new().unwrap();
1132
    let client = rt.block_on(async {
1133
      reqwest::Client::builder()
1134
        .timeout(Duration::from_secs(60))
1135
        .build()
1136
        .unwrap()
1137
    });
1138

1139
    let result = rt.block_on(download_file(
1140
      client.clone(),
1141
      &file_url,
1142
      &filename,
1143
      multi_progress.clone(),
1144
      progress_style.clone(),
1145
      temp_dir_str,
1146
    ));
1147

1148
    assert!(result.is_ok());
1149

1150
    let downloads_dir = PathBuf::from(temp_dir_str).join("downloads");
1151
    assert!(downloads_dir.exists());
1152

1153
    let downloaded_file = downloads_dir.join(&filename);
1154
    assert!(downloaded_file.exists());
1155

1156
    let file_content = std::fs::read(&downloaded_file).unwrap();
1157
    assert_eq!(file_content, test_data);
1158

1159
    let result2 = rt.block_on(download_file(
1160
      client.clone(),
1161
      &file_url,
1162
      &filename,
1163
      multi_progress.clone(),
1164
      progress_style.clone(),
1165
      temp_dir_str,
1166
    ));
1167

1168
    assert!(result2.is_ok());
1169
  }
1170

1171
  #[test]
1172
  fn test_download_file_missing_header() {
1173
    let mut server = Server::new();
1174
    let temp_dir = tempdir().unwrap();
1175
    let temp_dir_str = temp_dir.path().to_str().unwrap();
1176

1177
    let test_data = b"This is test file content";
1178

1179
    let mock = server
1180
      .mock("GET", "/test-file.zip")
1181
      .with_status(200)
1182
      .with_header("Content-Type", "application/zip")
1183
      .with_body(test_data)
1184
      .create();
1185

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

1189
    let multi_progress = MultiProgress::new();
1190
    let style_template = "";
1191
    let progress_style = ProgressStyle::with_template(style_template)
1192
      .unwrap()
1193
      .progress_chars("#>-");
1194

1195
    let rt = Runtime::new().unwrap();
1196
    let client = rt.block_on(async {
1197
      reqwest::Client::builder()
1198
        .timeout(Duration::from_secs(60))
1199
        .build()
1200
        .unwrap()
1201
    });
1202

1203
    let _result = rt.block_on(download_file(
1204
      client.clone(),
1205
      &file_url,
1206
      &filename,
1207
      multi_progress.clone(),
1208
      progress_style.clone(),
1209
      temp_dir_str,
1210
    ));
1211

1212
    mock.assert();
1213
  }
1214

1215
  #[test]
1216
  fn test_download_files() {
1217
    let mut server = Server::new();
1218
    let temp_dir = tempdir().unwrap();
1219
    let temp_dir_str = temp_dir.path().to_str().unwrap();
1220

1221
    let test_data1 = b"This is test file 1 content";
1222
    let content_length1 = test_data1.len();
1223

1224
    let test_data2 = b"This is test file 2 content - longer content";
1225
    let content_length2 = test_data2.len();
1226

1227
    let mock1 = server
1228
      .mock("GET", "/file1.zip")
1229
      .with_status(200)
1230
      .with_header("Content-Type", "application/zip")
1231
      .with_header("Content-Length", &content_length1.to_string())
1232
      .with_body(test_data1)
1233
      .create();
1234

1235
    let mock2 = server
1236
      .mock("GET", "/file2.zip")
1237
      .with_status(200)
1238
      .with_header("Content-Type", "application/zip")
1239
      .with_header("Content-Length", &content_length2.to_string())
1240
      .with_body(test_data2)
1241
      .create();
1242

1243
    let mut urls = HashMap::new();
1244
    urls.insert(
1245
      "file1.zip".to_string(),
1246
      format!("{}/file1.zip", server.url()),
1247
    );
1248
    urls.insert(
1249
      "file2.zip".to_string(),
1250
      format!("{}/file2.zip", server.url()),
1251
    );
1252

1253
    let rt = Runtime::new().unwrap();
1254
    let result = rt.block_on(download_files(urls, temp_dir_str));
1255

1256
    assert!(result.is_ok());
1257

1258
    let downloads_dir = PathBuf::from(temp_dir_str).join("downloads");
1259
    assert!(downloads_dir.exists());
1260

1261
    let file1 = downloads_dir.join("file1.zip");
1262
    let file2 = downloads_dir.join("file2.zip");
1263
    assert!(file1.exists());
1264
    assert!(file2.exists());
1265

1266
    let file1_content = std::fs::read(&file1).unwrap();
1267
    let file2_content = std::fs::read(&file2).unwrap();
1268
    assert_eq!(file1_content, test_data1);
1269
    assert_eq!(file2_content, test_data2);
1270

1271
    mock1.assert();
1272
    mock2.assert();
1273
  }
1274

1275
  #[test]
1276
  fn test_download_files_empty() {
1277
    let temp_dir = tempdir().unwrap();
1278
    let temp_dir_str = temp_dir.path().to_str().unwrap();
1279

1280
    let urls = HashMap::new();
1281

1282
    let rt = Runtime::new().unwrap();
1283
    let result = rt.block_on(download_files(urls, temp_dir_str));
1284

1285
    assert!(result.is_ok());
1286
  }
1287
}
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