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

endoze / valheim-mod-manager / 20038713584

08 Dec 2025 06:32PM UTC coverage: 92.604% (+0.2%) from 92.43%
20038713584

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

500 of 531 new or added lines in 3 files covered. (94.16%)

9 existing lines in 3 files now uncovered.

889 of 960 relevant lines covered (92.6%)

4.02 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::{
3
  InternedPackageManifest, Package, PackageManifest, SerializableInternedManifest,
4
};
5

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

17
const API_URL: &str = "https://stacklands.thunderstore.io/c/valheim/api/v1/package/";
18
const LAST_MODIFIED_FILENAME: &str = "last_modified";
19
const API_MANIFEST_FILENAME_V3: &str = "api_manifest_v3.bin.zst";
20
const API_MANIFEST_FILENAME_V2: &str = "api_manifest_v2.bin.zst";
21
const API_MANIFEST_FILENAME_V1: &str = "api_manifest.bin.zst";
22

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

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

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

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

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

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

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

144
  if path_v3.exists() {
6✔
145
    tracing::debug!("Loading v3 manifest format");
9✔
146

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

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

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

157
    let manifest: InternedPackageManifest = serializable.into();
4✔
158

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

163
    return Ok(manifest);
2✔
164
  }
165

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

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

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

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

NEW
179
    let manifest: InternedPackageManifest = v2_manifest.into();
×
180

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

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

NEW
192
    write_cache_to_disk(path_v3.clone(), &binary_data, true).await?;
×
193

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

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

NEW
216
    return Ok(manifest);
×
217
  }
218

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

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

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

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

232
    let manifest: InternedPackageManifest = packages.into();
4✔
233

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

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

245
    write_cache_to_disk(path_v3.clone(), &binary_data, true).await?;
6✔
246

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

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

269
    return Ok(manifest);
2✔
270
  }
271

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

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

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

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

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

322
  let raw_response_data = response.bytes().await?;
4✔
323

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

326
  progress_bar.finish_with_message(" Downloaded Api Manifest");
2✔
327

328
  Ok((packages, last_modified))
3✔
329
}
330

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

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

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

371
  write_cache_to_disk(api_manifest_path_v3(cache_dir), &binary_data, true).await?;
6✔
372

373
  Ok(manifest)
2✔
374
}
375

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

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

397
    let last_modified = DateTime::parse_from_rfc2822(&contents)?;
4✔
398

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

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

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

431
  let response = client.head(api_url).send().await?;
6✔
432

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

442
  Ok(last_modified_date)
2✔
443
}
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() {
12✔
486
    fs::create_dir_all(parent).await?;
12✔
487
  }
488

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

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

499
  if use_compression {
10✔
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?;
13✔
504
    file.flush().await?;
14✔
505
  } else {
506
    file.write_all(contents).await?;
15✔
507
    file.flush().await?;
17✔
508
  }
509

510
  Ok(())
5✔
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(
3✔
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);
3✔
618
  let mut downloads_directory = PathBuf::from(expanded_path.as_ref());
6✔
619
  downloads_directory.push("downloads");
3✔
620
  let mut file_path = downloads_directory.clone();
6✔
621
  file_path.push(filename);
3✔
622

623
  tokio::fs::DirBuilder::new()
15✔
624
    .recursive(true)
625
    .create(&downloads_directory)
3✔
626
    .await?;
11✔
627

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

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

634
  let response = client.get(url).send().await?;
8✔
635
  let content_length = response
7✔
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();
7✔
645

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

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

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

658
  file.flush().await?;
5✔
659
  file.sync_all().await?;
9✔
660

661
  progress_bar.finish();
4✔
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!(
873
      manifest.resolve_full_name_at(0),
874
      Some("Owner-ModA".to_string())
875
    );
876
  }
877

878
  #[test]
879
  fn test_get_manifest_from_disk_error() {
880
    let temp_dir = tempdir().unwrap();
881
    let temp_dir_str = temp_dir.path().to_str().unwrap();
882

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

890
    let rt = Runtime::new().unwrap();
891
    let result = rt.block_on(get_manifest_from_disk(temp_dir_str));
892

893
    assert!(result.is_err());
894
  }
895

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

908
    let result = rt.block_on(network_last_modified(&api_url));
909

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

916
    mock.assert();
917
  }
918

919
  #[test]
920
  fn test_get_manifest_from_network() {
921
    let mut server = Server::new();
922

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

957
    let test_date = "Wed, 21 Feb 2024 15:30:45 GMT";
958

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

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

969
    let rt = Runtime::new().unwrap();
970
    let result = rt.block_on(get_manifest_from_network(&api_url));
971

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

980
    mock.assert();
981
  }
982

983
  #[test]
984
  fn test_get_manifest() {
985
    let temp_dir = tempdir().unwrap();
986
    let temp_dir_str = temp_dir.path().to_str().unwrap();
987

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

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

1010
    std::fs::create_dir_all(PathBuf::from(temp_dir_str)).unwrap();
1011

1012
    let mut manifest_path = PathBuf::from(temp_dir_str);
1013
    manifest_path.push(API_MANIFEST_FILENAME_V3);
1014
    let mut file = File::create(manifest_path).unwrap();
1015
    file.write_all(&compressed_data).unwrap();
1016

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

1024
    let rt = Runtime::new().unwrap();
1025

1026
    let result = rt.block_on(get_manifest(temp_dir_str, None));
1027

1028
    assert!(result.is_ok());
1029
  }
1030

1031
  #[test]
1032
  fn test_manifest_v1_to_v2_migration() {
1033
    let temp_dir = tempdir().unwrap();
1034
    let temp_dir_str = temp_dir.path().to_str().unwrap();
1035

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

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

1056
    std::fs::create_dir_all(PathBuf::from(temp_dir_str)).unwrap();
1057

1058
    let mut v1_path = PathBuf::from(temp_dir_str);
1059
    v1_path.push(API_MANIFEST_FILENAME_V1);
1060
    let mut file = File::create(&v1_path).unwrap();
1061
    file.write_all(&compressed_data).unwrap();
1062

1063
    let v2_path = PathBuf::from(temp_dir_str).join(API_MANIFEST_FILENAME_V3);
1064
    assert!(!v2_path.exists());
1065
    assert!(v1_path.exists());
1066

1067
    let rt = Runtime::new().unwrap();
1068
    let result = rt.block_on(get_manifest_from_disk(temp_dir_str));
1069

1070
    assert!(result.is_ok());
1071
    let manifest = result.unwrap();
1072
    assert_eq!(manifest.len(), 1);
1073
    assert_eq!(
1074
      manifest.resolve_full_name_at(0),
1075
      Some("OldOwner-OldMod".to_string())
1076
    );
1077

1078
    assert!(v2_path.exists(), "v3 manifest should be created");
1079
    assert!(!v1_path.exists(), "v1 manifest should be removed");
1080
  }
1081

1082
  #[test]
1083
  fn test_get_manifest_from_network_and_cache() {
1084
    let mut server = Server::new();
1085

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

1120
    let test_date = "Wed, 21 Feb 2024 15:30:45 GMT";
1121

1122
    let mock = server
1123
      .mock("GET", "/c/valheim/api/v1/package/")
1124
      .with_status(200)
1125
      .with_header("Content-Type", "application/json")
1126
      .with_header("Last-Modified", test_date)
1127
      .with_body(test_json)
1128
      .create();
1129

1130
    let api_url = format!("{}/c/valheim/api/v1/package/", server.url());
1131
    let temp_dir = tempdir().unwrap();
1132
    let temp_dir_str = temp_dir.path().to_str().unwrap();
1133

1134
    let rt = Runtime::new().unwrap();
1135
    let result = rt.block_on(get_manifest_from_network_and_cache(temp_dir_str, &api_url));
1136

1137
    assert!(result.is_ok());
1138
    if let Ok(manifest) = result {
1139
      assert_eq!(manifest.len(), 1);
1140
      assert_eq!(
1141
        manifest.resolve_full_name_at(0),
1142
        Some("Owner-ModA".to_string())
1143
      );
1144

1145
      assert!(last_modified_path(temp_dir_str).exists());
1146
      assert!(api_manifest_path_v3(temp_dir_str).exists());
1147

1148
      let last_mod_content = std::fs::read_to_string(last_modified_path(temp_dir_str)).unwrap();
1149
      assert!(last_mod_content.contains("21 Feb 2024 15:30:45"));
1150
    }
1151

1152
    mock.assert();
1153
  }
1154

1155
  #[test]
1156
  fn test_download_file() {
1157
    let mut server = Server::new();
1158
    let temp_dir = tempdir().unwrap();
1159
    let temp_dir_str = temp_dir.path().to_str().unwrap();
1160

1161
    let test_data = b"This is test file content";
1162
    let content_length = test_data.len();
1163

1164
    let _mock = server
1165
      .mock("GET", "/test-file.zip")
1166
      .with_status(200)
1167
      .with_header("Content-Type", "application/zip")
1168
      .with_header("Content-Length", &content_length.to_string())
1169
      .with_body(test_data)
1170
      .create();
1171

1172
    let file_url = format!("{}/test-file.zip", server.url());
1173
    let filename = "test-file.zip".to_string();
1174

1175
    let multi_progress = MultiProgress::new();
1176
    let style_template = "";
1177
    let progress_style = ProgressStyle::with_template(style_template)
1178
      .unwrap()
1179
      .progress_chars("#>-");
1180

1181
    let rt = Runtime::new().unwrap();
1182
    let client = rt.block_on(async {
1183
      reqwest::Client::builder()
1184
        .timeout(Duration::from_secs(60))
1185
        .build()
1186
        .unwrap()
1187
    });
1188

1189
    let result = rt.block_on(download_file(
1190
      client.clone(),
1191
      &file_url,
1192
      &filename,
1193
      multi_progress.clone(),
1194
      progress_style.clone(),
1195
      temp_dir_str,
1196
    ));
1197

1198
    assert!(result.is_ok());
1199

1200
    let downloads_dir = PathBuf::from(temp_dir_str).join("downloads");
1201
    assert!(downloads_dir.exists());
1202

1203
    let downloaded_file = downloads_dir.join(&filename);
1204
    assert!(downloaded_file.exists());
1205

1206
    let file_content = std::fs::read(&downloaded_file).unwrap();
1207
    assert_eq!(file_content, test_data);
1208

1209
    let result2 = rt.block_on(download_file(
1210
      client.clone(),
1211
      &file_url,
1212
      &filename,
1213
      multi_progress.clone(),
1214
      progress_style.clone(),
1215
      temp_dir_str,
1216
    ));
1217

1218
    assert!(result2.is_ok());
1219
  }
1220

1221
  #[test]
1222
  fn test_download_file_missing_header() {
1223
    let mut server = Server::new();
1224
    let temp_dir = tempdir().unwrap();
1225
    let temp_dir_str = temp_dir.path().to_str().unwrap();
1226

1227
    let test_data = b"This is test file content";
1228

1229
    let mock = server
1230
      .mock("GET", "/test-file.zip")
1231
      .with_status(200)
1232
      .with_header("Content-Type", "application/zip")
1233
      .with_body(test_data)
1234
      .create();
1235

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

1239
    let multi_progress = MultiProgress::new();
1240
    let style_template = "";
1241
    let progress_style = ProgressStyle::with_template(style_template)
1242
      .unwrap()
1243
      .progress_chars("#>-");
1244

1245
    let rt = Runtime::new().unwrap();
1246
    let client = rt.block_on(async {
1247
      reqwest::Client::builder()
1248
        .timeout(Duration::from_secs(60))
1249
        .build()
1250
        .unwrap()
1251
    });
1252

1253
    let _result = rt.block_on(download_file(
1254
      client.clone(),
1255
      &file_url,
1256
      &filename,
1257
      multi_progress.clone(),
1258
      progress_style.clone(),
1259
      temp_dir_str,
1260
    ));
1261

1262
    mock.assert();
1263
  }
1264

1265
  #[test]
1266
  fn test_download_files() {
1267
    let mut server = Server::new();
1268
    let temp_dir = tempdir().unwrap();
1269
    let temp_dir_str = temp_dir.path().to_str().unwrap();
1270

1271
    let test_data1 = b"This is test file 1 content";
1272
    let content_length1 = test_data1.len();
1273

1274
    let test_data2 = b"This is test file 2 content - longer content";
1275
    let content_length2 = test_data2.len();
1276

1277
    let mock1 = server
1278
      .mock("GET", "/file1.zip")
1279
      .with_status(200)
1280
      .with_header("Content-Type", "application/zip")
1281
      .with_header("Content-Length", &content_length1.to_string())
1282
      .with_body(test_data1)
1283
      .create();
1284

1285
    let mock2 = server
1286
      .mock("GET", "/file2.zip")
1287
      .with_status(200)
1288
      .with_header("Content-Type", "application/zip")
1289
      .with_header("Content-Length", &content_length2.to_string())
1290
      .with_body(test_data2)
1291
      .create();
1292

1293
    let mut urls = HashMap::new();
1294
    urls.insert(
1295
      "file1.zip".to_string(),
1296
      format!("{}/file1.zip", server.url()),
1297
    );
1298
    urls.insert(
1299
      "file2.zip".to_string(),
1300
      format!("{}/file2.zip", server.url()),
1301
    );
1302

1303
    let rt = Runtime::new().unwrap();
1304
    let result = rt.block_on(download_files(urls, temp_dir_str));
1305

1306
    assert!(result.is_ok());
1307

1308
    let downloads_dir = PathBuf::from(temp_dir_str).join("downloads");
1309
    assert!(downloads_dir.exists());
1310

1311
    let file1 = downloads_dir.join("file1.zip");
1312
    let file2 = downloads_dir.join("file2.zip");
1313
    assert!(file1.exists());
1314
    assert!(file2.exists());
1315

1316
    let file1_content = std::fs::read(&file1).unwrap();
1317
    let file2_content = std::fs::read(&file2).unwrap();
1318
    assert_eq!(file1_content, test_data1);
1319
    assert_eq!(file2_content, test_data2);
1320

1321
    mock1.assert();
1322
    mock2.assert();
1323
  }
1324

1325
  #[test]
1326
  fn test_download_files_empty() {
1327
    let temp_dir = tempdir().unwrap();
1328
    let temp_dir_str = temp_dir.path().to_str().unwrap();
1329

1330
    let urls = HashMap::new();
1331

1332
    let rt = Runtime::new().unwrap();
1333
    let result = rt.block_on(download_files(urls, temp_dir_str));
1334

1335
    assert!(result.is_ok());
1336
  }
1337
}
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