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

endoze / valheim-mod-manager / 23714700391

29 Mar 2026 05:22PM UTC coverage: 92.604% (+0.2%) from 92.43%
23714700391

push

github

web-flow
Merge pull request #15 from endoze/add-string-interning

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

3.72 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());
4✔
35
  path.push(LAST_MODIFIED_FILENAME);
2✔
36
  path
2✔
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> {
11✔
140
  let path_v3 = api_manifest_path_v3(cache_dir);
3✔
141
  let path_v2 = api_manifest_path_v2(cache_dir);
2✔
142
  let path_v1 = api_manifest_path_v1(cache_dir);
2✔
143

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

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

151
    let decompressed_data = zstd::decode_all(compressed_data.as_slice())
10✔
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 {
4✔
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 {
5✔
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?;
6✔
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?;
5✔
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?;
9✔
396

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

399
    Ok(last_modified)
3✔
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() {
10✔
490
    fs::File::create(&path).await?;
16✔
491
  }
492

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

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

503
    file.write_all(&compressed_data).await?;
12✔
504
    file.flush().await?;
12✔
505
  } else {
506
    file.write_all(contents).await?;
18✔
507
    file.flush().await?;
20✔
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);
2✔
618
  let mut downloads_directory = PathBuf::from(expanded_path.as_ref());
4✔
619
  downloads_directory.push("downloads");
2✔
620
  let mut file_path = downloads_directory.clone();
7✔
621
  file_path.push(filename);
3✔
622

623
  tokio::fs::DirBuilder::new()
14✔
624
    .recursive(true)
625
    .create(&downloads_directory)
3✔
626
    .await?;
10✔
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?;
9✔
635
  let content_length = response
9✔
636
    .headers()
637
    .get(header::CONTENT_LENGTH)
638
    .ok_or(AppError::MissingHeader(format!(
6✔
639
      "Content-Length header for {}",
640
      url
641
    )))?
642
    .to_str()?
643
    .parse::<u64>()?;
644
  let mut response_data = response.bytes_stream();
6✔
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 {
12✔
653
    let chunk = result?;
5✔
654
    file.write_all(&chunk).await?;
10✔
655
    progress_bar.inc(chunk.len() as u64);
6✔
656
  }
657

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

661
  progress_bar.finish();
6✔
662

663
  Ok(())
5✔
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