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

endoze / valheim-mod-manager / 23718555039

29 Mar 2026 08:38PM UTC coverage: 92.806% (-1.3%) from 94.105%
23718555039

Pull #18

github

endoze
feat!: add list subcommand and XDG-based config discovery

Users need a way to inspect resolved mod lists without running a full
update, and the tool previously required a writable local directory for
its config file, making global installation awkward.

This introduces a `vmm list` command that resolves and prints all mods
(including transitive dependencies) from the current configuration, with
optional JSON output for scripting.

Config discovery is reworked to follow XDG conventions: vmm now looks
for a local `vmm_config.toml` first, then falls back to
`~/.config/vmm/vmm_config.toml`, creating the latter on first run if
neither exists. A `--config` flag is also added for explicit overrides.

The `cache_dir` config field is removed — the XDG config home is now
always used for cached data, removing the need for users to configure
this manually.

BREAKING CHANGE: The `cache_dir` field is no longer read from config.
Any existing `vmm_config.toml` files using `cache_dir` should remove
that field; cached data now always lives under `~/.config/vmm/`.
Pull Request #18: feat!: add list subcommand and XDG-based config discovery

21 of 32 new or added lines in 3 files covered. (65.63%)

34 existing lines in 3 files now uncovered.

903 of 973 relevant lines covered (92.81%)

3.49 hits per line

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

83.56
/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
fn last_modified_path(cache_dir: &str) -> PathBuf {
3✔
25
  PathBuf::from(cache_dir).join(LAST_MODIFIED_FILENAME)
4✔
26
}
27

28
/// Returns the path to the v3 API manifest file in the cache directory.
29
fn api_manifest_path_v3(cache_dir: &str) -> PathBuf {
2✔
30
  PathBuf::from(cache_dir).join(API_MANIFEST_FILENAME_V3)
2✔
31
}
32

33
/// Returns the path to the v2 API manifest file in the cache directory.
34
fn api_manifest_path_v2(cache_dir: &str) -> PathBuf {
2✔
35
  PathBuf::from(cache_dir).join(API_MANIFEST_FILENAME_V2)
2✔
36
}
37

38
/// Returns the path to the v1 API manifest file in the cache directory.
39
fn api_manifest_path_v1(cache_dir: &str) -> PathBuf {
2✔
40
  PathBuf::from(cache_dir).join(API_MANIFEST_FILENAME_V1)
2✔
41
}
42

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

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

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

100
  if path_v3.exists() {
4✔
101
    tracing::debug!("Loading v3 manifest format");
4✔
102

103
    let mut file = fs::File::open(&path_v3).await?;
6✔
104
    let mut compressed_data = Vec::new();
2✔
105
    file.read_to_end(&mut compressed_data).await?;
8✔
106

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

110
    let serializable: SerializableInternedManifest = bincode::deserialize(&decompressed_data)
6✔
111
      .map_err(|e| AppError::Manifest(format!("Failed to deserialize v3 manifest: {}", e)))?;
2✔
112

113
    let manifest: InternedPackageManifest = serializable.into();
4✔
114

115
    manifest
2✔
116
      .validate()
117
      .map_err(|e| AppError::Manifest(format!("V3 manifest validation failed: {}", e)))?;
2✔
118

119
    return Ok(manifest);
2✔
120
  }
121

122
  if path_v2.exists() {
4✔
UNCOV
123
    tracing::info!("Found v2 manifest, migrating to v3 format");
×
124

UNCOV
125
    let mut file = fs::File::open(&path_v2).await?;
×
UNCOV
126
    let mut compressed_data = Vec::new();
×
UNCOV
127
    file.read_to_end(&mut compressed_data).await?;
×
128

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

UNCOV
132
    let v2_manifest: PackageManifest = bincode::deserialize(&decompressed_data)
×
UNCOV
133
      .map_err(|e| AppError::Manifest(format!("Failed to deserialize v2 manifest: {}", e)))?;
×
134

UNCOV
135
    let manifest: InternedPackageManifest = v2_manifest.into();
×
136

UNCOV
137
    manifest.validate().map_err(|e| {
×
UNCOV
138
      AppError::Manifest(format!(
×
139
        "V3 manifest validation failed during migration: {}",
140
        e
141
      ))
142
    })?;
143

UNCOV
144
    let serializable: SerializableInternedManifest = (&manifest).into();
×
UNCOV
145
    let binary_data = bincode::serialize(&serializable)
×
UNCOV
146
      .map_err(|e| AppError::Manifest(format!("Failed to serialize v3 manifest: {}", e)))?;
×
147

UNCOV
148
    write_cache_to_disk(path_v3.clone(), &binary_data, true).await?;
×
149

UNCOV
150
    match tokio::fs::metadata(&path_v3).await {
×
UNCOV
151
      Ok(metadata) if metadata.len() > 0 => {
×
UNCOV
152
        tracing::info!("V3 manifest written successfully, removing v2");
×
153

UNCOV
154
        if let Err(e) = fs::remove_file(&path_v2).await {
×
155
          tracing::warn!(
×
156
            "Failed to remove old v2 manifest (keeping as backup): {}",
157
            e
158
          );
159
        }
160
      }
161
      Ok(_) => {
162
        tracing::error!("V3 manifest written but is empty, keeping v2 as backup");
×
163
      }
164
      Err(e) => {
×
165
        tracing::error!(
×
166
          "Failed to verify v3 manifest write: {}, keeping v2 as backup",
167
          e
168
        );
169
      }
170
    }
171

UNCOV
172
    return Ok(manifest);
×
173
  }
174

175
  if path_v1.exists() {
4✔
176
    tracing::info!("Found v1 manifest, migrating to v3 format");
4✔
177

178
    let mut file = fs::File::open(&path_v1).await?;
6✔
179
    let mut compressed_data = Vec::new();
2✔
180
    file.read_to_end(&mut compressed_data).await?;
8✔
181

182
    let decompressed_data = zstd::decode_all(compressed_data.as_slice())
4✔
183
      .map_err(|e| AppError::Manifest(format!("Failed to decompress v1 manifest: {}", e)))?;
2✔
184

185
    let packages: Vec<Package> = bincode::deserialize(&decompressed_data)
6✔
186
      .map_err(|e| AppError::Manifest(format!("Failed to deserialize v1 manifest: {}", e)))?;
2✔
187

188
    let manifest: InternedPackageManifest = packages.into();
4✔
189

190
    manifest.validate().map_err(|e| {
4✔
UNCOV
191
      AppError::Manifest(format!(
×
192
        "V3 manifest validation failed during migration: {}",
193
        e
194
      ))
195
    })?;
196

197
    let serializable: SerializableInternedManifest = (&manifest).into();
2✔
198
    let binary_data = bincode::serialize(&serializable)
4✔
199
      .map_err(|e| AppError::Manifest(format!("Failed to serialize v3 manifest: {}", e)))?;
2✔
200

201
    write_cache_to_disk(path_v3.clone(), &binary_data, true).await?;
5✔
202

203
    match tokio::fs::metadata(&path_v3).await {
4✔
204
      Ok(metadata) if metadata.len() > 0 => {
4✔
205
        tracing::info!("V3 manifest written successfully, removing v1");
4✔
206

207
        if let Err(e) = fs::remove_file(&path_v1).await {
6✔
UNCOV
208
          tracing::warn!(
×
209
            "Failed to remove old v1 manifest (keeping as backup): {}",
210
            e
211
          );
212
        }
213
      }
214
      Ok(_) => {
UNCOV
215
        tracing::error!("V3 manifest written but is empty, keeping v1 as backup");
×
216
      }
UNCOV
217
      Err(e) => {
×
UNCOV
218
        tracing::error!(
×
219
          "Failed to verify v3 manifest write: {}, keeping v1 as backup",
220
          e
221
        );
222
      }
223
    }
224

225
    return Ok(manifest);
2✔
226
  }
227

UNCOV
228
  Err(AppError::Manifest("No cached manifest found".to_string()))
×
229
}
230

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

259
  let multi_progress = MultiProgress::new();
4✔
260
  let style_template = "";
2✔
261
  let progress_style = ProgressStyle::with_template(style_template)
2✔
262
    .unwrap()
263
    .tick_strings(&["-", "\\", "|", "/", ""]);
2✔
264

265
  let progress_bar = multi_progress.add(ProgressBar::new(100000));
4✔
266
  progress_bar.set_style(progress_style);
2✔
267
  progress_bar.set_message("Downloading Api Manifest");
2✔
268
  progress_bar.enable_steady_tick(Duration::from_millis(130));
2✔
269

270
  let response = client.get(api_url).send().await?;
6✔
271
  let last_modified_str = response
6✔
272
    .headers()
273
    .get(header::LAST_MODIFIED)
2✔
274
    .ok_or(AppError::MissingHeader("Last-Modified".to_string()))?
2✔
275
    .to_str()?;
276
  let last_modified = DateTime::parse_from_rfc2822(last_modified_str)?;
2✔
277

278
  let raw_response_data = response.bytes().await?;
2✔
279

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

282
  progress_bar.finish_with_message(" Downloaded Api Manifest");
2✔
283

284
  Ok((packages, last_modified))
2✔
285
}
286

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

315
  write_cache_to_disk(
316
    last_modified_path(cache_dir),
4✔
317
    last_modified_from_network.to_rfc2822().as_bytes(),
4✔
318
    false,
319
  )
320
  .await?;
10✔
321

322
  let manifest: InternedPackageManifest = packages.into();
4✔
323
  let serializable: SerializableInternedManifest = (&manifest).into();
2✔
324
  let binary_data = bincode::serialize(&serializable)
4✔
325
    .map_err(|e| AppError::Manifest(format!("Failed to serialize manifest: {}", e)))?;
2✔
326

327
  write_cache_to_disk(api_manifest_path_v3(cache_dir), &binary_data, true).await?;
6✔
328

329
  Ok(manifest)
2✔
330
}
331

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

348
  if let Ok(mut file) = fs::File::open(&path).await {
11✔
349
    tracing::info!("Last modified file exists and was opened.");
5✔
350
    let mut contents = String::new();
2✔
351
    file.read_to_string(&mut contents).await?;
7✔
352

353
    let last_modified = DateTime::parse_from_rfc2822(&contents)?;
2✔
354

355
    Ok(last_modified)
4✔
356
  } else {
357
    tracing::info!("Last modified file does not exist and was not opened.");
2✔
358
    let dt = DateTime::from_timestamp(0, 0).unwrap().naive_utc();
4✔
359
    let offset = FixedOffset::east_opt(0).unwrap();
2✔
360

361
    let last_modified = DateTime::<FixedOffset>::from_naive_utc_and_offset(dt, offset);
2✔
362
    Ok(last_modified)
2✔
363
  }
364
}
365

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

387
  let response = client.head(api_url).send().await?;
6✔
388

389
  let last_modified =
4✔
390
    response
391
      .headers()
392
      .get(header::LAST_MODIFIED)
2✔
393
      .ok_or(AppError::MissingHeader(
2✔
394
        "Last-Modified for API manifest head request".to_string(),
2✔
395
      ))?;
396
  let last_modified_date = DateTime::parse_from_rfc2822(last_modified.to_str()?)?;
2✔
397

398
  Ok(last_modified_date)
2✔
399
}
400

401
/// Checks for the existence of any version of the cached manifest file (v1, v2, or v3).
402
///
403
/// # Parameters
404
///
405
/// * `cache_dir` - The cache directory path
406
///
407
/// # Returns
408
///
409
/// `true` if any api_manifest file exists, `false` otherwise.
410
fn api_manifest_file_exists(cache_dir: &str) -> bool {
2✔
411
  api_manifest_path_v3(cache_dir).exists()
6✔
412
    || api_manifest_path_v2(cache_dir).exists()
2✔
413
    || api_manifest_path_v1(cache_dir).exists()
2✔
414
}
415

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

445
  if !path.as_ref().exists() {
12✔
446
    fs::File::create(&path).await?;
18✔
447
  }
448

449
  let mut file = fs::OpenOptions::new()
28✔
450
    .write(true)
451
    .truncate(true)
452
    .open(&path)
6✔
453
    .await?;
21✔
454

455
  if use_compression {
8✔
456
    let compressed_data = zstd::encode_all(contents, 9)
10✔
457
      .map_err(|e| AppError::Manifest(format!("Failed to compress data: {}", e)))?;
5✔
458

459
    file.write_all(&compressed_data).await?;
15✔
460
    file.flush().await?;
10✔
461
  } else {
462
    file.write_all(contents).await?;
14✔
463
    file.flush().await?;
10✔
464
  }
465

466
  Ok(())
6✔
467
}
468

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

487
    return Ok(());
2✔
488
  }
489

490
  tracing::debug!("Processing {} mods", urls.len());
4✔
491

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

494
  let multi_progress = MultiProgress::new();
4✔
495
  let style_template = "";
3✔
496
  let progress_style = ProgressStyle::with_template(style_template)
4✔
497
    .unwrap()
498
    .progress_chars("#>-");
499

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

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

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

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

539
  Ok(())
2✔
540
}
541

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

578
  tokio::fs::DirBuilder::new()
11✔
579
    .recursive(true)
580
    .create(&downloads_directory)
2✔
581
    .await?;
11✔
582

583
  if file_path.exists() {
3✔
584
    tracing::debug!("{} already exists, skipping download", filename);
4✔
585

586
    return Ok(());
2✔
587
  }
588

589
  let response = client.get(url).send().await?;
8✔
590
  let content_length = response
15✔
591
    .headers()
592
    .get(header::CONTENT_LENGTH)
3✔
593
    .ok_or(AppError::MissingHeader(format!(
4✔
594
      "Content-Length header for {}",
595
      url
596
    )))?
597
    .to_str()?
598
    .parse::<u64>()?;
599
  let mut response_data = response.bytes_stream();
3✔
600

601
  let progress_bar = multi_progress.add(ProgressBar::new(content_length));
6✔
602
  progress_bar.set_style(progress_style);
3✔
603
  progress_bar.set_message(filename.clone());
3✔
604

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

607
  while let Some(result) = response_data.next().await {
9✔
608
    let chunk = result?;
6✔
609
    file.write_all(&chunk).await?;
8✔
610
    progress_bar.inc(chunk.len() as u64);
3✔
611
  }
612

613
  file.flush().await?;
9✔
614
  file.sync_all().await?;
6✔
615

616
  progress_bar.finish();
3✔
617

618
  Ok(())
3✔
619
}
620

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

631
  #[test]
632
  fn test_downloads_directory_construction() {
633
    let temp_dir = tempdir().unwrap();
634
    let cache_dir = temp_dir.path().to_str().unwrap();
635

636
    let mut downloads_directory = PathBuf::from(cache_dir);
637
    downloads_directory.push("downloads");
638

639
    let expected_directory = PathBuf::from(cache_dir).join("downloads");
640
    assert_eq!(downloads_directory, expected_directory);
641
  }
642

643
  #[test]
644
  fn test_api_manifest_file_exists() {
645
    let temp_dir = tempdir().unwrap();
646
    let temp_dir_str = temp_dir.path().to_str().unwrap();
647

648
    assert!(!api_manifest_file_exists(temp_dir_str));
649

650
    let mut path = PathBuf::from(temp_dir_str);
651
    path.push(API_MANIFEST_FILENAME_V3);
652
    let _ = File::create(path).unwrap();
653

654
    assert!(api_manifest_file_exists(temp_dir_str));
655
  }
656

657
  #[test]
658
  fn test_path_construction() {
659
    let cache_dir = "/some/cache/dir";
660

661
    let last_modified = last_modified_path(cache_dir);
662
    let api_manifest = api_manifest_path_v3(cache_dir);
663

664
    assert_eq!(
665
      last_modified,
666
      Path::new(cache_dir).join(LAST_MODIFIED_FILENAME)
667
    );
668
    assert_eq!(
669
      api_manifest,
670
      Path::new(cache_dir).join(API_MANIFEST_FILENAME_V3)
671
    );
672
  }
673

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

851
    assert!(result.is_err());
852
  }
853

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

866
    let result = rt.block_on(network_last_modified(&api_url));
867

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

874
    mock.assert();
875
  }
876

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

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

915
    let test_date = "Wed, 21 Feb 2024 15:30:45 GMT";
916

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

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

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

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

938
    mock.assert();
939
  }
940

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

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

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

968
    std::fs::create_dir_all(PathBuf::from(temp_dir_str)).unwrap();
969

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

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

982
    let rt = Runtime::new().unwrap();
983

984
    let result = rt.block_on(get_manifest(temp_dir_str, None));
985

986
    assert!(result.is_ok());
987
  }
988

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

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

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

1014
    std::fs::create_dir_all(PathBuf::from(temp_dir_str)).unwrap();
1015

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

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

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

1028
    assert!(result.is_ok());
1029
    let manifest = result.unwrap();
1030
    assert_eq!(manifest.len(), 1);
1031
    assert_eq!(
1032
      manifest.resolve_full_name_at(0),
1033
      Some("OldOwner-OldMod".to_string())
1034
    );
1035

1036
    assert!(v2_path.exists(), "v3 manifest should be created");
1037
    assert!(!v1_path.exists(), "v1 manifest should be removed");
1038
  }
1039

1040
  #[test]
1041
  fn test_get_manifest_from_network_and_cache() {
1042
    let mut server = Server::new();
1043

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

1078
    let test_date = "Wed, 21 Feb 2024 15:30:45 GMT";
1079

1080
    let mock = server
1081
      .mock("GET", "/c/valheim/api/v1/package/")
1082
      .with_status(200)
1083
      .with_header("Content-Type", "application/json")
1084
      .with_header("Last-Modified", test_date)
1085
      .with_body(test_json)
1086
      .create();
1087

1088
    let api_url = format!("{}/c/valheim/api/v1/package/", server.url());
1089
    let temp_dir = tempdir().unwrap();
1090
    let temp_dir_str = temp_dir.path().to_str().unwrap();
1091

1092
    let rt = Runtime::new().unwrap();
1093
    let result = rt.block_on(get_manifest_from_network_and_cache(temp_dir_str, &api_url));
1094

1095
    assert!(result.is_ok());
1096
    if let Ok(manifest) = result {
1097
      assert_eq!(manifest.len(), 1);
1098
      assert_eq!(
1099
        manifest.resolve_full_name_at(0),
1100
        Some("Owner-ModA".to_string())
1101
      );
1102

1103
      assert!(last_modified_path(temp_dir_str).exists());
1104
      assert!(api_manifest_path_v3(temp_dir_str).exists());
1105

1106
      let last_mod_content = std::fs::read_to_string(last_modified_path(temp_dir_str)).unwrap();
1107
      assert!(last_mod_content.contains("21 Feb 2024 15:30:45"));
1108
    }
1109

1110
    mock.assert();
1111
  }
1112

1113
  #[test]
1114
  fn test_download_file() {
1115
    let mut server = Server::new();
1116
    let temp_dir = tempdir().unwrap();
1117
    let temp_dir_str = temp_dir.path().to_str().unwrap();
1118

1119
    let test_data = b"This is test file content";
1120
    let content_length = test_data.len();
1121

1122
    let _mock = server
1123
      .mock("GET", "/test-file.zip")
1124
      .with_status(200)
1125
      .with_header("Content-Type", "application/zip")
1126
      .with_header("Content-Length", &content_length.to_string())
1127
      .with_body(test_data)
1128
      .create();
1129

1130
    let file_url = format!("{}/test-file.zip", server.url());
1131
    let filename = "test-file.zip".to_string();
1132

1133
    let multi_progress = MultiProgress::new();
1134
    let style_template = "";
1135
    let progress_style = ProgressStyle::with_template(style_template)
1136
      .unwrap()
1137
      .progress_chars("#>-");
1138

1139
    let rt = Runtime::new().unwrap();
1140
    let client = rt.block_on(async {
1141
      reqwest::Client::builder()
1142
        .timeout(Duration::from_secs(60))
1143
        .build()
1144
        .unwrap()
1145
    });
1146

1147
    let result = rt.block_on(download_file(
1148
      client.clone(),
1149
      &file_url,
1150
      &filename,
1151
      multi_progress.clone(),
1152
      progress_style.clone(),
1153
      temp_dir_str,
1154
    ));
1155

1156
    assert!(result.is_ok());
1157

1158
    let downloads_dir = PathBuf::from(temp_dir_str).join("downloads");
1159
    assert!(downloads_dir.exists());
1160

1161
    let downloaded_file = downloads_dir.join(&filename);
1162
    assert!(downloaded_file.exists());
1163

1164
    let file_content = std::fs::read(&downloaded_file).unwrap();
1165
    assert_eq!(file_content, test_data);
1166

1167
    let result2 = rt.block_on(download_file(
1168
      client.clone(),
1169
      &file_url,
1170
      &filename,
1171
      multi_progress.clone(),
1172
      progress_style.clone(),
1173
      temp_dir_str,
1174
    ));
1175

1176
    assert!(result2.is_ok());
1177
  }
1178

1179
  #[test]
1180
  fn test_download_file_missing_header() {
1181
    let mut server = Server::new();
1182
    let temp_dir = tempdir().unwrap();
1183
    let temp_dir_str = temp_dir.path().to_str().unwrap();
1184

1185
    let test_data = b"This is test file content";
1186

1187
    let mock = server
1188
      .mock("GET", "/test-file.zip")
1189
      .with_status(200)
1190
      .with_header("Content-Type", "application/zip")
1191
      .with_body(test_data)
1192
      .create();
1193

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

1197
    let multi_progress = MultiProgress::new();
1198
    let style_template = "";
1199
    let progress_style = ProgressStyle::with_template(style_template)
1200
      .unwrap()
1201
      .progress_chars("#>-");
1202

1203
    let rt = Runtime::new().unwrap();
1204
    let client = rt.block_on(async {
1205
      reqwest::Client::builder()
1206
        .timeout(Duration::from_secs(60))
1207
        .build()
1208
        .unwrap()
1209
    });
1210

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

1220
    mock.assert();
1221
  }
1222

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

1229
    let test_data1 = b"This is test file 1 content";
1230
    let content_length1 = test_data1.len();
1231

1232
    let test_data2 = b"This is test file 2 content - longer content";
1233
    let content_length2 = test_data2.len();
1234

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

1243
    let mock2 = server
1244
      .mock("GET", "/file2.zip")
1245
      .with_status(200)
1246
      .with_header("Content-Type", "application/zip")
1247
      .with_header("Content-Length", &content_length2.to_string())
1248
      .with_body(test_data2)
1249
      .create();
1250

1251
    let mut urls = HashMap::new();
1252
    urls.insert(
1253
      "file1.zip".to_string(),
1254
      format!("{}/file1.zip", server.url()),
1255
    );
1256
    urls.insert(
1257
      "file2.zip".to_string(),
1258
      format!("{}/file2.zip", server.url()),
1259
    );
1260

1261
    let rt = Runtime::new().unwrap();
1262
    let result = rt.block_on(download_files(urls, temp_dir_str));
1263

1264
    assert!(result.is_ok());
1265

1266
    let downloads_dir = PathBuf::from(temp_dir_str).join("downloads");
1267
    assert!(downloads_dir.exists());
1268

1269
    let file1 = downloads_dir.join("file1.zip");
1270
    let file2 = downloads_dir.join("file2.zip");
1271
    assert!(file1.exists());
1272
    assert!(file2.exists());
1273

1274
    let file1_content = std::fs::read(&file1).unwrap();
1275
    let file2_content = std::fs::read(&file2).unwrap();
1276
    assert_eq!(file1_content, test_data1);
1277
    assert_eq!(file2_content, test_data2);
1278

1279
    mock1.assert();
1280
    mock2.assert();
1281
  }
1282

1283
  #[test]
1284
  fn test_download_files_empty() {
1285
    let temp_dir = tempdir().unwrap();
1286
    let temp_dir_str = temp_dir.path().to_str().unwrap();
1287

1288
    let urls = HashMap::new();
1289

1290
    let rt = Runtime::new().unwrap();
1291
    let result = rt.block_on(download_files(urls, temp_dir_str));
1292

1293
    assert!(result.is_ok());
1294
  }
1295
}
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