Skip to main content

thunderstore_engine/profile/
mod.rs

1//! Profile installs on top of [`crate::install`]: five high-level operations
2//! (install an extracted package, uninstall, enable, disable, and the full
3//! download-resolve-install pipeline) that work against an arbitrary target
4//! directory. `mods.yml` ([`modlist`](crate::profile::modlist)) is the
5//! authoritative install record in every mode.
6//!
7//! Two layers share the same primitives:
8//!
9//! - **Client mode** (named r2modman profiles):
10//!   [`install_extracted`](crate::profile::install_extracted),
11//!   [`uninstall_mod`](crate::profile::uninstall_mod),
12//!   [`set_enabled`](crate::profile::set_enabled) (and
13//!   [`enable_mod`](crate::profile::enable_mod)/[`disable_mod`](crate::profile::disable_mod)),
14//!   and [`install_mod`](crate::profile::install_mod) take `base` +
15//!   `profile_name`, compute `layout::profile_dir(base, game, profile_name)`,
16//!   and delegate to the `_in` variant below. The profile name is validated by
17//!   [`layout::validate_name`](crate::profile::layout::validate_name).
18//! - **Server mode**: the `_in` variants
19//!   ([`install_extracted_in`](crate::profile::install_extracted_in),
20//!   [`uninstall_mod_in`](crate::profile::uninstall_mod_in),
21//!   [`set_enabled_in`](crate::profile::set_enabled_in)/[`enable_mod_in`](crate::profile::enable_mod_in)/
22//!   [`disable_mod_in`](crate::profile::disable_mod_in),
23//!   [`install_mod_in`](crate::profile::install_mod_in)) take a
24//!   `target_dir: &Path` directly — there is no named r2modman profile layout;
25//!   `mods.yml` is the source of truth for a single mod set installed straight
26//!   into the game directory.
27//!
28//! A caller-supplied `target_dir` is caller-owned and expected to already be
29//! absolute; unlike a layout profile name it is **not** name-validated here —
30//! there is no [`layout::validate_name`](crate::profile::layout::validate_name)-equivalent
31//! check on it, the caller is trusted to pass a real directory. Package
32//! *contents* are still traversal-guarded regardless of mode:
33//! [`crate::install`]'s `ensure_safe_relative` rejects any archive entry or
34//! computed destination that would escape `target_dir`.
35//!
36//! [`install_mod_in`](crate::profile::install_mod_in)/[`install_mod`](crate::profile::install_mod)
37//! additionally take a `cache_base` independent of the install target:
38//! extracted packages land under
39//! [`cache::ensure_extracted`](crate::profile::cache::ensure_extracted)`(cache_base, ...)`,
40//! never under `target_dir`.
41//! `cache_base` must persist across installs — it is also the restore source
42//! [`crate::install::enable_mod`] copies from when re-enabling a `State`-
43//! tracked mod — and in server mode it must not be `target_dir` itself, or
44//! cache contents would sit inside the installed mod tree.
45
46#![deny(missing_docs)]
47
48/// Extract packages into the per-game package cache.
49pub mod cache;
50/// Compute a launch invocation and stage loader files.
51pub mod launch;
52/// Directory layout and profile lifecycle (named r2modman profiles; client mode).
53pub mod layout;
54/// The `mods.yml` model and its read/write.
55pub mod modlist;
56/// `.r2z` export/import and Thunderstore profile codes.
57pub mod portability;
58/// Where an install goes: a named profile, or the game directory itself.
59pub mod target;
60
61use crate::client::ThunderstoreClient;
62use crate::ecosystem::Ecosystem;
63use crate::error::{Error, Result};
64use crate::extract::parse_package_filename;
65use crate::install::{
66  apply_install, clear_state_files, plan_install, remove_delisted, remove_mod_folders,
67  remove_state_file, remove_state_payload, state_tracker_from_plan, write_state_file,
68};
69use crate::models::{DependencyGraph, PackageIndex, Version};
70use modlist::{ProfileMod, SemVer};
71use std::collections::{BTreeSet, HashMap};
72use std::path::{Path, PathBuf};
73
74/// Builds a [`ProfileMod`] from a Thunderstore [`Version`], applying the same
75/// defaults r2modman uses for a Thunderstore install (`fromThunderstoreCombo`):
76/// `manifestVersion=1`, `gameVersion="0"`, `networkMode="both"`,
77/// `packageType="other"`, `installMode="managed"`.
78pub fn mod_entry_from_version(
79  full_name: &str,
80  owner: &str,
81  version: &Version,
82  installed_at_time: u64,
83) -> ProfileMod {
84  let version_number = version
85    .version_number
86    .as_deref()
87    .map(SemVer::parse)
88    .unwrap_or(SemVer {
89      major: 0,
90      minor: 0,
91      patch: 0,
92    });
93
94  ProfileMod {
95    manifest_version: 1,
96    name: full_name.to_string(),
97    author_name: owner.to_string(),
98    website_url: version.website_url.clone().unwrap_or_default(),
99    display_name: version
100      .name
101      .clone()
102      .unwrap_or_else(|| full_name.to_string()),
103    description: version.description.clone().unwrap_or_default(),
104    game_version: "0".to_string(),
105    network_mode: "both".to_string(),
106    package_type: "other".to_string(),
107    install_mode: "managed".to_string(),
108    installed_at_time,
109    loaders: Vec::new(),
110    dependencies: version.dependencies.clone(),
111    incompatibilities: Vec::new(),
112    optional_dependencies: Vec::new(),
113    version_number,
114    enabled: true,
115    icon: version.icon.clone().unwrap_or_default(),
116    online_source: true,
117    trusted_package: false,
118  }
119}
120
121/// The per-item result of a batch operation.
122///
123/// A batch does not stop at the first failure: an operation that already mutated
124/// earlier items has to report which ones, so `failed` carries the reason per
125/// item rather than collapsing to a single error.
126#[derive(Debug, Default)]
127#[non_exhaustive]
128pub struct BatchOutcome {
129  /// Identifiers the operation completed.
130  ///
131  /// For [`uninstall_batch`] and [`set_enabled_batch`], this is exactly the
132  /// requested identifiers, in the order each completed, matching this
133  /// field's name at face value. For [`install_batch`] it does not: that
134  /// function sorts and deduplicates it, and fills it with the whole
135  /// dependency closure that installed rather than only what the caller
136  /// asked for. A caller zipping `batch.desired` against this positionally,
137  /// or reading `succeeded.len()` as "how many of my requested items
138  /// worked", is wrong for install by however many dependencies were pulled
139  /// in.
140  pub succeeded: Vec<String>,
141  /// Identifiers left alone because the version already recorded in `mods.yml`
142  /// is the one that would be installed. Only [`install_batch`] fills this;
143  /// like `succeeded` it covers the dependency closure, sorted and
144  /// deduplicated, and never names anything `succeeded` or `failed` also names.
145  pub unchanged: Vec<String>,
146  /// Identifiers that failed, each with the reason.
147  pub failed: Vec<(String, Error)>,
148}
149
150impl BatchOutcome {
151  /// Whether every item succeeded.
152  pub fn is_ok(&self) -> bool {
153    self.failed.is_empty()
154  }
155}
156
157/// What an install batch will install, and whose disabled state it must restore
158/// afterwards.
159#[derive(Debug, Clone, Default)]
160#[non_exhaustive]
161pub struct InstallBatch {
162  /// Every identifier to install. Dependency closures are resolved per item by
163  /// the install itself, so this is what the caller asked for, not the closure.
164  pub desired: Vec<String>,
165  /// Recorded mods whose `enabled: false` must be restored after the batch.
166  ///
167  /// An install upserts every dependency-closure member with `enabled: true` and
168  /// `modlist::upsert` replaces the entry wholesale, so without this a mod the
169  /// user disabled is silently re-enabled by a reinstall.
170  pub protect_disabled: Vec<String>,
171  /// Mods the caller wants enabled after the batch (e.g. the ones it named).
172  ///
173  /// A reinstall already leaves them enabled, but one skipped because its
174  /// recorded version is current is not reinstalled at all, so without this
175  /// naming a disabled mod would leave it disabled. A name that is already
176  /// enabled, or was not skipped, is left alone.
177  pub enable_requested: Vec<String>,
178  /// Reinstall each `desired` mod even when `mods.yml` already records the
179  /// version that would be installed. Applies to the `desired` names only, so
180  /// their dependencies are still skipped at a recorded version.
181  pub force: bool,
182}
183
184impl InstallBatch {
185  /// Builds a batch directly, for a caller that knows its own protect set rather
186  /// than deriving it from the target (e.g. an import applying an export's
187  /// recorded disabled state).
188  pub fn new(desired: Vec<String>, protect_disabled: Vec<String>) -> Self {
189    Self {
190      desired,
191      protect_disabled,
192      ..Self::default()
193    }
194  }
195}
196
197/// Plans an install batch against `target_dir`'s current record.
198///
199/// `desired` is everything to install. `explicit` is the subset the caller named
200/// directly: asking for a mod by name is a request to have it active, so a
201/// disabled one goes to `enable_requested` rather than `protect_disabled`. Pass
202/// an empty `explicit` when the batch is run on the user's behalf rather than at
203/// their request, so every disabled mod is restored.
204pub fn plan_install_batch(
205  target_dir: &Path,
206  desired: &[String],
207  explicit: &[String],
208) -> Result<InstallBatch> {
209  let recorded = modlist::read(target_dir)?;
210
211  let (enable_requested, protect_disabled): (Vec<String>, Vec<String>) = recorded
212    .iter()
213    .filter(|entry| !entry.enabled)
214    .map(|entry| entry.name.clone())
215    .partition(|name| {
216      explicit
217        .iter()
218        .any(|requested| crate::util::full_name_prefix(requested) == name.as_str())
219    });
220
221  Ok(InstallBatch {
222    desired: desired.to_vec(),
223    protect_disabled,
224    enable_requested,
225    force: false,
226  })
227}
228
229/// Re-disables everything `batch` protects, after the installs have run.
230///
231/// Must run after the whole batch, never interleaved: a mod later in a batch can
232/// pull an earlier one back in as a dependency, so only a final pass catches
233/// every re-enable. Runs even when the batch failed partway, since items
234/// installed before the failure are already enabled. A protected mod that is not
235/// installed, because its own install failed, has nothing to disable and is
236/// skipped, so the install's failure is the one reported.
237pub fn reapply_batch_disabled(
238  target_dir: &Path,
239  eco: &Ecosystem,
240  game: &str,
241  batch: &InstallBatch,
242) -> Result<()> {
243  let recorded = modlist::read(target_dir)?;
244
245  for full_name in &batch.protect_disabled {
246    if !recorded.iter().any(|entry| &entry.name == full_name) {
247      continue;
248    }
249
250    set_enabled_in(target_dir, eco, game, full_name, false)?;
251  }
252
253  Ok(())
254}
255
256/// Installs an already-extracted package into `target_dir`: plans the file copy,
257/// removes any copy already installed (its namespaced `<route>/<ident>/` folders
258/// and `State`-tracked files, as r2modman uninstalls before installing), applies
259/// the copy, writes the `_state` tracker for any `State`-tracked files, and
260/// upserts the `mods.yml` entry.
261///
262/// Shared `none`-tracked files (config) are left in place, and an existing one is
263/// not overwritten. A failure after the removal leaves the mod without files
264/// while `mods.yml` and its `_state` record still name it; reinstalling repairs
265/// that, and uninstalling still works.
266///
267/// This is the apply-only, target-directory primitive; the full download
268/// pipeline ([`install_mod_in`]) is layered on top of it.
269#[allow(clippy::too_many_arguments)]
270pub fn install_extracted_in(
271  target_dir: &Path,
272  eco: &Ecosystem,
273  game: &str,
274  full_name: &str,
275  owner: &str,
276  version: &Version,
277  extracted_dir: &Path,
278  archive: Option<&Path>,
279  installed_at_time: u64,
280) -> Result<()> {
281  let game_profile = eco
282    .game(game)
283    .and_then(|g| g.profile())
284    .ok_or_else(|| Error::Profile(format!("no ecosystem profile for game {game:?}")))?;
285
286  let plan = plan_install(eco, game_profile, full_name, extracted_dir, archive)?;
287
288  // Planned first so a package that cannot be routed fails before anything of
289  // the installed copy is removed. Shared `none` routes are never named here,
290  // so user config survives. The `_state` record outlives its payload until the
291  // copy lands, so a failed apply still leaves the mod uninstallable, which
292  // `ensure_removable` refuses for a loader with no record.
293  clear_state_files(target_dir, full_name)?;
294  remove_mod_folders(game_profile, target_dir, full_name)?;
295  apply_install(&plan, target_dir)?;
296
297  match state_tracker_from_plan(&plan, full_name) {
298    Some(tracker) => write_state_file(target_dir, &tracker)?,
299    None => remove_state_file(target_dir, full_name)?,
300  }
301
302  let mut mods = modlist::read(target_dir)?;
303
304  modlist::upsert(
305    &mut mods,
306    mod_entry_from_version(full_name, owner, version, installed_at_time),
307  );
308
309  modlist::write(target_dir, &mods)
310}
311
312/// Installs an already-extracted package into an r2modman-layout profile
313/// (`<base>/<game>/profiles/<profile_name>`). See [`install_extracted_in`].
314#[allow(clippy::too_many_arguments)]
315pub fn install_extracted(
316  base: &Path,
317  eco: &Ecosystem,
318  game: &str,
319  profile_name: &str,
320  full_name: &str,
321  owner: &str,
322  version: &Version,
323  extracted_dir: &Path,
324  archive: Option<&Path>,
325  installed_at_time: u64,
326) -> Result<()> {
327  install_extracted_in(
328    &layout::profile_dir(base, game, profile_name),
329    eco,
330    game,
331    full_name,
332    owner,
333    version,
334    extracted_dir,
335    archive,
336    installed_at_time,
337  )
338}
339
340/// Why a recorded mod's files cannot be removed exactly.
341///
342/// Returned rather than flattened into a message because the distinction drives
343/// what a caller should advise: an absent record can be rebuilt by reinstalling,
344/// while an unreadable one is a file the engine will not guess the contents of.
345/// Only the wording of that advice belongs to the caller, and a caller that
346/// words it itself should not have to re-derive the reason by re-reading the
347/// record the engine has already read.
348///
349/// Deliberately **not** `#[non_exhaustive]`, unlike the rest of this crate's
350/// public types, for the same reason [`launch::LaunchPlan::to_command`] gives: a
351/// caller that words each refusal should stop compiling when a new reason
352/// appears, rather than silently printing a generic fallback for a case nobody
353/// worded. Designing that fallback is the thing this type exists to avoid.
354#[derive(Debug)]
355pub enum NotRemovable {
356  /// A recognised mod loader with no install record at `path`, so which files it
357  /// placed is unknown. Reinstalling the loader rebuilds the record.
358  RecordAbsent {
359    /// The mod that cannot be removed.
360    name: String,
361    /// Where the record was expected.
362    path: PathBuf,
363  },
364  /// An install record exists at `path` but could not be read or parsed, so the
365  /// engine does not know which files it tracked and will not guess.
366  RecordUnreadable {
367    /// The mod that cannot be removed.
368    name: String,
369    /// The record that could not be read.
370    path: PathBuf,
371    /// Why it could not be read.
372    source: Error,
373  },
374}
375
376impl std::fmt::Display for NotRemovable {
377  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
378    match self {
379      Self::RecordAbsent { name, path } => write!(
380        f,
381        "{name} is a mod loader with no install record at {}, so its files \
382         cannot be removed exactly. Reinstall it, then uninstall it again, or \
383         remove its files by hand.",
384        path.display()
385      ),
386      Self::RecordUnreadable { name, path, source } => write!(
387        f,
388        "{name} has an install record at {} that cannot be read, so its files \
389         cannot be removed exactly: {source}",
390        path.display()
391      ),
392    }
393  }
394}
395
396impl From<NotRemovable> for Error {
397  fn from(value: NotRemovable) -> Self {
398    Error::Profile(value.to_string())
399  }
400}
401
402/// Whether `full_name`'s files can be removed exactly, reporting the reason
403/// when they cannot.
404///
405/// A *missing* record is only a problem for a package whose files are `State`
406/// tracked. A recognised mod loader always gets one when this engine installs
407/// it, so a missing record means the target was adopted from disk rather than
408/// installed and there is no way to know which files the loader placed;
409/// uninstalling anyway would drop the `mods.yml` entry and leave every one of
410/// its files orphaned. An ordinary mod, by contrast, has no record by design
411/// under install rules with no `state` route, and `remove_delisted` reconciles
412/// its folders by presence instead.
413///
414/// An *unreadable* record is always a problem, loader or not: the file exists,
415/// so something tracked files, and guessing which is how an uninstall orphans or
416/// over-deletes. That case arrives as the `Err` from
417/// [`crate::install::read_state_file`], which is carried as
418/// [`NotRemovable::RecordUnreadable`]'s `source` rather than propagated on its
419/// own, so the two reasons reach a caller as one type it can match.
420///
421/// Returns [`NotRemovable`] rather than [`Error`] because the two reasons want
422/// different advice and a caller wording that advice itself would otherwise have
423/// to re-read the record to tell them apart. `?` still converts it for a caller
424/// that does not care, via [`From<NotRemovable>`](NotRemovable).
425///
426/// Public and separate from [`uninstall_mod_in`] so a caller can pre-flight a
427/// whole batch and refuse before touching the first mod. `uninstall_mod_in`
428/// calls it too, so a single uninstall is protected whether or not the caller
429/// does.
430///
431/// Takes no `game`: the only schema consulted is the mod-loader registry, which
432/// [`Ecosystem::modloader_package`] keys on package identity rather than on a
433/// game, so a `game` argument would go unread.
434pub fn ensure_removable(
435  target_dir: &Path,
436  eco: &Ecosystem,
437  full_name: &str,
438) -> std::result::Result<(), NotRemovable> {
439  match crate::install::read_state_file(target_dir, full_name) {
440    Ok(Some(_)) => return Ok(()),
441    Ok(None) => {}
442    Err(source) => {
443      return Err(NotRemovable::RecordUnreadable {
444        name: full_name.to_string(),
445        path: crate::install::state_file_path(target_dir, full_name),
446        source,
447      });
448    }
449  }
450
451  if eco.modloader_package(full_name).is_none() {
452    return Ok(());
453  }
454
455  Err(NotRemovable::RecordAbsent {
456    name: full_name.to_string(),
457    path: crate::install::state_file_path(target_dir, full_name),
458  })
459}
460
461/// Uninstalls a mod from `target_dir`: removes its `mods.yml` entry,
462/// reconciles the on-disk namespaced folders against the remaining set (so only
463/// this mod's folders are removed), and removes its `State`-tracked payload
464/// files along with its `_state` file.
465///
466/// [`ensure_removable`] gates the whole thing before anything is touched, so a
467/// mod whose files cannot be removed exactly is refused rather than half
468/// removed. See its doc for which records qualify.
469///
470/// Both removal paths are needed: `remove_delisted` reconciles the per-mod
471/// `<Owner-Name>/` folders under namespaced routes, while `State`-tracked files
472/// sit directly under a shared route (e.g. `BepInEx/core/`) and are only
473/// recoverable from the `_state` record. This function does not special-case
474/// the mod loader beyond the refusal above: naming it as `full_name` removes
475/// it through these same two paths like any other mod, which is how callers
476/// such as `vmm uninstall --all` uninstall it. Only shared config
477/// (`none`-tracked) is unconditionally left untouched, since nothing this
478/// function does ever names it for removal.
479pub fn uninstall_mod_in(
480  target_dir: &Path,
481  eco: &Ecosystem,
482  game: &str,
483  full_name: &str,
484) -> Result<()> {
485  let game_profile = eco
486    .game(game)
487    .and_then(|g| g.profile())
488    .ok_or_else(|| Error::Profile(format!("no ecosystem profile for game {game:?}")))?;
489
490  // Before any mutation. `remove_state_payload` below reaches an unreadable
491  // record on its own, but only after `remove_delisted` has already deleted this
492  // mod's folders and before `mods.yml` is rewritten, so the entry would be left
493  // naming files that are gone. Refusing here is what keeps both sides intact.
494  ensure_removable(target_dir, eco, full_name)?;
495
496  let mut mods = modlist::read(target_dir)?;
497
498  mods.retain(|m| m.name != full_name);
499
500  let keep: BTreeSet<String> = mods.iter().map(|m| m.name.clone()).collect();
501
502  remove_delisted(game_profile, target_dir, &keep)?;
503  remove_state_payload(target_dir, full_name)?;
504
505  modlist::write(target_dir, &mods)
506}
507
508/// Uninstalls a mod from an r2modman-layout profile. See [`uninstall_mod_in`].
509pub fn uninstall_mod(
510  base: &Path,
511  eco: &Ecosystem,
512  game: &str,
513  profile_name: &str,
514  full_name: &str,
515) -> Result<()> {
516  uninstall_mod_in(
517    &layout::profile_dir(base, game, profile_name),
518    eco,
519    game,
520    full_name,
521  )
522}
523
524/// Enables or disables an installed mod in `target_dir`: applies the on-disk
525/// file toggle through the install layer, then flips `enabled` in `mods.yml`.
526/// Errors if the mod is not present in `target_dir`.
527///
528/// A mod-loader pack (see [`Ecosystem::modloader_package`]) is a no-op here,
529/// matching r2modman: loaders can't be disabled, so neither the on-disk files
530/// nor its `enabled` flag are touched.
531pub fn set_enabled_in(
532  target_dir: &Path,
533  eco: &Ecosystem,
534  game: &str,
535  full_name: &str,
536  enabled: bool,
537) -> Result<()> {
538  let game_profile = eco
539    .game(game)
540    .and_then(|g| g.profile())
541    .ok_or_else(|| Error::Profile(format!("no ecosystem profile for game {game:?}")))?;
542
543  let mut mods = modlist::read(target_dir)?;
544
545  let entry = mods
546    .iter_mut()
547    .find(|m| m.name == full_name)
548    .ok_or_else(|| Error::Profile(format!("{full_name} is not installed")))?;
549
550  if eco.modloader_package(full_name).is_some() {
551    return Ok(());
552  }
553
554  if enabled {
555    crate::install::enable_mod(game_profile, target_dir, full_name)?;
556  } else {
557    crate::install::disable_mod(game_profile, target_dir, full_name)?;
558  }
559
560  entry.enabled = enabled;
561
562  modlist::write(target_dir, &mods)
563}
564
565/// Enables or disables an installed mod in an r2modman-layout profile. See
566/// [`set_enabled_in`].
567pub fn set_enabled(
568  base: &Path,
569  eco: &Ecosystem,
570  game: &str,
571  profile_name: &str,
572  full_name: &str,
573  enabled: bool,
574) -> Result<()> {
575  set_enabled_in(
576    &layout::profile_dir(base, game, profile_name),
577    eco,
578    game,
579    full_name,
580    enabled,
581  )
582}
583
584/// Enables an installed mod in `target_dir`. See [`set_enabled_in`].
585pub fn enable_mod_in(
586  target_dir: &Path,
587  eco: &Ecosystem,
588  game: &str,
589  full_name: &str,
590) -> Result<()> {
591  set_enabled_in(target_dir, eco, game, full_name, true)
592}
593
594/// Disables an installed mod in `target_dir`. See [`set_enabled_in`].
595pub fn disable_mod_in(
596  target_dir: &Path,
597  eco: &Ecosystem,
598  game: &str,
599  full_name: &str,
600) -> Result<()> {
601  set_enabled_in(target_dir, eco, game, full_name, false)
602}
603
604/// Enables an installed mod. See [`set_enabled`].
605pub fn enable_mod(
606  base: &Path,
607  eco: &Ecosystem,
608  game: &str,
609  profile_name: &str,
610  full_name: &str,
611) -> Result<()> {
612  set_enabled(base, eco, game, profile_name, full_name, true)
613}
614
615/// Disables an installed mod. See [`set_enabled`].
616pub fn disable_mod(
617  base: &Path,
618  eco: &Ecosystem,
619  game: &str,
620  profile_name: &str,
621  full_name: &str,
622) -> Result<()> {
623  set_enabled(base, eco, game, profile_name, full_name, false)
624}
625
626/// Installs a mod and its full dependency closure into `target_dir`: resolves
627/// the download set against `index`, downloads the archives via `client`,
628/// extracts each into `cache_base`'s per-game package cache, and installs
629/// each into `target_dir` (updating `mods.yml`).
630///
631/// A package whose version `mods.yml` already records, and whose files that
632/// version installs are still on disk, is skipped before it is downloaded, as
633/// r2modman does, unless it is `full_name` itself and `force` is set. A recorded
634/// package with missing files is reinstalled, which repairs a failed install or
635/// a hand-deleted folder. Returns the sorted `Owner-Name` identifiers installed
636/// and those skipped.
637///
638/// `cache_base` is deliberately independent of `target_dir`: packages are
639/// extracted *from* it, never installed *into* it. See the module docs for
640/// why it must persist across installs and, in server mode, must not be
641/// `target_dir` itself.
642#[allow(clippy::too_many_arguments)]
643async fn install_one(
644  target_dir: &Path,
645  cache_base: &Path,
646  eco: &Ecosystem,
647  index: &PackageIndex,
648  client: &ThunderstoreClient,
649  game: &str,
650  full_name: &str,
651  pins: &[String],
652  force: bool,
653  installed_at_time: u64,
654) -> Result<(Vec<String>, Vec<String>)> {
655  let resolution = DependencyGraph::new(vec![full_name.to_string()])
656    .pins(pins.iter().cloned())
657    .resolve_report(index);
658
659  // A pinned version the package does not publish stops resolution before any
660  // dependency is reached, so the request itself is the only entry.
661  if let [unresolved] = resolution.unresolved.as_slice()
662    && unresolved == full_name
663    && let Some((name, version)) = crate::util::split_version(full_name)
664  {
665    return Err(Error::Profile(format!(
666      "cannot install {full_name}: {name} has no version {version}"
667    )));
668  }
669
670  if !resolution.unresolved.is_empty() {
671    return Err(Error::Profile(format!(
672      "cannot install {full_name}: could not resolve {}",
673      resolution.unresolved.join(", ")
674    )));
675  }
676
677  let recorded = modlist::read(target_dir)?;
678  let forced = force.then(|| crate::util::full_name_prefix(full_name));
679  let downloads = client.cache_dir().join("downloads");
680  let mut urls = HashMap::new();
681  let mut unchanged = Vec::new();
682
683  for (filename, url) in resolution.urls {
684    let (pkg_full_name, version_str) = parse_package_filename(&filename)
685      .ok_or_else(|| Error::Profile(format!("unparseable package filename {filename:?}")))?;
686
687    let version = SemVer::parse(&version_str);
688    let current = forced != Some(pkg_full_name.as_str())
689      && recorded
690        .iter()
691        .find(|entry| entry.name == pkg_full_name && entry.version_number == version)
692        .is_some_and(|entry| {
693          recorded_files_present(
694            target_dir,
695            cache_base,
696            eco,
697            game,
698            entry,
699            &version_str,
700            &downloads.join(&filename),
701          )
702        });
703
704    if current {
705      unchanged.push(pkg_full_name);
706    } else {
707      urls.insert(filename, url);
708    }
709  }
710
711  client.download_files(urls.clone()).await?;
712
713  let mut installed = Vec::new();
714
715  for filename in urls.keys() {
716    let (pkg_full_name, version_str) = parse_package_filename(filename)
717      .ok_or_else(|| Error::Profile(format!("unparseable package filename {filename:?}")))?;
718
719    let archive = downloads.join(filename);
720
721    let extracted =
722      cache::ensure_extracted(cache_base, game, &pkg_full_name, &version_str, &archive)?;
723
724    let package = index
725      .get_package_by_full_name(&pkg_full_name)
726      .ok_or_else(|| Error::Profile(format!("{pkg_full_name} missing from index")))?;
727
728    let owner = package.owner.clone().unwrap_or_default();
729    let version = package
730      .versions
731      .iter()
732      .find(|v| v.version_number.as_deref() == Some(version_str.as_str()))
733      .or_else(|| package.latest_version())
734      .ok_or_else(|| Error::Profile(format!("{pkg_full_name} has no usable version")))?;
735
736    install_extracted_in(
737      target_dir,
738      eco,
739      game,
740      &pkg_full_name,
741      &owner,
742      version,
743      &extracted,
744      Some(&archive),
745      installed_at_time,
746    )?;
747
748    installed.push(pkg_full_name);
749  }
750
751  installed.sort();
752  unchanged.sort();
753
754  Ok((installed, unchanged))
755}
756
757/// Whether the files `entry` installed at `version` are still in place, judged
758/// from the package's cached extraction. A missing cache, an unknown game or a
759/// package that no longer plans counts as not in place, so the install runs and
760/// either repairs the mod or reports why it cannot.
761fn recorded_files_present(
762  target_dir: &Path,
763  cache_base: &Path,
764  eco: &Ecosystem,
765  game: &str,
766  entry: &ProfileMod,
767  version: &str,
768  archive: &Path,
769) -> bool {
770  let extracted = cache::package_cache_dir(cache_base, game, &entry.name, version);
771
772  if !extracted.is_dir() {
773    return false;
774  }
775
776  let Some(game_profile) = eco.game(game).and_then(|g| g.profile()) else {
777    return false;
778  };
779
780  let archive = archive.exists().then_some(archive);
781
782  plan_install(eco, game_profile, &entry.name, &extracted, archive).is_ok_and(|plan| {
783    crate::install::plan_is_on_disk(&plan, target_dir, &entry.name, entry.enabled)
784  })
785}
786
787/// Installs every identifier in `batch`, restoring protected disabled state
788/// afterwards, and reports per-item outcomes.
789///
790/// Failures do not abort the batch: each item is attempted and its reason
791/// recorded, because an operation that already installed earlier items has to
792/// say which ones. The disabled-state restore runs regardless, since items
793/// installed before a failure are already enabled. A requested enable that
794/// fails is recorded the same way.
795///
796/// A `desired` entry of `Owner-Name-Version` installs exactly that version, and
797/// the pin holds for the rest of the batch.
798#[allow(clippy::too_many_arguments)]
799pub async fn install_batch(
800  target_dir: &Path,
801  cache_base: &Path,
802  eco: &Ecosystem,
803  index: &PackageIndex,
804  client: &ThunderstoreClient,
805  game: &str,
806  batch: &InstallBatch,
807  installed_at_time: u64,
808) -> Result<BatchOutcome> {
809  let mut outcome = BatchOutcome::default();
810
811  for full_name in &batch.desired {
812    match install_one(
813      target_dir,
814      cache_base,
815      eco,
816      index,
817      client,
818      game,
819      full_name,
820      &batch.desired,
821      batch.force,
822      installed_at_time,
823    )
824    .await
825    {
826      Ok((installed, unchanged)) => {
827        outcome.succeeded.extend(installed);
828        outcome.unchanged.extend(unchanged);
829      }
830      Err(error) => outcome.failed.push((full_name.clone(), error)),
831    }
832  }
833
834  reapply_batch_disabled(target_dir, eco, game, batch)?;
835
836  outcome.succeeded.sort();
837  outcome.succeeded.dedup();
838  outcome.unchanged.sort();
839  outcome.unchanged.dedup();
840  outcome
841    .unchanged
842    .retain(|name| outcome.succeeded.binary_search(name).is_err());
843
844  // A reinstalled mod is already enabled; only one skipped as current can still
845  // carry the disabled state the caller asked to clear.
846  let recorded = modlist::read(target_dir)?;
847  let disabled: BTreeSet<&str> = recorded
848    .iter()
849    .filter(|entry| !entry.enabled)
850    .map(|entry| entry.name.as_str())
851    .collect();
852
853  for full_name in &batch.enable_requested {
854    if disabled.contains(full_name.as_str())
855      && outcome.unchanged.binary_search(full_name).is_ok()
856      && let Err(error) = set_enabled_in(target_dir, eco, game, full_name, true)
857    {
858      outcome.failed.push((full_name.clone(), error));
859    }
860  }
861
862  // A mod whose enable failed is reported as failed, not also as unchanged.
863  let failed: BTreeSet<&String> = outcome.failed.iter().map(|(name, _)| name).collect();
864
865  outcome.unchanged.retain(|name| !failed.contains(name));
866
867  Ok(outcome)
868}
869
870/// Installs one mod and its dependency closure, protecting the disabled state of
871/// every recorded mod it did not name. See [`install_batch`].
872///
873/// Returns every `Owner-Name` actually installed, which is the named package
874/// plus its transitive dependencies. A package already recorded at the version
875/// that would be installed, with its files still on disk, is skipped and not
876/// listed, so this is empty when the whole closure is current; use
877/// [`install_batch`] with [`InstallBatch::force`] to reinstall regardless.
878///
879/// `cache_base` must not be `target_dir` or a directory inside it, or extracted
880/// packages would land inside the installed mod tree.
881///
882/// # Errors
883///
884/// - [`Error::Http`] or
885///   [`Error::Download`] if fetching the package or a
886///   dependency fails.
887/// - [`Error::Install`] if the ecosystem has no install
888///   rules for `game`, or a package's files cannot be routed.
889/// - [`Error::Io`] if `target_dir` or `cache_base` cannot be
890///   written.
891///
892/// A package that is absent from `index` is skipped rather than reported here;
893/// see [`crate::models::DependencyGraph::resolve_report`] to detect that.
894///
895/// # Examples
896///
897/// ```no_run
898/// use std::path::Path;
899/// use thunderstore_engine::client::ThunderstoreClient;
900/// use thunderstore_engine::ecosystem::Ecosystem;
901/// use thunderstore_engine::profile::{install_mod_in, modlist};
902///
903/// # async fn run() -> thunderstore_engine::Result<()> {
904/// let client = ThunderstoreClient::builder()
905///   .community("valheim")
906///   .cache_dir("/var/cache/my-mod-manager")
907///   .build()?;
908///
909/// let index = client.get_manifest().await?;
910/// let eco = Ecosystem::bundled();
911///
912/// let installed = install_mod_in(
913///   Path::new("/srv/valheim"),
914///   Path::new("/var/cache/my-mod-manager"),
915///   &eco,
916///   &index,
917///   &client,
918///   "valheim",
919///   "denikson-BepInExPack_Valheim",
920///   modlist::now_millis(),
921/// )
922/// .await?;
923///
924/// println!("installed {} packages", installed.len());
925/// # Ok(())
926/// # }
927/// ```
928#[allow(clippy::too_many_arguments)]
929pub async fn install_mod_in(
930  target_dir: &Path,
931  cache_base: &Path,
932  eco: &Ecosystem,
933  index: &PackageIndex,
934  client: &ThunderstoreClient,
935  game: &str,
936  full_name: &str,
937  installed_at_time: u64,
938) -> Result<Vec<String>> {
939  install_named_in(
940    target_dir,
941    cache_base,
942    eco,
943    index,
944    client,
945    game,
946    full_name,
947    false,
948    installed_at_time,
949  )
950  .await
951}
952
953/// Installs one named mod and its dependency closure, surfacing the first
954/// per-item failure as the error. With `force`, the named mod is reinstalled
955/// even when `mods.yml` already records its version. Shared by
956/// [`install_mod_in`] and adoption, which forces the loader reinstall.
957#[allow(clippy::too_many_arguments)]
958pub(crate) async fn install_named_in(
959  target_dir: &Path,
960  cache_base: &Path,
961  eco: &Ecosystem,
962  index: &PackageIndex,
963  client: &ThunderstoreClient,
964  game: &str,
965  full_name: &str,
966  force: bool,
967  installed_at_time: u64,
968) -> Result<Vec<String>> {
969  let named = [full_name.to_string()];
970  let mut batch = plan_install_batch(target_dir, &named, &named)?;
971
972  batch.force = force;
973
974  let outcome = install_batch(
975    target_dir,
976    cache_base,
977    eco,
978    index,
979    client,
980    game,
981    &batch,
982    installed_at_time,
983  )
984  .await?;
985
986  match outcome.failed.into_iter().next() {
987    Some((_, error)) => Err(error),
988    None => Ok(outcome.succeeded),
989  }
990}
991
992/// Installs a mod and its full dependency closure into an r2modman-layout
993/// profile, using `base` as both the profile base and the package cache
994/// base (matching this pipeline's pre-existing behavior). See
995/// [`install_mod_in`].
996#[allow(clippy::too_many_arguments)]
997pub async fn install_mod(
998  base: &Path,
999  eco: &Ecosystem,
1000  index: &PackageIndex,
1001  client: &ThunderstoreClient,
1002  game: &str,
1003  profile_name: &str,
1004  full_name: &str,
1005  installed_at_time: u64,
1006) -> Result<Vec<String>> {
1007  install_mod_in(
1008    &layout::profile_dir(base, game, profile_name),
1009    base,
1010    eco,
1011    index,
1012    client,
1013    game,
1014    full_name,
1015    installed_at_time,
1016  )
1017  .await
1018}
1019
1020/// What an uninstall batch will remove.
1021///
1022/// Mods and loaders are held apart because a caller showing this to a user needs
1023/// to say the loader goes too, and because removing loaders last keeps every
1024/// intermediate state one a user could have reached by hand. Ordinarily there is
1025/// at most one loader, but the registry is keyed on package identity rather than
1026/// game, so nothing rules out a second.
1027#[derive(Debug, Clone, Default)]
1028#[non_exhaustive]
1029pub struct UninstallBatch {
1030  /// Every non-loader identifier to remove, sorted.
1031  pub mods: Vec<String>,
1032  /// Every recognised mod loader to remove, sorted, removed after `mods`.
1033  pub loaders: Vec<String>,
1034  /// Root-relative mod folders the record does not account for, which the
1035  /// reconcile sweep removes along with the named mods.
1036  pub untracked: Vec<PathBuf>,
1037}
1038
1039/// Every one of `names` that cannot be removed exactly, with the reason.
1040///
1041/// Separate from [`plan_uninstall_batch`], which refuses outright, so a caller
1042/// can list every problem at once instead of discovering them one at a time. The
1043/// reason is a [`NotRemovable`] rather than an [`Error`] so a caller can word
1044/// each kind of refusal without re-deriving which kind it is.
1045pub fn check_removable_batch(
1046  target_dir: &Path,
1047  eco: &Ecosystem,
1048  names: &[String],
1049) -> Vec<(String, NotRemovable)> {
1050  names
1051    .iter()
1052    .filter_map(|name| match ensure_removable(target_dir, eco, name) {
1053      Ok(()) => None,
1054      Err(refusal) => Some((name.clone(), refusal)),
1055    })
1056    .collect()
1057}
1058
1059/// Plans an uninstall, refusing before anything is mutated.
1060///
1061/// `names` of `None` means every recorded mod. A name that is not installed, or
1062/// any mod that cannot be removed exactly, is an error rather than a per-item
1063/// failure: this runs before any mutation, so refusing the whole request is what
1064/// keeps a batch from failing partway through.
1065pub fn plan_uninstall_batch(
1066  target_dir: &Path,
1067  eco: &Ecosystem,
1068  game: &str,
1069  names: Option<&[String]>,
1070) -> Result<UninstallBatch> {
1071  let game_profile = eco
1072    .game(game)
1073    .and_then(|g| g.profile())
1074    .ok_or_else(|| Error::Profile(format!("no ecosystem profile for game {game:?}")))?;
1075
1076  let recorded = modlist::read(target_dir)?;
1077
1078  let subjects: Vec<String> = match names {
1079    Some(names) => {
1080      for name in names {
1081        if modlist::find(&recorded, name).is_none() {
1082          return Err(Error::Profile(format!("{name} is not installed")));
1083        }
1084      }
1085
1086      names.to_vec()
1087    }
1088    None => recorded.iter().map(|entry| entry.name.clone()).collect(),
1089  };
1090
1091  if let Some((name, refusal)) = check_removable_batch(target_dir, eco, &subjects)
1092    .into_iter()
1093    .next()
1094  {
1095    return Err(Error::Profile(format!(
1096      "{name} cannot be removed: {refusal}"
1097    )));
1098  }
1099
1100  let mut mods: Vec<String> = subjects
1101    .iter()
1102    .filter(|name| eco.modloader_package(name).is_none())
1103    .cloned()
1104    .collect();
1105  let mut loaders: Vec<String> = subjects
1106    .iter()
1107    .filter(|name| eco.modloader_package(name).is_some())
1108    .cloned()
1109    .collect();
1110
1111  mods.sort();
1112  loaders.sort();
1113
1114  let keep: BTreeSet<String> = recorded.iter().map(|entry| entry.name.clone()).collect();
1115  let untracked = crate::install::plan_delisted(game_profile, target_dir, &keep)?;
1116
1117  Ok(UninstallBatch {
1118    mods,
1119    loaders,
1120    untracked,
1121  })
1122}
1123
1124/// Removes everything `batch` names, loaders last, reporting per-item outcomes.
1125///
1126/// Each removal re-derives its keep-set from the remaining record, so ordering is
1127/// not required for correctness; loaders go last so every intermediate state is
1128/// coherent.
1129pub fn uninstall_batch(
1130  target_dir: &Path,
1131  eco: &Ecosystem,
1132  game: &str,
1133  batch: &UninstallBatch,
1134) -> Result<BatchOutcome> {
1135  let mut outcome = BatchOutcome::default();
1136
1137  for full_name in batch.mods.iter().chain(batch.loaders.iter()) {
1138    match uninstall_mod_in(target_dir, eco, game, full_name) {
1139      Ok(()) => outcome.succeeded.push(full_name.clone()),
1140      Err(error) => outcome.failed.push((full_name.clone(), error)),
1141    }
1142  }
1143
1144  Ok(outcome)
1145}
1146
1147/// What a bulk enable or disable will do.
1148///
1149/// Loaders are excluded rather than merely skipped. `set_enabled_in` already
1150/// no-ops a loader, so including one would be harmless, but it would be counted
1151/// as a mod the operation acted on and any summary built from this would overstate
1152/// what happened.
1153#[derive(Debug, Clone, Default)]
1154#[non_exhaustive]
1155pub struct ToggleBatch {
1156  /// Identifiers whose state will change, sorted.
1157  pub changing: Vec<String>,
1158  /// Identifiers already in the requested state, sorted.
1159  pub already: Vec<String>,
1160  /// Recognised loaders, excluded from the toggle entirely, sorted.
1161  pub loaders: Vec<String>,
1162  /// The sense this batch was planned for: `true` to enable, `false` to
1163  /// disable. `set_enabled_batch` applies this instead of taking its own
1164  /// `enabled` argument, so a caller cannot execute a batch against a sense it
1165  /// was not planned for.
1166  pub enabled: bool,
1167}
1168
1169/// Plans a bulk enable or disable. `names` of `None` means every recorded mod.
1170///
1171/// A name in `names` that is not recorded at all is refused outright, in the
1172/// same shape as [`plan_uninstall_batch`]'s refusal, rather than silently
1173/// producing an empty plan: iterating the record and filtering would let an
1174/// unrecorded name simply never appear, indistinguishable from "nothing to
1175/// do".
1176pub fn plan_set_enabled_batch(
1177  target_dir: &Path,
1178  eco: &Ecosystem,
1179  names: Option<&[String]>,
1180  enabled: bool,
1181) -> Result<ToggleBatch> {
1182  let recorded = modlist::read(target_dir)?;
1183
1184  if let Some(names) = names {
1185    for name in names {
1186      if modlist::find(&recorded, name).is_none() {
1187        return Err(Error::Profile(format!("{name} is not installed")));
1188      }
1189    }
1190  }
1191
1192  let mut batch = ToggleBatch {
1193    enabled,
1194    ..ToggleBatch::default()
1195  };
1196
1197  for entry in &recorded {
1198    if let Some(names) = names
1199      && !names.iter().any(|name| name == &entry.name)
1200    {
1201      continue;
1202    }
1203
1204    if eco.modloader_package(&entry.name).is_some() {
1205      batch.loaders.push(entry.name.clone());
1206    } else if entry.enabled == enabled {
1207      batch.already.push(entry.name.clone());
1208    } else {
1209      batch.changing.push(entry.name.clone());
1210    }
1211  }
1212
1213  batch.changing.sort();
1214  batch.already.sort();
1215  batch.loaders.sort();
1216
1217  Ok(batch)
1218}
1219
1220/// Applies `batch`, reporting per-item outcomes.
1221///
1222/// One mod's failure does not abort the rest: returning early would leave a
1223/// half-applied toggle with nothing reported. Applies `batch.enabled`, the
1224/// sense it was planned for, rather than taking its own `enabled` argument, so
1225/// the executor cannot disagree with the plan it is executing.
1226pub fn set_enabled_batch(
1227  target_dir: &Path,
1228  eco: &Ecosystem,
1229  game: &str,
1230  batch: &ToggleBatch,
1231) -> Result<BatchOutcome> {
1232  let mut outcome = BatchOutcome::default();
1233
1234  for full_name in &batch.changing {
1235    match set_enabled_in(target_dir, eco, game, full_name, batch.enabled) {
1236      Ok(()) => outcome.succeeded.push(full_name.clone()),
1237      Err(error) => outcome.failed.push((full_name.clone(), error)),
1238    }
1239  }
1240
1241  Ok(outcome)
1242}
1243
1244#[cfg(test)]
1245mod tests {
1246  use super::*;
1247  use crate::ecosystem::Ecosystem;
1248  use crate::install::state_file_path;
1249  use crate::models::Version;
1250  use std::io::{Cursor, Write};
1251  use tempfile::tempdir;
1252  use time::OffsetDateTime;
1253  use zip::write::{FileOptions, ZipWriter};
1254
1255  fn write_file(path: &std::path::Path, bytes: &[u8]) {
1256    std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1257    std::fs::File::create(path)
1258      .unwrap()
1259      .write_all(bytes)
1260      .unwrap();
1261  }
1262
1263  fn version(full_name: &str) -> Version {
1264    Version {
1265      name: Some("Mod".to_string()),
1266      full_name: Some(full_name.to_string()),
1267      description: Some("desc".to_string()),
1268      icon: Some("icon.png".to_string()),
1269      version_number: Some("1.2.3".to_string()),
1270      dependencies: vec!["Other-Dep-1.0.0".to_string()],
1271      download_url: Some("https://example.com/dl".to_string()),
1272      downloads: Some(1),
1273      date_created: OffsetDateTime::UNIX_EPOCH,
1274      website_url: Some("https://example.com".to_string()),
1275      is_active: Some(true),
1276      uuid4: Some("u".to_string()),
1277      file_size: Some(1),
1278    }
1279  }
1280
1281  #[test]
1282  fn mod_entry_maps_version_with_r2modman_defaults() {
1283    let entry = mod_entry_from_version("Author-Mod", "Author", &version("Author-Mod"), 123);
1284
1285    assert_eq!(entry.name, "Author-Mod");
1286    assert_eq!(entry.author_name, "Author");
1287    assert_eq!(entry.network_mode, "both");
1288    assert_eq!(entry.package_type, "other");
1289    assert_eq!(entry.install_mode, "managed");
1290    assert_eq!(entry.game_version, "0");
1291    assert_eq!(entry.manifest_version, 1);
1292    assert_eq!(
1293      entry.version_number,
1294      crate::profile::modlist::SemVer {
1295        major: 1,
1296        minor: 2,
1297        patch: 3
1298      }
1299    );
1300    assert_eq!(entry.dependencies, vec!["Other-Dep-1.0.0".to_string()]);
1301    assert_eq!(entry.installed_at_time, 123);
1302    assert!(entry.enabled);
1303  }
1304
1305  #[test]
1306  fn install_extracted_places_files_and_records_mods_yml() {
1307    let eco = Ecosystem::bundled();
1308
1309    let dir = tempdir().unwrap();
1310    let base = dir.path();
1311
1312    let extracted = tempdir().unwrap();
1313    write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
1314    write_file(&extracted.path().join("manifest.json"), b"{}");
1315
1316    crate::profile::layout::create(base, "valheim", "Main").unwrap();
1317
1318    install_extracted(
1319      base,
1320      &eco,
1321      "valheim",
1322      "Main",
1323      "Author-Mod",
1324      "Author",
1325      &version("Author-Mod"),
1326      extracted.path(),
1327      None,
1328      123,
1329    )
1330    .unwrap();
1331
1332    let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
1333
1334    assert!(
1335      profile_dir
1336        .join("BepInEx/plugins/Author-Mod/Mod.dll")
1337        .exists()
1338    );
1339
1340    let mods = crate::profile::modlist::read(&profile_dir).unwrap();
1341
1342    assert_eq!(mods.len(), 1);
1343    assert_eq!(mods[0].name, "Author-Mod");
1344    assert!(mods[0].enabled);
1345  }
1346
1347  #[test]
1348  fn uninstall_removes_files_and_mods_yml_entry() {
1349    let eco = Ecosystem::bundled();
1350
1351    let dir = tempdir().unwrap();
1352    let base = dir.path();
1353
1354    let extracted = tempdir().unwrap();
1355    write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
1356
1357    crate::profile::layout::create(base, "valheim", "Main").unwrap();
1358
1359    install_extracted(
1360      base,
1361      &eco,
1362      "valheim",
1363      "Main",
1364      "Author-Mod",
1365      "Author",
1366      &version("Author-Mod"),
1367      extracted.path(),
1368      None,
1369      1,
1370    )
1371    .unwrap();
1372
1373    let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
1374
1375    assert!(
1376      profile_dir
1377        .join("BepInEx/plugins/Author-Mod/Mod.dll")
1378        .exists()
1379    );
1380
1381    uninstall_mod(base, &eco, "valheim", "Main", "Author-Mod").unwrap();
1382
1383    assert!(!profile_dir.join("BepInEx/plugins/Author-Mod").exists());
1384    assert_eq!(
1385      crate::profile::modlist::read(&profile_dir).unwrap().len(),
1386      0
1387    );
1388  }
1389
1390  #[test]
1391  fn uninstall_refuses_a_mod_whose_install_record_cannot_be_read() {
1392    let eco = Ecosystem::bundled();
1393
1394    let dir = tempdir().unwrap();
1395    let base = dir.path();
1396
1397    let extracted = tempdir().unwrap();
1398    write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
1399
1400    crate::profile::layout::create(base, "valheim", "Main").unwrap();
1401    install_extracted(
1402      base,
1403      &eco,
1404      "valheim",
1405      "Main",
1406      "Author-Mod",
1407      "Author",
1408      &version("Author-Mod"),
1409      extracted.path(),
1410      None,
1411      1,
1412    )
1413    .unwrap();
1414
1415    let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
1416
1417    // An ordinary mod routes entirely through its namespaced folder, so it gets
1418    // no tracker of its own. Nothing stops one existing anyway (a stray
1419    // leftover, or one written by hand), so an unparseable one is planted here.
1420    write_file(&state_file_path(&profile_dir, "Author-Mod"), b"not: [valid");
1421
1422    let error = uninstall_mod(base, &eco, "valheim", "Main", "Author-Mod").unwrap_err();
1423
1424    // `remove_state_payload` reaches the same unparseable record on its own, but
1425    // only after `remove_delisted` has deleted the folder and before `mods.yml`
1426    // is rewritten, which leaves the entry naming files that are already gone.
1427    // Refusing up front is what keeps both sides intact.
1428    assert!(
1429      profile_dir
1430        .join("BepInEx/plugins/Author-Mod/Mod.dll")
1431        .exists(),
1432      "the mod's files must survive a refusal; got error: {error}"
1433    );
1434    assert_eq!(
1435      crate::profile::modlist::read(&profile_dir).unwrap().len(),
1436      1
1437    );
1438  }
1439
1440  #[test]
1441  fn disable_then_enable_updates_disk_and_mods_yml() {
1442    let eco = Ecosystem::bundled();
1443
1444    let dir = tempdir().unwrap();
1445    let base = dir.path();
1446
1447    let extracted = tempdir().unwrap();
1448    write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
1449
1450    crate::profile::layout::create(base, "valheim", "Main").unwrap();
1451    install_extracted(
1452      base,
1453      &eco,
1454      "valheim",
1455      "Main",
1456      "Author-Mod",
1457      "Author",
1458      &version("Author-Mod"),
1459      extracted.path(),
1460      None,
1461      1,
1462    )
1463    .unwrap();
1464
1465    let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
1466
1467    disable_mod(base, &eco, "valheim", "Main", "Author-Mod").unwrap();
1468
1469    assert!(
1470      profile_dir
1471        .join("BepInEx/plugins/Author-Mod/Mod.dll.old")
1472        .exists()
1473    );
1474    assert!(!crate::profile::modlist::read(&profile_dir).unwrap()[0].enabled);
1475
1476    enable_mod(base, &eco, "valheim", "Main", "Author-Mod").unwrap();
1477
1478    assert!(
1479      profile_dir
1480        .join("BepInEx/plugins/Author-Mod/Mod.dll")
1481        .exists()
1482    );
1483    assert!(crate::profile::modlist::read(&profile_dir).unwrap()[0].enabled);
1484  }
1485
1486  #[test]
1487  fn disable_leaves_files_alone_when_the_install_record_cannot_be_read() {
1488    let eco = Ecosystem::bundled();
1489
1490    let dir = tempdir().unwrap();
1491    let base = dir.path();
1492
1493    let extracted = tempdir().unwrap();
1494    write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
1495
1496    crate::profile::layout::create(base, "valheim", "Main").unwrap();
1497    install_extracted(
1498      base,
1499      &eco,
1500      "valheim",
1501      "Main",
1502      "Author-Mod",
1503      "Author",
1504      &version("Author-Mod"),
1505      extracted.path(),
1506      None,
1507      1,
1508    )
1509    .unwrap();
1510
1511    let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
1512
1513    write_file(&state_file_path(&profile_dir, "Author-Mod"), b"not: [valid");
1514
1515    let error = disable_mod(base, &eco, "valheim", "Main", "Author-Mod").unwrap_err();
1516
1517    // An unreadable record already refused a disable, but only once the `.old`
1518    // rename pass had run, and `mods.yml` is written last of all. That left the
1519    // files disabled on disk and the entry still claiming `enabled`. Reading the
1520    // record up front is what keeps the two in step.
1521    assert!(
1522      profile_dir
1523        .join("BepInEx/plugins/Author-Mod/Mod.dll")
1524        .exists(),
1525      "the mod's files must be left as they were; got error: {error}"
1526    );
1527    assert!(
1528      !profile_dir
1529        .join("BepInEx/plugins/Author-Mod/Mod.dll.old")
1530        .exists()
1531    );
1532    assert!(crate::profile::modlist::read(&profile_dir).unwrap()[0].enabled);
1533  }
1534
1535  #[test]
1536  fn disable_mod_is_noop_for_modloader_pack() {
1537    let eco = Ecosystem::bundled();
1538
1539    let dir = tempdir().unwrap();
1540    let base = dir.path();
1541    let full_name = "denikson-BepInExPack_Valheim";
1542
1543    crate::profile::layout::create(base, "valheim", "Main").unwrap();
1544
1545    let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
1546    let loader_file = profile_dir.join("winhttp.dll");
1547
1548    write_file(&loader_file, b"dll");
1549
1550    let mut mods = Vec::new();
1551
1552    modlist::upsert(
1553      &mut mods,
1554      mod_entry_from_version(full_name, "denikson", &version(full_name), 1),
1555    );
1556    modlist::write(&profile_dir, &mods).unwrap();
1557
1558    let tracker = crate::install::ModFileTracker {
1559      mod_name: full_name.to_string(),
1560      files: vec![("unused-cache-source".to_string(), "winhttp.dll".to_string())],
1561    };
1562
1563    write_state_file(&profile_dir, &tracker).unwrap();
1564
1565    disable_mod(base, &eco, "valheim", "Main", full_name).unwrap();
1566
1567    assert!(loader_file.exists());
1568    assert!(crate::profile::modlist::read(&profile_dir).unwrap()[0].enabled);
1569  }
1570
1571  #[test]
1572  fn install_mod_fails_on_unresolved_dependency() {
1573    let eco = Ecosystem::bundled();
1574
1575    let package = crate::models::Package {
1576      name: Some("Mod".to_string()),
1577      full_name: Some("Author-Mod".to_string()),
1578      owner: Some("Author".to_string()),
1579      package_url: Some("https://example.com/Author-Mod".to_string()),
1580      date_created: OffsetDateTime::UNIX_EPOCH,
1581      date_updated: OffsetDateTime::UNIX_EPOCH,
1582      uuid4: Some("u".to_string()),
1583      rating_score: Some(0),
1584      is_pinned: Some(false),
1585      is_deprecated: Some(false),
1586      has_nsfw_content: Some(false),
1587      categories: vec![],
1588      versions: vec![Version {
1589        dependencies: vec!["Missing-Dep-1.0.0".to_string()],
1590        ..version("Author-Mod")
1591      }],
1592    };
1593
1594    let index: PackageIndex = vec![package].into();
1595
1596    let dir = tempdir().unwrap();
1597    let base = dir.path();
1598    let cache_dir = tempdir().unwrap();
1599
1600    let client = ThunderstoreClient::builder()
1601      .package_index_url("http://localhost/pkg/")
1602      .cache_dir(cache_dir.path())
1603      .build()
1604      .unwrap();
1605
1606    crate::profile::layout::create(base, "valheim", "Main").unwrap();
1607
1608    let rt = tokio::runtime::Runtime::new().unwrap();
1609    let result = rt.block_on(install_mod(
1610      base,
1611      &eco,
1612      &index,
1613      &client,
1614      "valheim",
1615      "Main",
1616      "Author-Mod",
1617      1,
1618    ));
1619
1620    let err = result.unwrap_err().to_string();
1621
1622    assert!(err.contains("Missing-Dep"), "unexpected error: {err}");
1623  }
1624
1625  #[test]
1626  fn server_mode_lifecycle_against_bare_target_dir() {
1627    let eco = Ecosystem::bundled();
1628
1629    let dir = tempdir().unwrap();
1630    let target_dir = dir.path();
1631
1632    let extracted = tempdir().unwrap();
1633    write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
1634
1635    install_extracted_in(
1636      target_dir,
1637      &eco,
1638      "valheim",
1639      "Author-Mod",
1640      "Author",
1641      &version("Author-Mod"),
1642      extracted.path(),
1643      None,
1644      123,
1645    )
1646    .unwrap();
1647
1648    assert!(
1649      target_dir
1650        .join("BepInEx/plugins/Author-Mod/Mod.dll")
1651        .exists()
1652    );
1653    assert!(target_dir.join("mods.yml").exists());
1654
1655    let mods = modlist::read(target_dir).unwrap();
1656
1657    assert_eq!(mods.len(), 1);
1658    assert_eq!(mods[0].name, "Author-Mod");
1659    assert!(mods[0].enabled);
1660
1661    // Simulate a `State`-tracked file alongside the `Subdir`-tracked plugin
1662    // above (e.g. a MelonLoader-style loose `.dll`), proving `_state` I/O
1663    // resolves entirely against `target_dir` with no r2modman profile layout
1664    // involved: the source is a real file so enable's restore-from-cache
1665    // copy succeeds, and the dest starts out present so disable's removal is
1666    // a real effect, not a no-op.
1667    let state_source = extracted.path().join("state-source.txt");
1668
1669    write_file(&state_source, b"state-data");
1670    write_file(&target_dir.join("state-dest.txt"), b"state-data");
1671
1672    let tracker = crate::install::ModFileTracker {
1673      mod_name: "Author-Mod".to_string(),
1674      files: vec![(
1675        state_source.to_string_lossy().to_string(),
1676        "state-dest.txt".to_string(),
1677      )],
1678    };
1679
1680    write_state_file(target_dir, &tracker).unwrap();
1681
1682    assert!(state_file_path(target_dir, "Author-Mod").exists());
1683
1684    disable_mod_in(target_dir, &eco, "valheim", "Author-Mod").unwrap();
1685
1686    assert!(
1687      target_dir
1688        .join("BepInEx/plugins/Author-Mod/Mod.dll.old")
1689        .exists()
1690    );
1691    assert!(!target_dir.join("state-dest.txt").exists());
1692    assert!(!modlist::read(target_dir).unwrap()[0].enabled);
1693
1694    enable_mod_in(target_dir, &eco, "valheim", "Author-Mod").unwrap();
1695
1696    assert!(
1697      target_dir
1698        .join("BepInEx/plugins/Author-Mod/Mod.dll")
1699        .exists()
1700    );
1701    assert!(target_dir.join("state-dest.txt").exists());
1702    assert!(modlist::read(target_dir).unwrap()[0].enabled);
1703
1704    uninstall_mod_in(target_dir, &eco, "valheim", "Author-Mod").unwrap();
1705
1706    assert!(!target_dir.join("BepInEx/plugins/Author-Mod").exists());
1707    // The `State`-tracked payload lives under a shared route, so `remove_delisted`
1708    // never reconciles it — uninstall must remove it from the `_state` record
1709    // before discarding that record, or the file would be orphaned untracked.
1710    assert!(!target_dir.join("state-dest.txt").exists());
1711    assert!(!state_file_path(target_dir, "Author-Mod").exists());
1712    assert_eq!(modlist::read(target_dir).unwrap().len(), 0);
1713  }
1714
1715  fn build_mod_zip() -> Vec<u8> {
1716    let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
1717    let opts: FileOptions<'_, ()> =
1718      FileOptions::default().compression_method(zip::CompressionMethod::Stored);
1719
1720    for (name, body) in [
1721      ("manifest.json", &b"{\"version_number\":\"1.0.0\"}"[..]),
1722      ("plugins/CoolMod.dll", &b"dll-bytes"[..]),
1723    ] {
1724      zip.start_file(name, opts).unwrap();
1725      zip.write_all(body).unwrap();
1726    }
1727
1728    zip.finish().unwrap().into_inner()
1729  }
1730
1731  fn index_json(server_url: &str) -> String {
1732    format!(
1733      r#"[{{"name":"CoolMod","full_name":"Author-CoolMod","owner":"Author","package_url":"https://example.com/CoolMod","date_created":"2024-01-01T12:00:00Z","date_updated":"2024-01-02T12:00:00Z","uuid4":"pkg","rating_score":1,"is_pinned":false,"is_deprecated":false,"has_nsfw_content":false,"categories":[],"versions":[{{"name":"CoolMod","full_name":"Author-CoolMod","description":"A cool mod","icon":"icon.png","version_number":"1.0.0","dependencies":[],"download_url":"{server_url}/dl/mod.zip","downloads":1,"date_created":"2024-01-01T12:00:00Z","website_url":"","is_active":true,"uuid4":"ver","file_size":1024}}]}}]"#
1734    )
1735  }
1736
1737  #[test]
1738  fn install_mod_in_extracts_into_cache_base_not_target_dir() {
1739    let mut server = mockito::Server::new();
1740    let zip_bytes = build_mod_zip();
1741
1742    let dl = server
1743      .mock("GET", "/dl/mod.zip")
1744      .with_status(200)
1745      .with_header("Content-Type", "application/zip")
1746      .with_body(zip_bytes)
1747      .create();
1748    let idx = server
1749      .mock("GET", "/pkg/")
1750      .with_status(200)
1751      .with_header("Content-Type", "application/json")
1752      .with_header("Last-Modified", "Wed, 21 Feb 2024 15:30:45 GMT")
1753      .with_body(index_json(&server.url()))
1754      .create();
1755
1756    let http_cache = tempdir().unwrap();
1757    let cache_base = tempdir().unwrap();
1758    let target = tempdir().unwrap();
1759
1760    let client = ThunderstoreClient::builder()
1761      .package_index_url(format!("{}/pkg/", server.url()))
1762      .cache_dir(http_cache.path())
1763      .build()
1764      .unwrap();
1765
1766    let rt = tokio::runtime::Runtime::new().unwrap();
1767    let eco = Ecosystem::bundled();
1768    let index = rt.block_on(client.get_manifest()).unwrap();
1769
1770    let installed = rt
1771      .block_on(install_mod_in(
1772        target.path(),
1773        cache_base.path(),
1774        &eco,
1775        &index,
1776        &client,
1777        "valheim",
1778        "Author-CoolMod",
1779        123,
1780      ))
1781      .unwrap();
1782
1783    assert_eq!(installed, vec!["Author-CoolMod".to_string()]);
1784
1785    assert!(
1786      target
1787        .path()
1788        .join("BepInEx/plugins/Author-CoolMod/CoolMod.dll")
1789        .exists()
1790    );
1791
1792    let cached = cache::package_cache_dir(cache_base.path(), "valheim", "Author-CoolMod", "1.0.0");
1793
1794    assert!(cached.join("plugins/CoolMod.dll").exists());
1795    assert!(!target.path().join("cache").exists());
1796
1797    dl.assert();
1798    idx.assert();
1799  }
1800
1801  /// Builds a minimal [`ProfileMod`] for a hand-built `mods.yml` entry.
1802  /// `ProfileMod` does not derive `Default`, so every field is filled with a
1803  /// plausible value.
1804  fn profile_mod(name: &str) -> ProfileMod {
1805    ProfileMod {
1806      manifest_version: 1,
1807      name: name.to_string(),
1808      author_name: String::new(),
1809      website_url: String::new(),
1810      display_name: name.to_string(),
1811      description: String::new(),
1812      game_version: "0".to_string(),
1813      network_mode: "both".to_string(),
1814      package_type: "other".to_string(),
1815      install_mode: "managed".to_string(),
1816      installed_at_time: 1,
1817      loaders: Vec::new(),
1818      dependencies: Vec::new(),
1819      incompatibilities: Vec::new(),
1820      optional_dependencies: Vec::new(),
1821      version_number: SemVer::parse("1.0.0"),
1822      enabled: true,
1823      icon: String::new(),
1824      online_source: false,
1825      trusted_package: false,
1826    }
1827  }
1828
1829  #[test]
1830  fn uninstalling_a_loader_pack_without_a_state_file_is_refused() {
1831    let dir = tempdir().unwrap();
1832    let target_dir = dir.path();
1833    let eco = crate::ecosystem::Ecosystem::bundled();
1834
1835    // A `mods.yml` naming the loader, with no `_state` tracker beside it: the
1836    // shape `import_r2modman_dir` leaves behind, since r2modman never wrote
1837    // this engine's tracker.
1838    modlist::write(target_dir, &[profile_mod("denikson-BepInExPack_Valheim")]).unwrap();
1839
1840    let result = uninstall_mod_in(target_dir, &eco, "valheim", "denikson-BepInExPack_Valheim");
1841
1842    assert!(result.is_err());
1843
1844    let message = result.unwrap_err().to_string();
1845
1846    assert!(
1847      message.contains("denikson-BepInExPack_Valheim"),
1848      "the error must name the loader; got: {message}"
1849    );
1850    // Refusing must leave the record intact rather than dropping it, which is
1851    // the orphaning this guard exists to prevent.
1852    assert_eq!(modlist::read(target_dir).unwrap().len(), 1);
1853  }
1854
1855  #[test]
1856  fn uninstalling_a_regular_mod_without_a_state_file_still_works() {
1857    let dir = tempdir().unwrap();
1858    let target_dir = dir.path();
1859    let eco = crate::ecosystem::Ecosystem::bundled();
1860
1861    // Valheim's install rules are `subdir` and `none` only, so a regular mod
1862    // never has a `_state` file. That must stay a normal uninstall.
1863    modlist::write(target_dir, &[profile_mod("Owner-Regular")]).unwrap();
1864
1865    uninstall_mod_in(target_dir, &eco, "valheim", "Owner-Regular").unwrap();
1866
1867    assert!(modlist::read(target_dir).unwrap().is_empty());
1868  }
1869
1870  #[test]
1871  fn check_removable_batch_reports_every_unremovable_name_with_its_reason() {
1872    let dir = tempdir().unwrap();
1873    let target_dir = dir.path();
1874    let eco = crate::ecosystem::Ecosystem::bundled();
1875
1876    modlist::write(
1877      target_dir,
1878      &[
1879        profile_mod("denikson-BepInExPack_Valheim"),
1880        profile_mod("Owner-Regular"),
1881      ],
1882    )
1883    .unwrap();
1884
1885    // The loader has no `_state` tracker at all: adopted from disk rather than
1886    // installed, so which files it placed is unknown.
1887    //
1888    // The regular mod's `_state` file is present but unparseable: a stray
1889    // leftover or a hand edit, so its contents are unreadable rather than
1890    // absent. Different reason, same "cannot be removed exactly" outcome.
1891    let state_path = crate::install::state_file_path(target_dir, "Owner-Regular");
1892
1893    std::fs::create_dir_all(state_path.parent().unwrap()).unwrap();
1894    std::fs::write(&state_path, "not: [valid").unwrap();
1895
1896    let names = vec![
1897      "denikson-BepInExPack_Valheim".to_string(),
1898      "Owner-Regular".to_string(),
1899    ];
1900
1901    let blocked = check_removable_batch(target_dir, &eco, &names);
1902
1903    assert_eq!(
1904      blocked.len(),
1905      2,
1906      "both un-removable names must be reported, not just the first; got {blocked:?}"
1907    );
1908
1909    let loader_refusal = blocked
1910      .iter()
1911      .find(|(name, _)| name == "denikson-BepInExPack_Valheim")
1912      .map(|(_, refusal)| refusal)
1913      .unwrap();
1914    let loader_path = state_file_path(target_dir, "denikson-BepInExPack_Valheim");
1915
1916    // The reason is returned rather than flattened into a sentence, so a caller
1917    // decides what to advise from the variant instead of re-statting the record.
1918    assert!(
1919      matches!(loader_refusal, NotRemovable::RecordAbsent { path, .. } if path == &loader_path),
1920      "expected an absent record carrying its expected path; got: {loader_refusal:?}"
1921    );
1922
1923    let regular_refusal = blocked
1924      .iter()
1925      .find(|(name, _)| name == "Owner-Regular")
1926      .map(|(_, refusal)| refusal)
1927      .unwrap();
1928
1929    assert!(
1930      matches!(regular_refusal, NotRemovable::RecordUnreadable { path, .. } if path == &state_path),
1931      "expected an unreadable record carrying the path it could not read; got: {regular_refusal:?}"
1932    );
1933  }
1934
1935  #[test]
1936  fn a_removability_refusal_keeps_todays_wording_when_it_becomes_an_error() {
1937    let dir = tempdir().unwrap();
1938    let target_dir = dir.path();
1939    let eco = crate::ecosystem::Ecosystem::bundled();
1940
1941    modlist::write(target_dir, &[profile_mod("denikson-BepInExPack_Valheim")]).unwrap();
1942
1943    let refusal = ensure_removable(target_dir, &eco, "denikson-BepInExPack_Valheim").unwrap_err();
1944    let path = state_file_path(target_dir, "denikson-BepInExPack_Valheim");
1945
1946    // `uninstall_mod_in` propagates this refusal with `?`, so the sentence a
1947    // caller without its own wording sees has to survive the conversion intact.
1948    assert_eq!(
1949      Error::from(refusal).to_string(),
1950      format!(
1951        "Profile error: denikson-BepInExPack_Valheim is a mod loader with no \
1952         install record at {}, so its files cannot be removed exactly. Reinstall \
1953         it, then uninstall it again, or remove its files by hand.",
1954        path.display()
1955      )
1956    );
1957  }
1958
1959  #[test]
1960  fn a_reinstall_the_caller_did_not_name_keeps_its_disabled_state() {
1961    let eco = Ecosystem::bundled();
1962    let dir = tempdir().unwrap();
1963    let base = dir.path();
1964
1965    crate::profile::layout::create(base, "valheim", "Main").unwrap();
1966
1967    let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
1968    let extracted = tempdir().unwrap();
1969
1970    write_file(&extracted.path().join("plugins/Base.dll"), b"dll");
1971
1972    install_extracted(
1973      base,
1974      &eco,
1975      "valheim",
1976      "Main",
1977      "Author-Base",
1978      "Author",
1979      &version("Author-Base"),
1980      extracted.path(),
1981      None,
1982      1,
1983    )
1984    .unwrap();
1985
1986    disable_mod(base, &eco, "valheim", "Main", "Author-Base").unwrap();
1987
1988    assert!(!modlist::read(&profile_dir).unwrap()[0].enabled);
1989    assert!(
1990      profile_dir
1991        .join("BepInEx/plugins/Author-Base/Base.dll.old")
1992        .exists(),
1993      "the disable must have suffixed the file, or this test proves nothing"
1994    );
1995
1996    // Reinstalling Author-Base the way a dependency closure would: not named by
1997    // the user, so its disabled state must survive.
1998    let batch = plan_install_batch(&profile_dir, &["Author-Base".to_string()], &[]).unwrap();
1999
2000    assert_eq!(batch.protect_disabled, vec!["Author-Base".to_string()]);
2001
2002    install_extracted(
2003      base,
2004      &eco,
2005      "valheim",
2006      "Main",
2007      "Author-Base",
2008      "Author",
2009      &version("Author-Base"),
2010      extracted.path(),
2011      None,
2012      2,
2013    )
2014    .unwrap();
2015
2016    reapply_batch_disabled(&profile_dir, &eco, "valheim", &batch).unwrap();
2017
2018    let mods = modlist::read(&profile_dir).unwrap();
2019
2020    assert!(
2021      !mods[0].enabled,
2022      "a disabled dependency must not be re-enabled by an incidental reinstall"
2023    );
2024    assert!(
2025      profile_dir
2026        .join("BepInEx/plugins/Author-Base/Base.dll.old")
2027        .exists(),
2028      "the file must be left suffixed, not rewritten un-suffixed and orphaned"
2029    );
2030  }
2031
2032  #[test]
2033  fn planning_an_uninstall_batch_partitions_loaders_and_lists_untracked() {
2034    let eco = Ecosystem::bundled();
2035    let dir = tempdir().unwrap();
2036    let base = dir.path();
2037
2038    crate::profile::layout::create(base, "valheim", "Main").unwrap();
2039
2040    let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
2041    let extracted = tempdir().unwrap();
2042
2043    write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
2044    install_extracted(
2045      base,
2046      &eco,
2047      "valheim",
2048      "Main",
2049      "Author-Mod",
2050      "Author",
2051      &version("Author-Mod"),
2052      extracted.path(),
2053      None,
2054      1,
2055    )
2056    .unwrap();
2057
2058    // A folder mods.yml never recorded, which the sweep would take along.
2059    write_file(
2060      &profile_dir.join("BepInEx/plugins/Other-ByHand/x.dll"),
2061      b"dll",
2062    );
2063    // Not `<Owner-Name>`-shaped, so the sweep ignores it and the plan must too.
2064    write_file(&profile_dir.join("BepInEx/plugins/MMHOOK/h.dll"), b"dll");
2065
2066    let batch = plan_uninstall_batch(&profile_dir, &eco, "valheim", None).unwrap();
2067
2068    assert_eq!(batch.mods, vec!["Author-Mod".to_string()]);
2069    assert!(batch.loaders.is_empty());
2070    assert_eq!(
2071      batch.untracked,
2072      vec![std::path::PathBuf::from("BepInEx/plugins/Other-ByHand")]
2073    );
2074
2075    let outcome = uninstall_batch(&profile_dir, &eco, "valheim", &batch).unwrap();
2076
2077    assert!(outcome.is_ok(), "failed: {:?}", outcome.failed);
2078    assert_eq!(outcome.succeeded, vec!["Author-Mod".to_string()]);
2079    assert!(modlist::read(&profile_dir).unwrap().is_empty());
2080    assert!(profile_dir.join("BepInEx/plugins/MMHOOK/h.dll").exists());
2081  }
2082
2083  #[test]
2084  fn uninstalling_a_batch_removes_the_loader_after_ordinary_mods_and_its_own_files() {
2085    let eco = Ecosystem::bundled();
2086    let dir = tempdir().unwrap();
2087    let base = dir.path();
2088    let loader = "denikson-BepInExPack_Valheim";
2089
2090    crate::profile::layout::create(base, "valheim", "Main").unwrap();
2091
2092    let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
2093    let extracted = tempdir().unwrap();
2094
2095    write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
2096    install_extracted(
2097      base,
2098      &eco,
2099      "valheim",
2100      "Main",
2101      "Author-Mod",
2102      "Author",
2103      &version("Author-Mod"),
2104      extracted.path(),
2105      None,
2106      1,
2107    )
2108    .unwrap();
2109
2110    // A recorded loader, fabricated the same way `disable_mod_is_noop_for_modloader_pack`
2111    // does: its files land directly under the profile root, not through
2112    // `install_extracted`, since a real loader package's layout (a
2113    // `rootFolder` wrapper stripped by the download pipeline) is not what
2114    // that helper exercises.
2115    let winhttp = profile_dir.join("winhttp.dll");
2116    let core_dll = profile_dir.join("BepInEx/core/BepInEx.dll");
2117
2118    write_file(&winhttp, b"proxy");
2119    write_file(&core_dll, b"core");
2120
2121    let mut mods = modlist::read(&profile_dir).unwrap();
2122
2123    modlist::upsert(
2124      &mut mods,
2125      mod_entry_from_version(loader, "denikson", &version(loader), 1),
2126    );
2127    modlist::write(&profile_dir, &mods).unwrap();
2128
2129    let tracker = crate::install::ModFileTracker {
2130      mod_name: loader.to_string(),
2131      files: vec![
2132        (
2133          "unused-cache-source-1".to_string(),
2134          "winhttp.dll".to_string(),
2135        ),
2136        (
2137          "unused-cache-source-2".to_string(),
2138          "BepInEx/core/BepInEx.dll".to_string(),
2139        ),
2140      ],
2141    };
2142
2143    write_state_file(&profile_dir, &tracker).unwrap();
2144
2145    let batch = plan_uninstall_batch(&profile_dir, &eco, "valheim", None).unwrap();
2146
2147    assert_eq!(batch.mods, vec!["Author-Mod".to_string()]);
2148    assert_eq!(batch.loaders, vec![loader.to_string()]);
2149
2150    let outcome = uninstall_batch(&profile_dir, &eco, "valheim", &batch).unwrap();
2151
2152    assert!(outcome.is_ok(), "failed: {:?}", outcome.failed);
2153    // `uninstall_batch` chains `batch.mods` before `batch.loaders`, so the
2154    // order items were attempted in is what `succeeded` records them in. Two
2155    // items give no way to observe the timing of one removal relative to the
2156    // other beyond this: there is no shared file or intermediate read that
2157    // would tell mods-then-loader apart from an interleaved or reversed order
2158    // except the order they were pushed onto `succeeded` in, which is exactly
2159    // what this pins.
2160    assert_eq!(
2161      outcome.succeeded,
2162      vec!["Author-Mod".to_string(), loader.to_string()]
2163    );
2164    assert!(modlist::read(&profile_dir).unwrap().is_empty());
2165    // The loader's own `State`-tracked files are gone too, not just its
2166    // `mods.yml` entry.
2167    assert!(!winhttp.exists());
2168    assert!(!core_dll.exists());
2169  }
2170
2171  #[test]
2172  fn planning_an_uninstall_batch_sorts_and_removes_two_recorded_loaders() {
2173    let eco = Ecosystem::bundled();
2174    let dir = tempdir().unwrap();
2175    let base = dir.path();
2176    let first_loader = "denikson-BepInExPack_Valheim";
2177    // A second, unrelated loader pack recognised by the same registry:
2178    // `modloader_package` is a global lookup keyed on package identity, not
2179    // scoped to a game, so nothing about a Valheim profile rules out a second
2180    // recognised loader ending up in its `mods.yml` (a real entry in the
2181    // bundled registry).
2182    let second_loader = "1F31A-BepInEx_Valheim_Full";
2183
2184    crate::profile::layout::create(base, "valheim", "Main").unwrap();
2185
2186    let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
2187
2188    let mut mods = Vec::new();
2189
2190    for loader in [first_loader, second_loader] {
2191      modlist::upsert(
2192        &mut mods,
2193        mod_entry_from_version(loader, "owner", &version(loader), 1),
2194      );
2195
2196      // An empty tracker satisfies the install-record precondition without
2197      // claiming ownership of any real file, so removing both loaders cannot
2198      // conflict over shared files.
2199      write_state_file(
2200        &profile_dir,
2201        &crate::install::ModFileTracker {
2202          mod_name: loader.to_string(),
2203          files: Vec::new(),
2204        },
2205      )
2206      .unwrap();
2207    }
2208
2209    modlist::write(&profile_dir, &mods).unwrap();
2210
2211    let batch = plan_uninstall_batch(&profile_dir, &eco, "valheim", None).unwrap();
2212
2213    assert!(batch.mods.is_empty());
2214    // Sorted, so neither loader is silently dropped by the other overwriting it.
2215    assert_eq!(
2216      batch.loaders,
2217      vec![second_loader.to_string(), first_loader.to_string()]
2218    );
2219
2220    let outcome = uninstall_batch(&profile_dir, &eco, "valheim", &batch).unwrap();
2221
2222    assert!(outcome.is_ok(), "failed: {:?}", outcome.failed);
2223    assert!(modlist::read(&profile_dir).unwrap().is_empty());
2224  }
2225
2226  #[test]
2227  fn planning_a_toggle_batch_splits_changing_from_already_and_excludes_loaders() {
2228    let eco = Ecosystem::bundled();
2229    let dir = tempdir().unwrap();
2230    let base = dir.path();
2231    let loader = "denikson-BepInExPack_Valheim";
2232
2233    crate::profile::layout::create(base, "valheim", "Main").unwrap();
2234
2235    let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
2236    let extracted = tempdir().unwrap();
2237
2238    write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
2239
2240    for name in ["Author-One", "Author-Two"] {
2241      install_extracted(
2242        base,
2243        &eco,
2244        "valheim",
2245        "Main",
2246        name,
2247        "Author",
2248        &version(name),
2249        extracted.path(),
2250        None,
2251        1,
2252      )
2253      .unwrap();
2254    }
2255
2256    // A recorded loader, which has no disabled state and must be excluded.
2257    let mut mods = modlist::read(&profile_dir).unwrap();
2258
2259    mods.push(mod_entry_from_version(
2260      loader,
2261      "denikson",
2262      &version(loader),
2263      1,
2264    ));
2265    modlist::write(&profile_dir, &mods).unwrap();
2266
2267    disable_mod(base, &eco, "valheim", "Main", "Author-One").unwrap();
2268
2269    let batch = plan_set_enabled_batch(&profile_dir, &eco, None, false).unwrap();
2270
2271    assert_eq!(batch.changing, vec!["Author-Two".to_string()]);
2272    assert_eq!(batch.already, vec!["Author-One".to_string()]);
2273    assert_eq!(batch.loaders, vec![loader.to_string()]);
2274
2275    let outcome = set_enabled_batch(&profile_dir, &eco, "valheim", &batch).unwrap();
2276
2277    assert!(outcome.is_ok(), "failed: {:?}", outcome.failed);
2278    assert_eq!(outcome.succeeded, vec!["Author-Two".to_string()]);
2279
2280    let after = modlist::read(&profile_dir).unwrap();
2281
2282    assert!(
2283      !after
2284        .iter()
2285        .find(|m| m.name == "Author-Two")
2286        .unwrap()
2287        .enabled
2288    );
2289    // The loader was never touched, so its record still reads enabled.
2290    assert!(after.iter().find(|m| m.name == loader).unwrap().enabled);
2291  }
2292
2293  #[test]
2294  fn plan_set_enabled_batch_refuses_an_unrecorded_name() {
2295    let eco = Ecosystem::bundled();
2296    let dir = tempdir().unwrap();
2297    let base = dir.path();
2298
2299    crate::profile::layout::create(base, "valheim", "Main").unwrap();
2300
2301    let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
2302    let extracted = tempdir().unwrap();
2303
2304    write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
2305
2306    install_extracted(
2307      base,
2308      &eco,
2309      "valheim",
2310      "Main",
2311      "Author-One",
2312      "Author",
2313      &version("Author-One"),
2314      extracted.path(),
2315      None,
2316      1,
2317    )
2318    .unwrap();
2319
2320    let names = vec!["Author-Gone".to_string()];
2321    let error = plan_set_enabled_batch(&profile_dir, &eco, Some(&names), false).unwrap_err();
2322
2323    assert!(error.to_string().contains("Author-Gone"));
2324    assert!(error.to_string().contains("is not installed"));
2325  }
2326
2327  /// Install batches run end to end against a mock Thunderstore: real downloads,
2328  /// extraction, routing and `mods.yml`, so skip, clean and force are checked
2329  /// on disk rather than against a stub.
2330  mod reinstall {
2331    use super::*;
2332    use crate::models::Package;
2333
2334    const MOD: &str = "Author-Mod";
2335    const DEP: &str = "Author-Dep";
2336    const LOADER: &str = "denikson-BepInExPack_Valheim";
2337
2338    /// One published version of a package.
2339    struct Pkg<'a> {
2340      full_name: &'a str,
2341      version: &'a str,
2342      deps: &'a [&'a str],
2343      files: &'a [(&'a str, &'a [u8])],
2344    }
2345
2346    struct Repo {
2347      server: mockito::ServerGuard,
2348      mocks: Vec<mockito::Mock>,
2349      client: ThunderstoreClient,
2350      _http_cache: tempfile::TempDir,
2351      cache_base: tempfile::TempDir,
2352      target: tempfile::TempDir,
2353      eco: Ecosystem,
2354      rt: tokio::runtime::Runtime,
2355      clock: u64,
2356    }
2357
2358    impl Repo {
2359      fn new() -> Self {
2360        let server = mockito::Server::new();
2361        let http_cache = tempdir().unwrap();
2362        let client = ThunderstoreClient::builder()
2363          .package_index_url(format!("{}/pkg/", server.url()))
2364          .cache_dir(http_cache.path())
2365          .build()
2366          .unwrap();
2367
2368        Self {
2369          server,
2370          mocks: Vec::new(),
2371          client,
2372          _http_cache: http_cache,
2373          cache_base: tempdir().unwrap(),
2374          target: tempdir().unwrap(),
2375          eco: Ecosystem::bundled(),
2376          rt: tokio::runtime::Runtime::new().unwrap(),
2377          clock: 0,
2378        }
2379      }
2380
2381      /// Serves each package's archive and returns an index of them. Versions of
2382      /// one package are published in slice order, so the last is the latest.
2383      fn publish(&mut self, pkgs: &[Pkg<'_>]) -> PackageIndex {
2384        let mut packages: Vec<serde_json::Value> = Vec::new();
2385
2386        for (day, pkg) in pkgs.iter().enumerate() {
2387          let path = format!("/dl/{}-{}.zip", pkg.full_name, pkg.version);
2388          let mock = self
2389            .server
2390            .mock("GET", path.as_str())
2391            .with_status(200)
2392            .with_header("Content-Type", "application/zip")
2393            .with_body(zip_of(pkg.files))
2394            .create();
2395
2396          self.mocks.push(mock);
2397
2398          let (owner, name) = pkg.full_name.split_once('-').unwrap();
2399          let created = format!("2024-01-{:02}T12:00:00Z", day + 1);
2400          let version = serde_json::json!({
2401            "name": name, "full_name": pkg.full_name, "description": "d", "icon": "i",
2402            "version_number": pkg.version, "dependencies": pkg.deps,
2403            "download_url": format!("{}{path}", self.server.url()), "downloads": 1,
2404            "date_created": created, "website_url": "", "is_active": true,
2405            "uuid4": "ver", "file_size": 1
2406          });
2407
2408          match packages
2409            .iter_mut()
2410            .find(|p| p["full_name"] == pkg.full_name)
2411          {
2412            Some(package) => package["versions"].as_array_mut().unwrap().push(version),
2413            None => packages.push(serde_json::json!({
2414              "name": name, "full_name": pkg.full_name, "owner": owner,
2415              "package_url": "https://example.com", "date_created": "2024-01-01T12:00:00Z",
2416              "date_updated": "2024-01-02T12:00:00Z", "uuid4": "pkg", "rating_score": 1,
2417              "is_pinned": false, "is_deprecated": false, "has_nsfw_content": false,
2418              "categories": [], "versions": [version]
2419            })),
2420          }
2421        }
2422
2423        let packages = packages
2424          .into_iter()
2425          .map(|json| serde_json::from_value::<Package>(json).unwrap())
2426          .collect::<Vec<_>>();
2427
2428        PackageIndex::from(packages)
2429      }
2430
2431      /// Like [`Self::try_install`], asserting that nothing failed.
2432      fn install(
2433        &mut self,
2434        index: &PackageIndex,
2435        desired: &[&str],
2436        explicit: &[&str],
2437        force: bool,
2438      ) -> BatchOutcome {
2439        let outcome = self.try_install(index, desired, explicit, force);
2440
2441        assert!(outcome.is_ok(), "batch failed: {:?}", outcome.failed);
2442
2443        outcome
2444      }
2445
2446      /// Plans a batch for `desired` (naming `explicit`) and runs it, without
2447      /// asserting that nothing failed.
2448      fn try_install(
2449        &mut self,
2450        index: &PackageIndex,
2451        desired: &[&str],
2452        explicit: &[&str],
2453        force: bool,
2454      ) -> BatchOutcome {
2455        let desired: Vec<String> = desired.iter().map(|s| s.to_string()).collect();
2456        let explicit: Vec<String> = explicit.iter().map(|s| s.to_string()).collect();
2457        let mut batch = plan_install_batch(self.target.path(), &desired, &explicit).unwrap();
2458
2459        batch.force = force;
2460        self.clock += 1;
2461
2462        self
2463          .rt
2464          .block_on(install_batch(
2465            self.target.path(),
2466            self.cache_base.path(),
2467            &self.eco,
2468            index,
2469            &self.client,
2470            "valheim",
2471            &batch,
2472            self.clock,
2473          ))
2474          .unwrap()
2475      }
2476
2477      fn path(&self, rel: &str) -> PathBuf {
2478        self.target.path().join(rel)
2479      }
2480
2481      fn entry(&self, name: &str) -> ProfileMod {
2482        modlist::read(self.target.path())
2483          .unwrap()
2484          .into_iter()
2485          .find(|m| m.name == name)
2486          .unwrap()
2487      }
2488    }
2489
2490    fn zip_of(files: &[(&str, &[u8])]) -> Vec<u8> {
2491      let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
2492      let opts: FileOptions<'_, ()> =
2493        FileOptions::default().compression_method(zip::CompressionMethod::Stored);
2494
2495      for (name, body) in files {
2496        zip.start_file(*name, opts).unwrap();
2497        zip.write_all(body).unwrap();
2498      }
2499
2500      zip.finish().unwrap().into_inner()
2501    }
2502
2503    fn names(list: &[&str]) -> Vec<String> {
2504      list.iter().map(|s| s.to_string()).collect()
2505    }
2506
2507    #[test]
2508    fn an_install_at_the_recorded_version_is_skipped() {
2509      let mut repo = Repo::new();
2510      let index = repo.publish(&[Pkg {
2511        full_name: MOD,
2512        version: "1.0.0",
2513        deps: &[],
2514        files: &[("plugins/Mod.dll", b"v1")],
2515      }]);
2516
2517      repo.install(&index, &[MOD], &[MOD], false);
2518
2519      let user_file = repo.path("BepInEx/plugins/Author-Mod/user.txt");
2520
2521      write_file(&user_file, b"mine");
2522
2523      let installed_at = repo.entry(MOD).installed_at_time;
2524      let outcome = repo.install(&index, &[MOD], &[MOD], false);
2525
2526      assert!(outcome.succeeded.is_empty());
2527      assert_eq!(outcome.unchanged, names(&[MOD]));
2528      assert!(user_file.exists(), "a skipped install must not touch files");
2529      assert_eq!(repo.entry(MOD).installed_at_time, installed_at);
2530    }
2531
2532    #[test]
2533    fn a_version_change_removes_files_only_the_old_version_had() {
2534      let mut repo = Repo::new();
2535      let v1 = repo.publish(&[Pkg {
2536        full_name: MOD,
2537        version: "1.0.0",
2538        deps: &[],
2539        files: &[
2540          ("plugins/Old.dll", b"old"),
2541          ("plugins/german.json", b"flat"),
2542        ],
2543      }]);
2544
2545      repo.install(&v1, &[MOD], &[MOD], false);
2546
2547      assert!(repo.path("BepInEx/plugins/Author-Mod/german.json").exists());
2548
2549      let v2 = repo.publish(&[Pkg {
2550        full_name: MOD,
2551        version: "2.0.0",
2552        deps: &[],
2553        files: &[
2554          ("plugins/Mod.dll", b"new"),
2555          ("plugins/Translations/German/german.json", b"nested"),
2556        ],
2557      }]);
2558      let outcome = repo.install(&v2, &[MOD], &[MOD], false);
2559
2560      assert_eq!(outcome.succeeded, names(&[MOD]));
2561      assert!(!repo.path("BepInEx/plugins/Author-Mod/Old.dll").exists());
2562      assert!(!repo.path("BepInEx/plugins/Author-Mod/german.json").exists());
2563      assert!(
2564        repo
2565          .path("BepInEx/plugins/Author-Mod/Translations/German/german.json")
2566          .exists()
2567      );
2568      assert_eq!(repo.entry(MOD).version_number.to_string(), "2.0.0");
2569    }
2570
2571    #[test]
2572    fn force_reinstalls_the_recorded_version_from_a_clean_folder() {
2573      let mut repo = Repo::new();
2574      let index = repo.publish(&[Pkg {
2575        full_name: MOD,
2576        version: "1.0.0",
2577        deps: &[],
2578        files: &[("plugins/Translations/German/german.json", b"nested")],
2579      }]);
2580
2581      repo.install(&index, &[MOD], &[MOD], false);
2582
2583      // What an install made before nested folders were kept left behind.
2584      let stale = repo.path("BepInEx/plugins/Author-Mod/german.json");
2585
2586      write_file(&stale, b"flat");
2587
2588      let outcome = repo.install(&index, &[MOD], &[MOD], true);
2589
2590      assert_eq!(outcome.succeeded, names(&[MOD]));
2591      assert!(outcome.unchanged.is_empty());
2592      assert!(!stale.exists());
2593      assert!(
2594        repo
2595          .path("BepInEx/plugins/Author-Mod/Translations/German/german.json")
2596          .exists()
2597      );
2598    }
2599
2600    #[test]
2601    fn force_reinstalls_only_the_named_mod_not_its_dependencies() {
2602      let mut repo = Repo::new();
2603      let index = repo.publish(&[
2604        Pkg {
2605          full_name: MOD,
2606          version: "1.0.0",
2607          deps: &["Author-Dep-1.0.0"],
2608          files: &[("plugins/Mod.dll", b"mod")],
2609        },
2610        Pkg {
2611          full_name: DEP,
2612          version: "1.0.0",
2613          deps: &[],
2614          files: &[("plugins/Dep.dll", b"dep")],
2615        },
2616      ]);
2617
2618      repo.install(&index, &[MOD], &[MOD], false);
2619
2620      let dep_marker = repo.path("BepInEx/plugins/Author-Dep/user.txt");
2621
2622      write_file(&dep_marker, b"mine");
2623
2624      let outcome = repo.install(&index, &[MOD], &[MOD], true);
2625
2626      assert_eq!(outcome.succeeded, names(&[MOD]));
2627      assert_eq!(outcome.unchanged, names(&[DEP]));
2628      assert!(
2629        dep_marker.exists(),
2630        "the dependency must not be reinstalled"
2631      );
2632    }
2633
2634    #[test]
2635    fn a_disabled_mod_stays_disabled_through_force_and_an_update() {
2636      let mut repo = Repo::new();
2637      let v1 = repo.publish(&[Pkg {
2638        full_name: MOD,
2639        version: "1.0.0",
2640        deps: &[],
2641        files: &[("plugins/Mod.dll", b"v1"), ("plugins/Old.dll", b"v1")],
2642      }]);
2643
2644      repo.install(&v1, &[MOD], &[MOD], false);
2645
2646      set_enabled_in(repo.target.path(), &repo.eco, "valheim", MOD, false).unwrap();
2647
2648      // Not named, as an update on the user's behalf runs it.
2649      repo.install(&v1, &[MOD], &[], true);
2650
2651      assert!(!repo.entry(MOD).enabled);
2652      assert!(repo.path("BepInEx/plugins/Author-Mod/Mod.dll.old").exists());
2653      assert!(!repo.path("BepInEx/plugins/Author-Mod/Mod.dll").exists());
2654
2655      let v2 = repo.publish(&[Pkg {
2656        full_name: MOD,
2657        version: "2.0.0",
2658        deps: &[],
2659        files: &[("plugins/Mod.dll", b"v2")],
2660      }]);
2661
2662      repo.install(&v2, &[MOD], &[], false);
2663
2664      assert!(!repo.entry(MOD).enabled);
2665      assert!(repo.path("BepInEx/plugins/Author-Mod/Mod.dll.old").exists());
2666      assert!(!repo.path("BepInEx/plugins/Author-Mod/Mod.dll").exists());
2667      assert!(!repo.path("BepInEx/plugins/Author-Mod/Old.dll.old").exists());
2668    }
2669
2670    #[test]
2671    fn naming_a_disabled_mod_at_its_recorded_version_enables_it() {
2672      let mut repo = Repo::new();
2673      let index = repo.publish(&[Pkg {
2674        full_name: MOD,
2675        version: "1.0.0",
2676        deps: &[],
2677        files: &[("plugins/Mod.dll", b"v1")],
2678      }]);
2679
2680      repo.install(&index, &[MOD], &[MOD], false);
2681
2682      set_enabled_in(repo.target.path(), &repo.eco, "valheim", MOD, false).unwrap();
2683
2684      let outcome = repo.install(&index, &[MOD], &[MOD], false);
2685
2686      assert_eq!(outcome.unchanged, names(&[MOD]));
2687      assert!(repo.entry(MOD).enabled);
2688      assert!(repo.path("BepInEx/plugins/Author-Mod/Mod.dll").exists());
2689      assert!(!repo.path("BepInEx/plugins/Author-Mod/Mod.dll.old").exists());
2690    }
2691
2692    #[test]
2693    fn shared_config_survives_force_and_an_update() {
2694      let mut repo = Repo::new();
2695      let v1 = repo.publish(&[Pkg {
2696        full_name: MOD,
2697        version: "1.0.0",
2698        deps: &[],
2699        files: &[("plugins/Mod.dll", b"v1"), ("config/Mod.cfg", b"default")],
2700      }]);
2701
2702      repo.install(&v1, &[MOD], &[MOD], false);
2703
2704      let cfg = repo.path("BepInEx/config/Mod.cfg");
2705
2706      write_file(&cfg, b"edited");
2707      repo.install(&v1, &[MOD], &[MOD], true);
2708
2709      assert_eq!(std::fs::read(&cfg).unwrap(), b"edited");
2710
2711      let v2 = repo.publish(&[Pkg {
2712        full_name: MOD,
2713        version: "2.0.0",
2714        deps: &[],
2715        files: &[("plugins/Mod.dll", b"v2"), ("config/Mod.cfg", b"default")],
2716      }]);
2717
2718      repo.install(&v2, &[MOD], &[MOD], false);
2719
2720      assert_eq!(std::fs::read(&cfg).unwrap(), b"edited");
2721    }
2722
2723    #[test]
2724    fn force_reinstalls_a_loader_pack_and_keeps_its_config() {
2725      let mut repo = Repo::new();
2726      let index = repo.publish(&[Pkg {
2727        full_name: LOADER,
2728        version: "5.4.2333",
2729        deps: &[],
2730        files: &[
2731          ("BepInExPack_Valheim/winhttp.dll", b"proxy"),
2732          ("BepInExPack_Valheim/BepInEx/core/BepInEx.dll", b"core"),
2733          ("BepInExPack_Valheim/BepInEx/config/BepInEx.cfg", b"default"),
2734        ],
2735      }]);
2736
2737      repo.install(&index, &[LOADER], &[LOADER], false);
2738
2739      let cfg = repo.path("BepInEx/config/BepInEx.cfg");
2740      let core = repo.path("BepInEx/core/BepInEx.dll");
2741
2742      write_file(&cfg, b"edited");
2743      write_file(&core, b"tampered");
2744
2745      let outcome = repo.install(&index, &[LOADER], &[LOADER], true);
2746
2747      assert_eq!(outcome.succeeded, names(&[LOADER]));
2748      assert_eq!(std::fs::read(&core).unwrap(), b"core");
2749      assert!(repo.path("winhttp.dll").exists());
2750      assert_eq!(std::fs::read(&cfg).unwrap(), b"edited");
2751      assert!(state_file_path(repo.target.path(), LOADER).exists());
2752    }
2753
2754    #[test]
2755    fn a_recorded_mod_whose_folder_is_gone_is_reinstalled() {
2756      let mut repo = Repo::new();
2757      let index = repo.publish(&[Pkg {
2758        full_name: MOD,
2759        version: "1.0.0",
2760        deps: &[],
2761        files: &[("plugins/Mod.dll", b"v1")],
2762      }]);
2763
2764      repo.install(&index, &[MOD], &[MOD], false);
2765
2766      std::fs::remove_dir_all(repo.path("BepInEx/plugins/Author-Mod")).unwrap();
2767
2768      let outcome = repo.install(&index, &[MOD], &[MOD], false);
2769
2770      assert_eq!(outcome.succeeded, names(&[MOD]));
2771      assert!(outcome.unchanged.is_empty());
2772      assert!(repo.path("BepInEx/plugins/Author-Mod/Mod.dll").exists());
2773    }
2774
2775    #[test]
2776    fn a_loader_pack_missing_a_tracked_file_is_reinstalled() {
2777      let mut repo = Repo::new();
2778      let index = repo.publish(&[Pkg {
2779        full_name: LOADER,
2780        version: "5.4.2333",
2781        deps: &[],
2782        files: &[
2783          ("BepInExPack_Valheim/winhttp.dll", b"proxy"),
2784          ("BepInExPack_Valheim/BepInEx/core/BepInEx.dll", b"core"),
2785        ],
2786      }]);
2787
2788      repo.install(&index, &[LOADER], &[LOADER], false);
2789
2790      std::fs::remove_file(repo.path("winhttp.dll")).unwrap();
2791
2792      let outcome = repo.install(&index, &[LOADER], &[LOADER], false);
2793
2794      assert_eq!(outcome.succeeded, names(&[LOADER]));
2795      assert!(repo.path("winhttp.dll").exists());
2796    }
2797
2798    #[test]
2799    fn a_recorded_mod_with_no_files_to_place_is_skipped() {
2800      let mut repo = Repo::new();
2801      let index = repo.publish(&[Pkg {
2802        full_name: MOD,
2803        version: "1.0.0",
2804        deps: &[],
2805        files: &[("manifest.json", b"{}"), ("README.md", b"modpack")],
2806      }]);
2807
2808      repo.install(&index, &[MOD], &[MOD], false);
2809
2810      let outcome = repo.install(&index, &[MOD], &[MOD], false);
2811
2812      assert!(outcome.succeeded.is_empty());
2813      assert_eq!(outcome.unchanged, names(&[MOD]));
2814    }
2815
2816    #[cfg(unix)]
2817    #[test]
2818    fn a_failed_enable_is_reported_per_item_without_losing_the_batch() {
2819      use std::os::unix::fs::PermissionsExt;
2820
2821      let mut repo = Repo::new();
2822      let index = repo.publish(&[
2823        Pkg {
2824          full_name: MOD,
2825          version: "1.0.0",
2826          deps: &[],
2827          files: &[("plugins/Mod.dll", b"v1")],
2828        },
2829        Pkg {
2830          full_name: DEP,
2831          version: "1.0.0",
2832          deps: &[],
2833          files: &[("plugins/Dep.dll", b"dep")],
2834        },
2835      ]);
2836
2837      repo.install(&index, &[MOD], &[MOD], false);
2838
2839      set_enabled_in(repo.target.path(), &repo.eco, "valheim", MOD, false).unwrap();
2840
2841      // A read-only folder makes the `.old` rename fail, as a locked file would.
2842      let folder = repo.path("BepInEx/plugins/Author-Mod");
2843
2844      std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o555)).unwrap();
2845
2846      if std::fs::write(folder.join("probe"), b"").is_ok() {
2847        // Running as root: permissions cannot force the failure.
2848        return;
2849      }
2850
2851      let desired = names(&[MOD, DEP]);
2852      let batch = plan_install_batch(repo.target.path(), &desired, &desired).unwrap();
2853      let result = repo.rt.block_on(install_batch(
2854        repo.target.path(),
2855        repo.cache_base.path(),
2856        &repo.eco,
2857        &index,
2858        &repo.client,
2859        "valheim",
2860        &batch,
2861        1,
2862      ));
2863
2864      std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o755)).unwrap();
2865
2866      let outcome = result.expect("a failed enable must not discard the batch");
2867      let failed: Vec<&str> = outcome.failed.iter().map(|(n, _)| n.as_str()).collect();
2868
2869      assert_eq!(failed, vec![MOD]);
2870      assert_eq!(outcome.succeeded, names(&[DEP]));
2871      assert!(outcome.unchanged.is_empty());
2872    }
2873
2874    #[test]
2875    fn a_disabled_mod_not_named_is_still_skipped() {
2876      let mut repo = Repo::new();
2877      let index = repo.publish(&[Pkg {
2878        full_name: MOD,
2879        version: "1.0.0",
2880        deps: &[],
2881        files: &[("plugins/Mod.dll", b"v1")],
2882      }]);
2883
2884      repo.install(&index, &[MOD], &[MOD], false);
2885
2886      set_enabled_in(repo.target.path(), &repo.eco, "valheim", MOD, false).unwrap();
2887
2888      let outcome = repo.install(&index, &[MOD], &[], false);
2889
2890      assert_eq!(outcome.unchanged, names(&[MOD]));
2891      assert!(!repo.entry(MOD).enabled);
2892      assert!(repo.path("BepInEx/plugins/Author-Mod/Mod.dll.old").exists());
2893    }
2894
2895    #[test]
2896    fn a_mod_left_half_disabled_is_reinstalled() {
2897      let mut repo = Repo::new();
2898      let index = repo.publish(&[Pkg {
2899        full_name: MOD,
2900        version: "1.0.0",
2901        deps: &[],
2902        files: &[("plugins/Mod.dll", b"v1"), ("plugins/Extra.dll", b"v1")],
2903      }]);
2904
2905      repo.install(&index, &[MOD], &[MOD], false);
2906
2907      // A disable that failed after its first rename: one file toggled, the
2908      // other not, and `mods.yml` still recording the mod as enabled.
2909      let extra = repo.path("BepInEx/plugins/Author-Mod/Extra.dll");
2910
2911      std::fs::rename(
2912        &extra,
2913        repo.path("BepInEx/plugins/Author-Mod/Extra.dll.old"),
2914      )
2915      .unwrap();
2916
2917      let outcome = repo.install(&index, &[MOD], &[MOD], false);
2918
2919      assert_eq!(outcome.succeeded, names(&[MOD]));
2920      assert!(outcome.unchanged.is_empty());
2921      assert!(extra.exists());
2922      assert!(
2923        !repo
2924          .path("BepInEx/plugins/Author-Mod/Extra.dll.old")
2925          .exists()
2926      );
2927      assert!(repo.path("BepInEx/plugins/Author-Mod/Mod.dll").exists());
2928    }
2929
2930    #[cfg(unix)]
2931    #[test]
2932    fn a_failed_loader_reinstall_keeps_it_uninstallable() {
2933      let mut repo = Repo::new();
2934      let v1 = repo.publish(&[Pkg {
2935        full_name: LOADER,
2936        version: "5.4.2333",
2937        deps: &[],
2938        files: &[
2939          ("BepInExPack_Valheim/winhttp.dll", b"proxy"),
2940          ("BepInExPack_Valheim/BepInEx/core/BepInEx.dll", b"core"),
2941        ],
2942      }]);
2943
2944      repo.install(&v1, &[LOADER], &[LOADER], false);
2945
2946      let v2 = repo.publish(&[Pkg {
2947        full_name: LOADER,
2948        version: "5.4.2334",
2949        deps: &[],
2950        files: &[
2951          ("BepInExPack_Valheim/winhttp.dll", b"proxy"),
2952          ("BepInExPack_Valheim/BepInEx/core/BepInEx.dll", b"core"),
2953          ("BepInExPack_Valheim/BepInEx/core/Extra.dll", b"extra"),
2954        ],
2955      }]);
2956
2957      // A symlink at a destination only the new version writes makes the apply
2958      // refuse after the old payload is already gone.
2959      std::os::unix::fs::symlink(
2960        repo.path("elsewhere.dll"),
2961        repo.path("BepInEx/core/Extra.dll"),
2962      )
2963      .unwrap();
2964
2965      let desired = names(&[LOADER]);
2966      let batch = plan_install_batch(repo.target.path(), &desired, &desired).unwrap();
2967      let outcome = repo
2968        .rt
2969        .block_on(install_batch(
2970          repo.target.path(),
2971          repo.cache_base.path(),
2972          &repo.eco,
2973          &v2,
2974          &repo.client,
2975          "valheim",
2976          &batch,
2977          2,
2978        ))
2979        .unwrap();
2980      let failed: Vec<&str> = outcome.failed.iter().map(|(n, _)| n.as_str()).collect();
2981
2982      assert_eq!(failed, vec![LOADER]);
2983      assert!(state_file_path(repo.target.path(), LOADER).exists());
2984
2985      uninstall_mod_in(repo.target.path(), &repo.eco, "valheim", LOADER).unwrap();
2986
2987      assert!(modlist::read(repo.target.path()).unwrap().is_empty());
2988      assert!(!state_file_path(repo.target.path(), LOADER).exists());
2989    }
2990
2991    fn mod_history<'a>() -> [Pkg<'a>; 2] {
2992      [
2993        Pkg {
2994          full_name: MOD,
2995          version: "1.0.0",
2996          deps: &[],
2997          files: &[("plugins/Mod.dll", b"v1")],
2998        },
2999        Pkg {
3000          full_name: MOD,
3001          version: "2.0.0",
3002          deps: &[],
3003          files: &[("plugins/Mod.dll", b"v2")],
3004        },
3005      ]
3006    }
3007
3008    #[test]
3009    fn a_pinned_install_lands_the_requested_version_not_the_latest() {
3010      let mut repo = Repo::new();
3011      let index = repo.publish(&mod_history());
3012      let dll = repo.path("BepInEx/plugins/Author-Mod/Mod.dll");
3013
3014      let outcome = repo.install(&index, &["Author-Mod-1.0.0"], &["Author-Mod-1.0.0"], false);
3015
3016      assert_eq!(outcome.succeeded, names(&[MOD]));
3017      assert_eq!(repo.entry(MOD).version_number.to_string(), "1.0.0");
3018      assert_eq!(std::fs::read(&dll).unwrap(), b"v1");
3019
3020      repo.install(&index, &[MOD], &[MOD], false);
3021
3022      assert_eq!(repo.entry(MOD).version_number.to_string(), "2.0.0");
3023      assert_eq!(std::fs::read(&dll).unwrap(), b"v2");
3024
3025      repo.install(&index, &["Author-Mod-1.0.0"], &["Author-Mod-1.0.0"], false);
3026
3027      assert_eq!(repo.entry(MOD).version_number.to_string(), "1.0.0");
3028      assert_eq!(std::fs::read(&dll).unwrap(), b"v1");
3029    }
3030
3031    #[test]
3032    fn a_pin_holds_for_another_item_in_the_batch_that_depends_on_it() {
3033      let mut repo = Repo::new();
3034      let index = repo.publish(&[
3035        Pkg {
3036          full_name: DEP,
3037          version: "1.0.0",
3038          deps: &[],
3039          files: &[("plugins/Dep.dll", b"dep1")],
3040        },
3041        Pkg {
3042          full_name: DEP,
3043          version: "2.0.0",
3044          deps: &[],
3045          files: &[("plugins/Dep.dll", b"dep2")],
3046        },
3047        Pkg {
3048          full_name: MOD,
3049          version: "1.0.0",
3050          deps: &["Author-Dep-2.0.0"],
3051          files: &[("plugins/Mod.dll", b"v1")],
3052        },
3053      ]);
3054
3055      let desired = ["Author-Dep-1.0.0", MOD];
3056
3057      repo.install(&index, &desired, &desired, false);
3058
3059      assert_eq!(repo.entry(DEP).version_number.to_string(), "1.0.0");
3060      assert_eq!(repo.entry(MOD).version_number.to_string(), "1.0.0");
3061    }
3062
3063    #[test]
3064    fn a_missing_pinned_version_fails_rather_than_installing_the_latest() {
3065      let mut repo = Repo::new();
3066      let index = repo.publish(&mod_history());
3067      let outcome = repo.try_install(&index, &["Author-Mod-9.9.9"], &["Author-Mod-9.9.9"], false);
3068
3069      assert!(outcome.succeeded.is_empty());
3070      assert_eq!(outcome.failed.len(), 1);
3071      assert_eq!(outcome.failed[0].0, "Author-Mod-9.9.9");
3072      assert!(
3073        outcome.failed[0]
3074          .1
3075          .to_string()
3076          .contains("Author-Mod has no version 9.9.9"),
3077        "{}",
3078        outcome.failed[0].1
3079      );
3080      assert!(
3081        modlist::read(repo.target.path())
3082          .unwrap_or_default()
3083          .is_empty()
3084      );
3085    }
3086
3087    #[test]
3088    fn naming_a_disabled_mod_with_a_version_enables_it() {
3089      let mut repo = Repo::new();
3090      let index = repo.publish(&mod_history());
3091
3092      repo.install(&index, &["Author-Mod-1.0.0"], &["Author-Mod-1.0.0"], false);
3093      set_enabled_in(repo.target.path(), &repo.eco, "valheim", MOD, false).unwrap();
3094
3095      assert!(!repo.entry(MOD).enabled);
3096
3097      let outcome = repo.install(&index, &["Author-Mod-1.0.0"], &["Author-Mod-1.0.0"], false);
3098
3099      assert_eq!(outcome.unchanged, names(&[MOD]));
3100      assert!(repo.entry(MOD).enabled);
3101    }
3102  }
3103}