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