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, plan_install, remove_delisted, remove_state_payload, state_tracker_from_plan,
67  write_state_file,
68};
69use crate::models::{DependencyGraph, PackageIndex, Version};
70use modlist::{ProfileMod, SemVer};
71use std::collections::BTreeSet;
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 that failed, each with the reason.
142  pub failed: Vec<(String, Error)>,
143}
144
145impl BatchOutcome {
146  /// Whether every item succeeded.
147  pub fn is_ok(&self) -> bool {
148    self.failed.is_empty()
149  }
150}
151
152/// What an install batch will install, and whose disabled state it must restore
153/// afterwards.
154#[derive(Debug, Clone, Default)]
155#[non_exhaustive]
156pub struct InstallBatch {
157  /// Every identifier to install. Dependency closures are resolved per item by
158  /// the install itself, so this is what the caller asked for, not the closure.
159  pub desired: Vec<String>,
160  /// Recorded mods whose `enabled: false` must be restored after the batch.
161  ///
162  /// An install upserts every dependency-closure member with `enabled: true` and
163  /// `modlist::upsert` replaces the entry wholesale, so without this a mod the
164  /// user disabled is silently re-enabled and its `.old` files are orphaned
165  /// beside the freshly written ones.
166  pub protect_disabled: Vec<String>,
167}
168
169impl InstallBatch {
170  /// Builds a batch directly, for a caller that knows its own protect set rather
171  /// than deriving it from the target (e.g. an import applying an export's
172  /// recorded disabled state).
173  pub fn new(desired: Vec<String>, protect_disabled: Vec<String>) -> Self {
174    Self {
175      desired,
176      protect_disabled,
177    }
178  }
179}
180
181/// Plans an install batch against `target_dir`'s current record.
182///
183/// `desired` is everything to install. `explicit` is the subset the caller named
184/// directly, which is excluded from `protect_disabled`: asking for a mod by name
185/// is a request to have it active. Pass an empty `explicit` when the batch is run
186/// on the user's behalf rather than at their request, so every disabled mod is
187/// restored.
188pub fn plan_install_batch(
189  target_dir: &Path,
190  desired: &[String],
191  explicit: &[String],
192) -> Result<InstallBatch> {
193  let recorded = modlist::read(target_dir)?;
194
195  let protect_disabled = recorded
196    .iter()
197    .filter(|entry| !entry.enabled)
198    .filter(|entry| !explicit.iter().any(|name| name == &entry.name))
199    .map(|entry| entry.name.clone())
200    .collect();
201
202  Ok(InstallBatch {
203    desired: desired.to_vec(),
204    protect_disabled,
205  })
206}
207
208/// Re-disables everything `batch` protects, after the installs have run.
209///
210/// Must run after the whole batch, never interleaved: a mod later in a batch can
211/// pull an earlier one back in as a dependency, so only a final pass catches
212/// every re-enable. Runs even when the batch failed partway, since items
213/// installed before the failure are already enabled.
214pub fn reapply_batch_disabled(
215  target_dir: &Path,
216  eco: &Ecosystem,
217  game: &str,
218  batch: &InstallBatch,
219) -> Result<()> {
220  for full_name in &batch.protect_disabled {
221    set_enabled_in(target_dir, eco, game, full_name, false)?;
222  }
223
224  Ok(())
225}
226
227/// Installs an already-extracted package into `target_dir`: plans and applies
228/// the file copy, writes the `_state` tracker for any `State`-tracked files,
229/// and upserts the `mods.yml` entry.
230///
231/// This is the apply-only, target-directory primitive; the full download
232/// pipeline ([`install_mod_in`]) is layered on top of it.
233#[allow(clippy::too_many_arguments)]
234pub fn install_extracted_in(
235  target_dir: &Path,
236  eco: &Ecosystem,
237  game: &str,
238  full_name: &str,
239  owner: &str,
240  version: &Version,
241  extracted_dir: &Path,
242  archive: Option<&Path>,
243  installed_at_time: u64,
244) -> Result<()> {
245  let game_profile = eco
246    .game(game)
247    .and_then(|g| g.profile())
248    .ok_or_else(|| Error::Profile(format!("no ecosystem profile for game {game:?}")))?;
249
250  let plan = plan_install(eco, game_profile, full_name, extracted_dir, archive)?;
251
252  apply_install(&plan, target_dir)?;
253
254  if let Some(tracker) = state_tracker_from_plan(&plan, full_name) {
255    write_state_file(target_dir, &tracker)?;
256  }
257
258  let mut mods = modlist::read(target_dir)?;
259
260  modlist::upsert(
261    &mut mods,
262    mod_entry_from_version(full_name, owner, version, installed_at_time),
263  );
264
265  modlist::write(target_dir, &mods)
266}
267
268/// Installs an already-extracted package into an r2modman-layout profile
269/// (`<base>/<game>/profiles/<profile_name>`). See [`install_extracted_in`].
270#[allow(clippy::too_many_arguments)]
271pub fn install_extracted(
272  base: &Path,
273  eco: &Ecosystem,
274  game: &str,
275  profile_name: &str,
276  full_name: &str,
277  owner: &str,
278  version: &Version,
279  extracted_dir: &Path,
280  archive: Option<&Path>,
281  installed_at_time: u64,
282) -> Result<()> {
283  install_extracted_in(
284    &layout::profile_dir(base, game, profile_name),
285    eco,
286    game,
287    full_name,
288    owner,
289    version,
290    extracted_dir,
291    archive,
292    installed_at_time,
293  )
294}
295
296/// Why a recorded mod's files cannot be removed exactly.
297///
298/// Returned rather than flattened into a message because the distinction drives
299/// what a caller should advise: an absent record can be rebuilt by reinstalling,
300/// while an unreadable one is a file the engine will not guess the contents of.
301/// Only the wording of that advice belongs to the caller, and a caller that
302/// words it itself should not have to re-derive the reason by re-reading the
303/// record the engine has already read.
304///
305/// Deliberately **not** `#[non_exhaustive]`, unlike the rest of this crate's
306/// public types, for the same reason [`launch::LaunchPlan::to_command`] gives: a
307/// caller that words each refusal should stop compiling when a new reason
308/// appears, rather than silently printing a generic fallback for a case nobody
309/// worded. Designing that fallback is the thing this type exists to avoid.
310#[derive(Debug)]
311pub enum NotRemovable {
312  /// A recognised mod loader with no install record at `path`, so which files it
313  /// placed is unknown. Reinstalling the loader rebuilds the record.
314  RecordAbsent {
315    /// The mod that cannot be removed.
316    name: String,
317    /// Where the record was expected.
318    path: PathBuf,
319  },
320  /// An install record exists at `path` but could not be read or parsed, so the
321  /// engine does not know which files it tracked and will not guess.
322  RecordUnreadable {
323    /// The mod that cannot be removed.
324    name: String,
325    /// The record that could not be read.
326    path: PathBuf,
327    /// Why it could not be read.
328    source: Error,
329  },
330}
331
332impl std::fmt::Display for NotRemovable {
333  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
334    match self {
335      Self::RecordAbsent { name, path } => write!(
336        f,
337        "{name} is a mod loader with no install record at {}, so its files \
338         cannot be removed exactly. Reinstall it, then uninstall it again, or \
339         remove its files by hand.",
340        path.display()
341      ),
342      Self::RecordUnreadable { name, path, source } => write!(
343        f,
344        "{name} has an install record at {} that cannot be read, so its files \
345         cannot be removed exactly: {source}",
346        path.display()
347      ),
348    }
349  }
350}
351
352impl From<NotRemovable> for Error {
353  fn from(value: NotRemovable) -> Self {
354    Error::Profile(value.to_string())
355  }
356}
357
358/// Whether `full_name`'s files can be removed exactly, reporting the reason
359/// when they cannot.
360///
361/// A *missing* record is only a problem for a package whose files are `State`
362/// tracked. A recognised mod loader always gets one when this engine installs
363/// it, so a missing record means the target was adopted from disk rather than
364/// installed and there is no way to know which files the loader placed;
365/// uninstalling anyway would drop the `mods.yml` entry and leave every one of
366/// its files orphaned. An ordinary mod, by contrast, has no record by design
367/// under install rules with no `state` route, and `remove_delisted` reconciles
368/// its folders by presence instead.
369///
370/// An *unreadable* record is always a problem, loader or not: the file exists,
371/// so something tracked files, and guessing which is how an uninstall orphans or
372/// over-deletes. That case arrives as the `Err` from
373/// [`crate::install::read_state_file`], which is carried as
374/// [`NotRemovable::RecordUnreadable`]'s `source` rather than propagated on its
375/// own, so the two reasons reach a caller as one type it can match.
376///
377/// Returns [`NotRemovable`] rather than [`Error`] because the two reasons want
378/// different advice and a caller wording that advice itself would otherwise have
379/// to re-read the record to tell them apart. `?` still converts it for a caller
380/// that does not care, via [`From<NotRemovable>`](NotRemovable).
381///
382/// Public and separate from [`uninstall_mod_in`] so a caller can pre-flight a
383/// whole batch and refuse before touching the first mod. `uninstall_mod_in`
384/// calls it too, so a single uninstall is protected whether or not the caller
385/// does.
386///
387/// Takes no `game`: the only schema consulted is the mod-loader registry, which
388/// [`Ecosystem::modloader_package`] keys on package identity rather than on a
389/// game, so a `game` argument would go unread.
390pub fn ensure_removable(
391  target_dir: &Path,
392  eco: &Ecosystem,
393  full_name: &str,
394) -> std::result::Result<(), NotRemovable> {
395  match crate::install::read_state_file(target_dir, full_name) {
396    Ok(Some(_)) => return Ok(()),
397    Ok(None) => {}
398    Err(source) => {
399      return Err(NotRemovable::RecordUnreadable {
400        name: full_name.to_string(),
401        path: crate::install::state_file_path(target_dir, full_name),
402        source,
403      });
404    }
405  }
406
407  if eco.modloader_package(full_name).is_none() {
408    return Ok(());
409  }
410
411  Err(NotRemovable::RecordAbsent {
412    name: full_name.to_string(),
413    path: crate::install::state_file_path(target_dir, full_name),
414  })
415}
416
417/// Uninstalls a mod from `target_dir`: removes its `mods.yml` entry,
418/// reconciles the on-disk namespaced folders against the remaining set (so only
419/// this mod's folders are removed), and removes its `State`-tracked payload
420/// files along with its `_state` file.
421///
422/// [`ensure_removable`] gates the whole thing before anything is touched, so a
423/// mod whose files cannot be removed exactly is refused rather than half
424/// removed. See its doc for which records qualify.
425///
426/// Both removal paths are needed: `remove_delisted` reconciles the per-mod
427/// `<Owner-Name>/` folders under namespaced routes, while `State`-tracked files
428/// sit directly under a shared route (e.g. `BepInEx/core/`) and are only
429/// recoverable from the `_state` record. This function does not special-case
430/// the mod loader beyond the refusal above: naming it as `full_name` removes
431/// it through these same two paths like any other mod, which is how callers
432/// such as `vmm uninstall --all` uninstall it. Only shared config
433/// (`none`-tracked) is unconditionally left untouched, since nothing this
434/// function does ever names it for removal.
435pub fn uninstall_mod_in(
436  target_dir: &Path,
437  eco: &Ecosystem,
438  game: &str,
439  full_name: &str,
440) -> Result<()> {
441  let game_profile = eco
442    .game(game)
443    .and_then(|g| g.profile())
444    .ok_or_else(|| Error::Profile(format!("no ecosystem profile for game {game:?}")))?;
445
446  // Before any mutation. `remove_state_payload` below reaches an unreadable
447  // record on its own, but only after `remove_delisted` has already deleted this
448  // mod's folders and before `mods.yml` is rewritten, so the entry would be left
449  // naming files that are gone. Refusing here is what keeps both sides intact.
450  ensure_removable(target_dir, eco, full_name)?;
451
452  let mut mods = modlist::read(target_dir)?;
453
454  mods.retain(|m| m.name != full_name);
455
456  let keep: BTreeSet<String> = mods.iter().map(|m| m.name.clone()).collect();
457
458  remove_delisted(game_profile, target_dir, &keep)?;
459  remove_state_payload(target_dir, full_name)?;
460
461  modlist::write(target_dir, &mods)
462}
463
464/// Uninstalls a mod from an r2modman-layout profile. See [`uninstall_mod_in`].
465pub fn uninstall_mod(
466  base: &Path,
467  eco: &Ecosystem,
468  game: &str,
469  profile_name: &str,
470  full_name: &str,
471) -> Result<()> {
472  uninstall_mod_in(
473    &layout::profile_dir(base, game, profile_name),
474    eco,
475    game,
476    full_name,
477  )
478}
479
480/// Enables or disables an installed mod in `target_dir`: applies the on-disk
481/// file toggle through the install layer, then flips `enabled` in `mods.yml`.
482/// Errors if the mod is not present in `target_dir`.
483///
484/// A mod-loader pack (see [`Ecosystem::modloader_package`]) is a no-op here,
485/// matching r2modman: loaders can't be disabled, so neither the on-disk files
486/// nor its `enabled` flag are touched.
487pub fn set_enabled_in(
488  target_dir: &Path,
489  eco: &Ecosystem,
490  game: &str,
491  full_name: &str,
492  enabled: bool,
493) -> Result<()> {
494  let game_profile = eco
495    .game(game)
496    .and_then(|g| g.profile())
497    .ok_or_else(|| Error::Profile(format!("no ecosystem profile for game {game:?}")))?;
498
499  let mut mods = modlist::read(target_dir)?;
500
501  let entry = mods
502    .iter_mut()
503    .find(|m| m.name == full_name)
504    .ok_or_else(|| Error::Profile(format!("{full_name} is not installed")))?;
505
506  if eco.modloader_package(full_name).is_some() {
507    return Ok(());
508  }
509
510  if enabled {
511    crate::install::enable_mod(game_profile, target_dir, full_name)?;
512  } else {
513    crate::install::disable_mod(game_profile, target_dir, full_name)?;
514  }
515
516  entry.enabled = enabled;
517
518  modlist::write(target_dir, &mods)
519}
520
521/// Enables or disables an installed mod in an r2modman-layout profile. See
522/// [`set_enabled_in`].
523pub fn set_enabled(
524  base: &Path,
525  eco: &Ecosystem,
526  game: &str,
527  profile_name: &str,
528  full_name: &str,
529  enabled: bool,
530) -> Result<()> {
531  set_enabled_in(
532    &layout::profile_dir(base, game, profile_name),
533    eco,
534    game,
535    full_name,
536    enabled,
537  )
538}
539
540/// Enables an installed mod in `target_dir`. See [`set_enabled_in`].
541pub fn enable_mod_in(
542  target_dir: &Path,
543  eco: &Ecosystem,
544  game: &str,
545  full_name: &str,
546) -> Result<()> {
547  set_enabled_in(target_dir, eco, game, full_name, true)
548}
549
550/// Disables an installed mod in `target_dir`. See [`set_enabled_in`].
551pub fn disable_mod_in(
552  target_dir: &Path,
553  eco: &Ecosystem,
554  game: &str,
555  full_name: &str,
556) -> Result<()> {
557  set_enabled_in(target_dir, eco, game, full_name, false)
558}
559
560/// Enables an installed mod. See [`set_enabled`].
561pub fn enable_mod(
562  base: &Path,
563  eco: &Ecosystem,
564  game: &str,
565  profile_name: &str,
566  full_name: &str,
567) -> Result<()> {
568  set_enabled(base, eco, game, profile_name, full_name, true)
569}
570
571/// Disables an installed mod. See [`set_enabled`].
572pub fn disable_mod(
573  base: &Path,
574  eco: &Ecosystem,
575  game: &str,
576  profile_name: &str,
577  full_name: &str,
578) -> Result<()> {
579  set_enabled(base, eco, game, profile_name, full_name, false)
580}
581
582/// Installs a mod and its full dependency closure into `target_dir`: resolves
583/// the download set against `index`, downloads the archives via `client`,
584/// extracts each into `cache_base`'s per-game package cache, and installs
585/// each into `target_dir` (updating `mods.yml`). Returns the sorted
586/// `Owner-Name` identifiers installed.
587///
588/// `cache_base` is deliberately independent of `target_dir`: packages are
589/// extracted *from* it, never installed *into* it. See the module docs for
590/// why it must persist across installs and, in server mode, must not be
591/// `target_dir` itself.
592#[allow(clippy::too_many_arguments)]
593async fn install_one(
594  target_dir: &Path,
595  cache_base: &Path,
596  eco: &Ecosystem,
597  index: &PackageIndex,
598  client: &ThunderstoreClient,
599  game: &str,
600  full_name: &str,
601  installed_at_time: u64,
602) -> Result<Vec<String>> {
603  let resolution = DependencyGraph::new(vec![full_name.to_string()]).resolve_report(index);
604
605  if !resolution.unresolved.is_empty() {
606    return Err(Error::Profile(format!(
607      "cannot install {full_name}: unresolved dependencies: {}",
608      resolution.unresolved.join(", ")
609    )));
610  }
611
612  let urls = resolution.urls;
613
614  client.download_files(urls.clone()).await?;
615
616  let downloads = client.cache_dir().join("downloads");
617  let mut installed = Vec::new();
618
619  for filename in urls.keys() {
620    let (pkg_full_name, version_str) = parse_package_filename(filename)
621      .ok_or_else(|| Error::Profile(format!("unparseable package filename {filename:?}")))?;
622
623    let archive = downloads.join(filename);
624
625    let extracted =
626      cache::ensure_extracted(cache_base, game, &pkg_full_name, &version_str, &archive)?;
627
628    let package = index
629      .get_package_by_full_name(&pkg_full_name)
630      .ok_or_else(|| Error::Profile(format!("{pkg_full_name} missing from index")))?;
631
632    let owner = package.owner.clone().unwrap_or_default();
633    let version = package
634      .versions
635      .iter()
636      .find(|v| v.version_number.as_deref() == Some(version_str.as_str()))
637      .or_else(|| package.latest_version())
638      .ok_or_else(|| Error::Profile(format!("{pkg_full_name} has no usable version")))?;
639
640    install_extracted_in(
641      target_dir,
642      eco,
643      game,
644      &pkg_full_name,
645      &owner,
646      version,
647      &extracted,
648      Some(&archive),
649      installed_at_time,
650    )?;
651
652    installed.push(pkg_full_name);
653  }
654
655  installed.sort();
656
657  Ok(installed)
658}
659
660/// Installs every identifier in `batch`, restoring protected disabled state
661/// afterwards, and reports per-item outcomes.
662///
663/// Failures do not abort the batch: each item is attempted and its reason
664/// recorded, because an operation that already installed earlier items has to
665/// say which ones. The disabled-state restore runs regardless, since items
666/// installed before a failure are already enabled.
667#[allow(clippy::too_many_arguments)]
668pub async fn install_batch(
669  target_dir: &Path,
670  cache_base: &Path,
671  eco: &Ecosystem,
672  index: &PackageIndex,
673  client: &ThunderstoreClient,
674  game: &str,
675  batch: &InstallBatch,
676  installed_at_time: u64,
677) -> Result<BatchOutcome> {
678  let mut outcome = BatchOutcome::default();
679
680  for full_name in &batch.desired {
681    match install_one(
682      target_dir,
683      cache_base,
684      eco,
685      index,
686      client,
687      game,
688      full_name,
689      installed_at_time,
690    )
691    .await
692    {
693      Ok(installed) => outcome.succeeded.extend(installed),
694      Err(error) => outcome.failed.push((full_name.clone(), error)),
695    }
696  }
697
698  reapply_batch_disabled(target_dir, eco, game, batch)?;
699
700  outcome.succeeded.sort();
701  outcome.succeeded.dedup();
702
703  Ok(outcome)
704}
705
706/// Installs one mod and its dependency closure, protecting the disabled state of
707/// every recorded mod it did not name. See [`install_batch`].
708///
709/// Returns every `Owner-Name` actually installed, which is the named package
710/// plus its transitive dependencies, so it is normally longer than the one name
711/// passed in.
712///
713/// `cache_base` must not be `target_dir` or a directory inside it, or extracted
714/// packages would land inside the installed mod tree.
715///
716/// # Errors
717///
718/// - [`Error::Http`] or
719///   [`Error::Download`] if fetching the package or a
720///   dependency fails.
721/// - [`Error::Install`] if the ecosystem has no install
722///   rules for `game`, or a package's files cannot be routed.
723/// - [`Error::Io`] if `target_dir` or `cache_base` cannot be
724///   written.
725///
726/// A package that is absent from `index` is skipped rather than reported here;
727/// see [`crate::models::DependencyGraph::resolve_report`] to detect that.
728///
729/// # Examples
730///
731/// ```no_run
732/// use std::path::Path;
733/// use thunderstore_engine::client::ThunderstoreClient;
734/// use thunderstore_engine::ecosystem::Ecosystem;
735/// use thunderstore_engine::profile::{install_mod_in, modlist};
736///
737/// # async fn run() -> thunderstore_engine::Result<()> {
738/// let client = ThunderstoreClient::builder()
739///   .community("valheim")
740///   .cache_dir("/var/cache/my-mod-manager")
741///   .build()?;
742///
743/// let index = client.get_manifest().await?;
744/// let eco = Ecosystem::bundled();
745///
746/// let installed = install_mod_in(
747///   Path::new("/srv/valheim"),
748///   Path::new("/var/cache/my-mod-manager"),
749///   &eco,
750///   &index,
751///   &client,
752///   "valheim",
753///   "denikson-BepInExPack_Valheim",
754///   modlist::now_millis(),
755/// )
756/// .await?;
757///
758/// println!("installed {} packages", installed.len());
759/// # Ok(())
760/// # }
761/// ```
762#[allow(clippy::too_many_arguments)]
763pub async fn install_mod_in(
764  target_dir: &Path,
765  cache_base: &Path,
766  eco: &Ecosystem,
767  index: &PackageIndex,
768  client: &ThunderstoreClient,
769  game: &str,
770  full_name: &str,
771  installed_at_time: u64,
772) -> Result<Vec<String>> {
773  let named = [full_name.to_string()];
774  let batch = plan_install_batch(target_dir, &named, &named)?;
775
776  let outcome = install_batch(
777    target_dir,
778    cache_base,
779    eco,
780    index,
781    client,
782    game,
783    &batch,
784    installed_at_time,
785  )
786  .await?;
787
788  match outcome.failed.into_iter().next() {
789    Some((_, error)) => Err(error),
790    None => Ok(outcome.succeeded),
791  }
792}
793
794/// Installs a mod and its full dependency closure into an r2modman-layout
795/// profile, using `base` as both the profile base and the package cache
796/// base (matching this pipeline's pre-existing behavior). See
797/// [`install_mod_in`].
798#[allow(clippy::too_many_arguments)]
799pub async fn install_mod(
800  base: &Path,
801  eco: &Ecosystem,
802  index: &PackageIndex,
803  client: &ThunderstoreClient,
804  game: &str,
805  profile_name: &str,
806  full_name: &str,
807  installed_at_time: u64,
808) -> Result<Vec<String>> {
809  install_mod_in(
810    &layout::profile_dir(base, game, profile_name),
811    base,
812    eco,
813    index,
814    client,
815    game,
816    full_name,
817    installed_at_time,
818  )
819  .await
820}
821
822/// What an uninstall batch will remove.
823///
824/// Mods and loaders are held apart because a caller showing this to a user needs
825/// to say the loader goes too, and because removing loaders last keeps every
826/// intermediate state one a user could have reached by hand. Ordinarily there is
827/// at most one loader, but the registry is keyed on package identity rather than
828/// game, so nothing rules out a second.
829#[derive(Debug, Clone, Default)]
830#[non_exhaustive]
831pub struct UninstallBatch {
832  /// Every non-loader identifier to remove, sorted.
833  pub mods: Vec<String>,
834  /// Every recognised mod loader to remove, sorted, removed after `mods`.
835  pub loaders: Vec<String>,
836  /// Root-relative mod folders the record does not account for, which the
837  /// reconcile sweep removes along with the named mods.
838  pub untracked: Vec<PathBuf>,
839}
840
841/// Every one of `names` that cannot be removed exactly, with the reason.
842///
843/// Separate from [`plan_uninstall_batch`], which refuses outright, so a caller
844/// can list every problem at once instead of discovering them one at a time. The
845/// reason is a [`NotRemovable`] rather than an [`Error`] so a caller can word
846/// each kind of refusal without re-deriving which kind it is.
847pub fn check_removable_batch(
848  target_dir: &Path,
849  eco: &Ecosystem,
850  names: &[String],
851) -> Vec<(String, NotRemovable)> {
852  names
853    .iter()
854    .filter_map(|name| match ensure_removable(target_dir, eco, name) {
855      Ok(()) => None,
856      Err(refusal) => Some((name.clone(), refusal)),
857    })
858    .collect()
859}
860
861/// Plans an uninstall, refusing before anything is mutated.
862///
863/// `names` of `None` means every recorded mod. A name that is not installed, or
864/// any mod that cannot be removed exactly, is an error rather than a per-item
865/// failure: this runs before any mutation, so refusing the whole request is what
866/// keeps a batch from failing partway through.
867pub fn plan_uninstall_batch(
868  target_dir: &Path,
869  eco: &Ecosystem,
870  game: &str,
871  names: Option<&[String]>,
872) -> Result<UninstallBatch> {
873  let game_profile = eco
874    .game(game)
875    .and_then(|g| g.profile())
876    .ok_or_else(|| Error::Profile(format!("no ecosystem profile for game {game:?}")))?;
877
878  let recorded = modlist::read(target_dir)?;
879
880  let subjects: Vec<String> = match names {
881    Some(names) => {
882      for name in names {
883        if modlist::find(&recorded, name).is_none() {
884          return Err(Error::Profile(format!("{name} is not installed")));
885        }
886      }
887
888      names.to_vec()
889    }
890    None => recorded.iter().map(|entry| entry.name.clone()).collect(),
891  };
892
893  if let Some((name, refusal)) = check_removable_batch(target_dir, eco, &subjects)
894    .into_iter()
895    .next()
896  {
897    return Err(Error::Profile(format!(
898      "{name} cannot be removed: {refusal}"
899    )));
900  }
901
902  let mut mods: Vec<String> = subjects
903    .iter()
904    .filter(|name| eco.modloader_package(name).is_none())
905    .cloned()
906    .collect();
907  let mut loaders: Vec<String> = subjects
908    .iter()
909    .filter(|name| eco.modloader_package(name).is_some())
910    .cloned()
911    .collect();
912
913  mods.sort();
914  loaders.sort();
915
916  let keep: BTreeSet<String> = recorded.iter().map(|entry| entry.name.clone()).collect();
917  let untracked = crate::install::plan_delisted(game_profile, target_dir, &keep)?;
918
919  Ok(UninstallBatch {
920    mods,
921    loaders,
922    untracked,
923  })
924}
925
926/// Removes everything `batch` names, loaders last, reporting per-item outcomes.
927///
928/// Each removal re-derives its keep-set from the remaining record, so ordering is
929/// not required for correctness; loaders go last so every intermediate state is
930/// coherent.
931pub fn uninstall_batch(
932  target_dir: &Path,
933  eco: &Ecosystem,
934  game: &str,
935  batch: &UninstallBatch,
936) -> Result<BatchOutcome> {
937  let mut outcome = BatchOutcome::default();
938
939  for full_name in batch.mods.iter().chain(batch.loaders.iter()) {
940    match uninstall_mod_in(target_dir, eco, game, full_name) {
941      Ok(()) => outcome.succeeded.push(full_name.clone()),
942      Err(error) => outcome.failed.push((full_name.clone(), error)),
943    }
944  }
945
946  Ok(outcome)
947}
948
949/// What a bulk enable or disable will do.
950///
951/// Loaders are excluded rather than merely skipped. `set_enabled_in` already
952/// no-ops a loader, so including one would be harmless, but it would be counted
953/// as a mod the operation acted on and any summary built from this would overstate
954/// what happened.
955#[derive(Debug, Clone, Default)]
956#[non_exhaustive]
957pub struct ToggleBatch {
958  /// Identifiers whose state will change, sorted.
959  pub changing: Vec<String>,
960  /// Identifiers already in the requested state, sorted.
961  pub already: Vec<String>,
962  /// Recognised loaders, excluded from the toggle entirely, sorted.
963  pub loaders: Vec<String>,
964  /// The sense this batch was planned for: `true` to enable, `false` to
965  /// disable. `set_enabled_batch` applies this instead of taking its own
966  /// `enabled` argument, so a caller cannot execute a batch against a sense it
967  /// was not planned for.
968  pub enabled: bool,
969}
970
971/// Plans a bulk enable or disable. `names` of `None` means every recorded mod.
972///
973/// A name in `names` that is not recorded at all is refused outright, in the
974/// same shape as [`plan_uninstall_batch`]'s refusal, rather than silently
975/// producing an empty plan: iterating the record and filtering would let an
976/// unrecorded name simply never appear, indistinguishable from "nothing to
977/// do".
978pub fn plan_set_enabled_batch(
979  target_dir: &Path,
980  eco: &Ecosystem,
981  names: Option<&[String]>,
982  enabled: bool,
983) -> Result<ToggleBatch> {
984  let recorded = modlist::read(target_dir)?;
985
986  if let Some(names) = names {
987    for name in names {
988      if modlist::find(&recorded, name).is_none() {
989        return Err(Error::Profile(format!("{name} is not installed")));
990      }
991    }
992  }
993
994  let mut batch = ToggleBatch {
995    enabled,
996    ..ToggleBatch::default()
997  };
998
999  for entry in &recorded {
1000    if let Some(names) = names
1001      && !names.iter().any(|name| name == &entry.name)
1002    {
1003      continue;
1004    }
1005
1006    if eco.modloader_package(&entry.name).is_some() {
1007      batch.loaders.push(entry.name.clone());
1008    } else if entry.enabled == enabled {
1009      batch.already.push(entry.name.clone());
1010    } else {
1011      batch.changing.push(entry.name.clone());
1012    }
1013  }
1014
1015  batch.changing.sort();
1016  batch.already.sort();
1017  batch.loaders.sort();
1018
1019  Ok(batch)
1020}
1021
1022/// Applies `batch`, reporting per-item outcomes.
1023///
1024/// One mod's failure does not abort the rest: returning early would leave a
1025/// half-applied toggle with nothing reported. Applies `batch.enabled`, the
1026/// sense it was planned for, rather than taking its own `enabled` argument, so
1027/// the executor cannot disagree with the plan it is executing.
1028pub fn set_enabled_batch(
1029  target_dir: &Path,
1030  eco: &Ecosystem,
1031  game: &str,
1032  batch: &ToggleBatch,
1033) -> Result<BatchOutcome> {
1034  let mut outcome = BatchOutcome::default();
1035
1036  for full_name in &batch.changing {
1037    match set_enabled_in(target_dir, eco, game, full_name, batch.enabled) {
1038      Ok(()) => outcome.succeeded.push(full_name.clone()),
1039      Err(error) => outcome.failed.push((full_name.clone(), error)),
1040    }
1041  }
1042
1043  Ok(outcome)
1044}
1045
1046#[cfg(test)]
1047mod tests {
1048  use super::*;
1049  use crate::ecosystem::Ecosystem;
1050  use crate::install::state_file_path;
1051  use crate::models::Version;
1052  use std::io::{Cursor, Write};
1053  use tempfile::tempdir;
1054  use time::OffsetDateTime;
1055  use zip::write::{FileOptions, ZipWriter};
1056
1057  fn write_file(path: &std::path::Path, bytes: &[u8]) {
1058    std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1059    std::fs::File::create(path)
1060      .unwrap()
1061      .write_all(bytes)
1062      .unwrap();
1063  }
1064
1065  fn version(full_name: &str) -> Version {
1066    Version {
1067      name: Some("Mod".to_string()),
1068      full_name: Some(full_name.to_string()),
1069      description: Some("desc".to_string()),
1070      icon: Some("icon.png".to_string()),
1071      version_number: Some("1.2.3".to_string()),
1072      dependencies: vec!["Other-Dep-1.0.0".to_string()],
1073      download_url: Some("https://example.com/dl".to_string()),
1074      downloads: Some(1),
1075      date_created: OffsetDateTime::UNIX_EPOCH,
1076      website_url: Some("https://example.com".to_string()),
1077      is_active: Some(true),
1078      uuid4: Some("u".to_string()),
1079      file_size: Some(1),
1080    }
1081  }
1082
1083  #[test]
1084  fn mod_entry_maps_version_with_r2modman_defaults() {
1085    let entry = mod_entry_from_version("Author-Mod", "Author", &version("Author-Mod"), 123);
1086
1087    assert_eq!(entry.name, "Author-Mod");
1088    assert_eq!(entry.author_name, "Author");
1089    assert_eq!(entry.network_mode, "both");
1090    assert_eq!(entry.package_type, "other");
1091    assert_eq!(entry.install_mode, "managed");
1092    assert_eq!(entry.game_version, "0");
1093    assert_eq!(entry.manifest_version, 1);
1094    assert_eq!(
1095      entry.version_number,
1096      crate::profile::modlist::SemVer {
1097        major: 1,
1098        minor: 2,
1099        patch: 3
1100      }
1101    );
1102    assert_eq!(entry.dependencies, vec!["Other-Dep-1.0.0".to_string()]);
1103    assert_eq!(entry.installed_at_time, 123);
1104    assert!(entry.enabled);
1105  }
1106
1107  #[test]
1108  fn install_extracted_places_files_and_records_mods_yml() {
1109    let eco = Ecosystem::bundled();
1110
1111    let dir = tempdir().unwrap();
1112    let base = dir.path();
1113
1114    let extracted = tempdir().unwrap();
1115    write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
1116    write_file(&extracted.path().join("manifest.json"), b"{}");
1117
1118    crate::profile::layout::create(base, "valheim", "Main").unwrap();
1119
1120    install_extracted(
1121      base,
1122      &eco,
1123      "valheim",
1124      "Main",
1125      "Author-Mod",
1126      "Author",
1127      &version("Author-Mod"),
1128      extracted.path(),
1129      None,
1130      123,
1131    )
1132    .unwrap();
1133
1134    let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
1135
1136    assert!(
1137      profile_dir
1138        .join("BepInEx/plugins/Author-Mod/Mod.dll")
1139        .exists()
1140    );
1141
1142    let mods = crate::profile::modlist::read(&profile_dir).unwrap();
1143
1144    assert_eq!(mods.len(), 1);
1145    assert_eq!(mods[0].name, "Author-Mod");
1146    assert!(mods[0].enabled);
1147  }
1148
1149  #[test]
1150  fn uninstall_removes_files_and_mods_yml_entry() {
1151    let eco = Ecosystem::bundled();
1152
1153    let dir = tempdir().unwrap();
1154    let base = dir.path();
1155
1156    let extracted = tempdir().unwrap();
1157    write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
1158
1159    crate::profile::layout::create(base, "valheim", "Main").unwrap();
1160
1161    install_extracted(
1162      base,
1163      &eco,
1164      "valheim",
1165      "Main",
1166      "Author-Mod",
1167      "Author",
1168      &version("Author-Mod"),
1169      extracted.path(),
1170      None,
1171      1,
1172    )
1173    .unwrap();
1174
1175    let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
1176
1177    assert!(
1178      profile_dir
1179        .join("BepInEx/plugins/Author-Mod/Mod.dll")
1180        .exists()
1181    );
1182
1183    uninstall_mod(base, &eco, "valheim", "Main", "Author-Mod").unwrap();
1184
1185    assert!(!profile_dir.join("BepInEx/plugins/Author-Mod").exists());
1186    assert_eq!(
1187      crate::profile::modlist::read(&profile_dir).unwrap().len(),
1188      0
1189    );
1190  }
1191
1192  #[test]
1193  fn uninstall_refuses_a_mod_whose_install_record_cannot_be_read() {
1194    let eco = Ecosystem::bundled();
1195
1196    let dir = tempdir().unwrap();
1197    let base = dir.path();
1198
1199    let extracted = tempdir().unwrap();
1200    write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
1201
1202    crate::profile::layout::create(base, "valheim", "Main").unwrap();
1203    install_extracted(
1204      base,
1205      &eco,
1206      "valheim",
1207      "Main",
1208      "Author-Mod",
1209      "Author",
1210      &version("Author-Mod"),
1211      extracted.path(),
1212      None,
1213      1,
1214    )
1215    .unwrap();
1216
1217    let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
1218
1219    // An ordinary mod routes entirely through its namespaced folder, so it gets
1220    // no tracker of its own. Nothing stops one existing anyway (a stray
1221    // leftover, or one written by hand), so an unparseable one is planted here.
1222    write_file(&state_file_path(&profile_dir, "Author-Mod"), b"not: [valid");
1223
1224    let error = uninstall_mod(base, &eco, "valheim", "Main", "Author-Mod").unwrap_err();
1225
1226    // `remove_state_payload` reaches the same unparseable record on its own, but
1227    // only after `remove_delisted` has deleted the folder and before `mods.yml`
1228    // is rewritten, which leaves the entry naming files that are already gone.
1229    // Refusing up front is what keeps both sides intact.
1230    assert!(
1231      profile_dir
1232        .join("BepInEx/plugins/Author-Mod/Mod.dll")
1233        .exists(),
1234      "the mod's files must survive a refusal; got error: {error}"
1235    );
1236    assert_eq!(
1237      crate::profile::modlist::read(&profile_dir).unwrap().len(),
1238      1
1239    );
1240  }
1241
1242  #[test]
1243  fn disable_then_enable_updates_disk_and_mods_yml() {
1244    let eco = Ecosystem::bundled();
1245
1246    let dir = tempdir().unwrap();
1247    let base = dir.path();
1248
1249    let extracted = tempdir().unwrap();
1250    write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
1251
1252    crate::profile::layout::create(base, "valheim", "Main").unwrap();
1253    install_extracted(
1254      base,
1255      &eco,
1256      "valheim",
1257      "Main",
1258      "Author-Mod",
1259      "Author",
1260      &version("Author-Mod"),
1261      extracted.path(),
1262      None,
1263      1,
1264    )
1265    .unwrap();
1266
1267    let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
1268
1269    disable_mod(base, &eco, "valheim", "Main", "Author-Mod").unwrap();
1270
1271    assert!(
1272      profile_dir
1273        .join("BepInEx/plugins/Author-Mod/Mod.dll.old")
1274        .exists()
1275    );
1276    assert!(!crate::profile::modlist::read(&profile_dir).unwrap()[0].enabled);
1277
1278    enable_mod(base, &eco, "valheim", "Main", "Author-Mod").unwrap();
1279
1280    assert!(
1281      profile_dir
1282        .join("BepInEx/plugins/Author-Mod/Mod.dll")
1283        .exists()
1284    );
1285    assert!(crate::profile::modlist::read(&profile_dir).unwrap()[0].enabled);
1286  }
1287
1288  #[test]
1289  fn disable_leaves_files_alone_when_the_install_record_cannot_be_read() {
1290    let eco = Ecosystem::bundled();
1291
1292    let dir = tempdir().unwrap();
1293    let base = dir.path();
1294
1295    let extracted = tempdir().unwrap();
1296    write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
1297
1298    crate::profile::layout::create(base, "valheim", "Main").unwrap();
1299    install_extracted(
1300      base,
1301      &eco,
1302      "valheim",
1303      "Main",
1304      "Author-Mod",
1305      "Author",
1306      &version("Author-Mod"),
1307      extracted.path(),
1308      None,
1309      1,
1310    )
1311    .unwrap();
1312
1313    let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
1314
1315    write_file(&state_file_path(&profile_dir, "Author-Mod"), b"not: [valid");
1316
1317    let error = disable_mod(base, &eco, "valheim", "Main", "Author-Mod").unwrap_err();
1318
1319    // An unreadable record already refused a disable, but only once the `.old`
1320    // rename pass had run, and `mods.yml` is written last of all. That left the
1321    // files disabled on disk and the entry still claiming `enabled`. Reading the
1322    // record up front is what keeps the two in step.
1323    assert!(
1324      profile_dir
1325        .join("BepInEx/plugins/Author-Mod/Mod.dll")
1326        .exists(),
1327      "the mod's files must be left as they were; got error: {error}"
1328    );
1329    assert!(
1330      !profile_dir
1331        .join("BepInEx/plugins/Author-Mod/Mod.dll.old")
1332        .exists()
1333    );
1334    assert!(crate::profile::modlist::read(&profile_dir).unwrap()[0].enabled);
1335  }
1336
1337  #[test]
1338  fn disable_mod_is_noop_for_modloader_pack() {
1339    let eco = Ecosystem::bundled();
1340
1341    let dir = tempdir().unwrap();
1342    let base = dir.path();
1343    let full_name = "denikson-BepInExPack_Valheim";
1344
1345    crate::profile::layout::create(base, "valheim", "Main").unwrap();
1346
1347    let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
1348    let loader_file = profile_dir.join("winhttp.dll");
1349
1350    write_file(&loader_file, b"dll");
1351
1352    let mut mods = Vec::new();
1353
1354    modlist::upsert(
1355      &mut mods,
1356      mod_entry_from_version(full_name, "denikson", &version(full_name), 1),
1357    );
1358    modlist::write(&profile_dir, &mods).unwrap();
1359
1360    let tracker = crate::install::ModFileTracker {
1361      mod_name: full_name.to_string(),
1362      files: vec![("unused-cache-source".to_string(), "winhttp.dll".to_string())],
1363    };
1364
1365    write_state_file(&profile_dir, &tracker).unwrap();
1366
1367    disable_mod(base, &eco, "valheim", "Main", full_name).unwrap();
1368
1369    assert!(loader_file.exists());
1370    assert!(crate::profile::modlist::read(&profile_dir).unwrap()[0].enabled);
1371  }
1372
1373  #[test]
1374  fn install_mod_fails_on_unresolved_dependency() {
1375    let eco = Ecosystem::bundled();
1376
1377    let package = crate::models::Package {
1378      name: Some("Mod".to_string()),
1379      full_name: Some("Author-Mod".to_string()),
1380      owner: Some("Author".to_string()),
1381      package_url: Some("https://example.com/Author-Mod".to_string()),
1382      date_created: OffsetDateTime::UNIX_EPOCH,
1383      date_updated: OffsetDateTime::UNIX_EPOCH,
1384      uuid4: Some("u".to_string()),
1385      rating_score: Some(0),
1386      is_pinned: Some(false),
1387      is_deprecated: Some(false),
1388      has_nsfw_content: Some(false),
1389      categories: vec![],
1390      versions: vec![Version {
1391        dependencies: vec!["Missing-Dep-1.0.0".to_string()],
1392        ..version("Author-Mod")
1393      }],
1394    };
1395
1396    let index: PackageIndex = vec![package].into();
1397
1398    let dir = tempdir().unwrap();
1399    let base = dir.path();
1400    let cache_dir = tempdir().unwrap();
1401
1402    let client = ThunderstoreClient::builder()
1403      .package_index_url("http://localhost/pkg/")
1404      .cache_dir(cache_dir.path())
1405      .build()
1406      .unwrap();
1407
1408    crate::profile::layout::create(base, "valheim", "Main").unwrap();
1409
1410    let rt = tokio::runtime::Runtime::new().unwrap();
1411    let result = rt.block_on(install_mod(
1412      base,
1413      &eco,
1414      &index,
1415      &client,
1416      "valheim",
1417      "Main",
1418      "Author-Mod",
1419      1,
1420    ));
1421
1422    let err = result.unwrap_err().to_string();
1423
1424    assert!(err.contains("Missing-Dep"), "unexpected error: {err}");
1425  }
1426
1427  #[test]
1428  fn server_mode_lifecycle_against_bare_target_dir() {
1429    let eco = Ecosystem::bundled();
1430
1431    let dir = tempdir().unwrap();
1432    let target_dir = dir.path();
1433
1434    let extracted = tempdir().unwrap();
1435    write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
1436
1437    install_extracted_in(
1438      target_dir,
1439      &eco,
1440      "valheim",
1441      "Author-Mod",
1442      "Author",
1443      &version("Author-Mod"),
1444      extracted.path(),
1445      None,
1446      123,
1447    )
1448    .unwrap();
1449
1450    assert!(
1451      target_dir
1452        .join("BepInEx/plugins/Author-Mod/Mod.dll")
1453        .exists()
1454    );
1455    assert!(target_dir.join("mods.yml").exists());
1456
1457    let mods = modlist::read(target_dir).unwrap();
1458
1459    assert_eq!(mods.len(), 1);
1460    assert_eq!(mods[0].name, "Author-Mod");
1461    assert!(mods[0].enabled);
1462
1463    // Simulate a `State`-tracked file alongside the `Subdir`-tracked plugin
1464    // above (e.g. a MelonLoader-style loose `.dll`), proving `_state` I/O
1465    // resolves entirely against `target_dir` with no r2modman profile layout
1466    // involved: the source is a real file so enable's restore-from-cache
1467    // copy succeeds, and the dest starts out present so disable's removal is
1468    // a real effect, not a no-op.
1469    let state_source = extracted.path().join("state-source.txt");
1470
1471    write_file(&state_source, b"state-data");
1472    write_file(&target_dir.join("state-dest.txt"), b"state-data");
1473
1474    let tracker = crate::install::ModFileTracker {
1475      mod_name: "Author-Mod".to_string(),
1476      files: vec![(
1477        state_source.to_string_lossy().to_string(),
1478        "state-dest.txt".to_string(),
1479      )],
1480    };
1481
1482    write_state_file(target_dir, &tracker).unwrap();
1483
1484    assert!(state_file_path(target_dir, "Author-Mod").exists());
1485
1486    disable_mod_in(target_dir, &eco, "valheim", "Author-Mod").unwrap();
1487
1488    assert!(
1489      target_dir
1490        .join("BepInEx/plugins/Author-Mod/Mod.dll.old")
1491        .exists()
1492    );
1493    assert!(!target_dir.join("state-dest.txt").exists());
1494    assert!(!modlist::read(target_dir).unwrap()[0].enabled);
1495
1496    enable_mod_in(target_dir, &eco, "valheim", "Author-Mod").unwrap();
1497
1498    assert!(
1499      target_dir
1500        .join("BepInEx/plugins/Author-Mod/Mod.dll")
1501        .exists()
1502    );
1503    assert!(target_dir.join("state-dest.txt").exists());
1504    assert!(modlist::read(target_dir).unwrap()[0].enabled);
1505
1506    uninstall_mod_in(target_dir, &eco, "valheim", "Author-Mod").unwrap();
1507
1508    assert!(!target_dir.join("BepInEx/plugins/Author-Mod").exists());
1509    // The `State`-tracked payload lives under a shared route, so `remove_delisted`
1510    // never reconciles it — uninstall must remove it from the `_state` record
1511    // before discarding that record, or the file would be orphaned untracked.
1512    assert!(!target_dir.join("state-dest.txt").exists());
1513    assert!(!state_file_path(target_dir, "Author-Mod").exists());
1514    assert_eq!(modlist::read(target_dir).unwrap().len(), 0);
1515  }
1516
1517  fn build_mod_zip() -> Vec<u8> {
1518    let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
1519    let opts: FileOptions<'_, ()> =
1520      FileOptions::default().compression_method(zip::CompressionMethod::Stored);
1521
1522    for (name, body) in [
1523      ("manifest.json", &b"{\"version_number\":\"1.0.0\"}"[..]),
1524      ("plugins/CoolMod.dll", &b"dll-bytes"[..]),
1525    ] {
1526      zip.start_file(name, opts).unwrap();
1527      zip.write_all(body).unwrap();
1528    }
1529
1530    zip.finish().unwrap().into_inner()
1531  }
1532
1533  fn index_json(server_url: &str) -> String {
1534    format!(
1535      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}}]}}]"#
1536    )
1537  }
1538
1539  #[test]
1540  fn install_mod_in_extracts_into_cache_base_not_target_dir() {
1541    let mut server = mockito::Server::new();
1542    let zip_bytes = build_mod_zip();
1543
1544    let dl = server
1545      .mock("GET", "/dl/mod.zip")
1546      .with_status(200)
1547      .with_header("Content-Type", "application/zip")
1548      .with_body(zip_bytes)
1549      .create();
1550    let idx = server
1551      .mock("GET", "/pkg/")
1552      .with_status(200)
1553      .with_header("Content-Type", "application/json")
1554      .with_header("Last-Modified", "Wed, 21 Feb 2024 15:30:45 GMT")
1555      .with_body(index_json(&server.url()))
1556      .create();
1557
1558    let http_cache = tempdir().unwrap();
1559    let cache_base = tempdir().unwrap();
1560    let target = tempdir().unwrap();
1561
1562    let client = ThunderstoreClient::builder()
1563      .package_index_url(format!("{}/pkg/", server.url()))
1564      .cache_dir(http_cache.path())
1565      .build()
1566      .unwrap();
1567
1568    let rt = tokio::runtime::Runtime::new().unwrap();
1569    let eco = Ecosystem::bundled();
1570    let index = rt.block_on(client.get_manifest()).unwrap();
1571
1572    let installed = rt
1573      .block_on(install_mod_in(
1574        target.path(),
1575        cache_base.path(),
1576        &eco,
1577        &index,
1578        &client,
1579        "valheim",
1580        "Author-CoolMod",
1581        123,
1582      ))
1583      .unwrap();
1584
1585    assert_eq!(installed, vec!["Author-CoolMod".to_string()]);
1586
1587    assert!(
1588      target
1589        .path()
1590        .join("BepInEx/plugins/Author-CoolMod/CoolMod.dll")
1591        .exists()
1592    );
1593
1594    let cached = cache::package_cache_dir(cache_base.path(), "valheim", "Author-CoolMod", "1.0.0");
1595
1596    assert!(cached.join("plugins/CoolMod.dll").exists());
1597    assert!(!target.path().join("cache").exists());
1598
1599    dl.assert();
1600    idx.assert();
1601  }
1602
1603  /// Builds a minimal [`ProfileMod`] for a hand-built `mods.yml` entry.
1604  /// `ProfileMod` does not derive `Default`, so every field is filled with a
1605  /// plausible value.
1606  fn profile_mod(name: &str) -> ProfileMod {
1607    ProfileMod {
1608      manifest_version: 1,
1609      name: name.to_string(),
1610      author_name: String::new(),
1611      website_url: String::new(),
1612      display_name: name.to_string(),
1613      description: String::new(),
1614      game_version: "0".to_string(),
1615      network_mode: "both".to_string(),
1616      package_type: "other".to_string(),
1617      install_mode: "managed".to_string(),
1618      installed_at_time: 1,
1619      loaders: Vec::new(),
1620      dependencies: Vec::new(),
1621      incompatibilities: Vec::new(),
1622      optional_dependencies: Vec::new(),
1623      version_number: SemVer::parse("1.0.0"),
1624      enabled: true,
1625      icon: String::new(),
1626      online_source: false,
1627      trusted_package: false,
1628    }
1629  }
1630
1631  #[test]
1632  fn uninstalling_a_loader_pack_without_a_state_file_is_refused() {
1633    let dir = tempdir().unwrap();
1634    let target_dir = dir.path();
1635    let eco = crate::ecosystem::Ecosystem::bundled();
1636
1637    // A `mods.yml` naming the loader, with no `_state` tracker beside it: the
1638    // shape `import_r2modman_dir` leaves behind, since r2modman never wrote
1639    // this engine's tracker.
1640    modlist::write(target_dir, &[profile_mod("denikson-BepInExPack_Valheim")]).unwrap();
1641
1642    let result = uninstall_mod_in(target_dir, &eco, "valheim", "denikson-BepInExPack_Valheim");
1643
1644    assert!(result.is_err());
1645
1646    let message = result.unwrap_err().to_string();
1647
1648    assert!(
1649      message.contains("denikson-BepInExPack_Valheim"),
1650      "the error must name the loader; got: {message}"
1651    );
1652    // Refusing must leave the record intact rather than dropping it, which is
1653    // the orphaning this guard exists to prevent.
1654    assert_eq!(modlist::read(target_dir).unwrap().len(), 1);
1655  }
1656
1657  #[test]
1658  fn uninstalling_a_regular_mod_without_a_state_file_still_works() {
1659    let dir = tempdir().unwrap();
1660    let target_dir = dir.path();
1661    let eco = crate::ecosystem::Ecosystem::bundled();
1662
1663    // Valheim's install rules are `subdir` and `none` only, so a regular mod
1664    // never has a `_state` file. That must stay a normal uninstall.
1665    modlist::write(target_dir, &[profile_mod("Owner-Regular")]).unwrap();
1666
1667    uninstall_mod_in(target_dir, &eco, "valheim", "Owner-Regular").unwrap();
1668
1669    assert!(modlist::read(target_dir).unwrap().is_empty());
1670  }
1671
1672  #[test]
1673  fn check_removable_batch_reports_every_unremovable_name_with_its_reason() {
1674    let dir = tempdir().unwrap();
1675    let target_dir = dir.path();
1676    let eco = crate::ecosystem::Ecosystem::bundled();
1677
1678    modlist::write(
1679      target_dir,
1680      &[
1681        profile_mod("denikson-BepInExPack_Valheim"),
1682        profile_mod("Owner-Regular"),
1683      ],
1684    )
1685    .unwrap();
1686
1687    // The loader has no `_state` tracker at all: adopted from disk rather than
1688    // installed, so which files it placed is unknown.
1689    //
1690    // The regular mod's `_state` file is present but unparseable: a stray
1691    // leftover or a hand edit, so its contents are unreadable rather than
1692    // absent. Different reason, same "cannot be removed exactly" outcome.
1693    let state_path = crate::install::state_file_path(target_dir, "Owner-Regular");
1694
1695    std::fs::create_dir_all(state_path.parent().unwrap()).unwrap();
1696    std::fs::write(&state_path, "not: [valid").unwrap();
1697
1698    let names = vec![
1699      "denikson-BepInExPack_Valheim".to_string(),
1700      "Owner-Regular".to_string(),
1701    ];
1702
1703    let blocked = check_removable_batch(target_dir, &eco, &names);
1704
1705    assert_eq!(
1706      blocked.len(),
1707      2,
1708      "both un-removable names must be reported, not just the first; got {blocked:?}"
1709    );
1710
1711    let loader_refusal = blocked
1712      .iter()
1713      .find(|(name, _)| name == "denikson-BepInExPack_Valheim")
1714      .map(|(_, refusal)| refusal)
1715      .unwrap();
1716    let loader_path = state_file_path(target_dir, "denikson-BepInExPack_Valheim");
1717
1718    // The reason is returned rather than flattened into a sentence, so a caller
1719    // decides what to advise from the variant instead of re-statting the record.
1720    assert!(
1721      matches!(loader_refusal, NotRemovable::RecordAbsent { path, .. } if path == &loader_path),
1722      "expected an absent record carrying its expected path; got: {loader_refusal:?}"
1723    );
1724
1725    let regular_refusal = blocked
1726      .iter()
1727      .find(|(name, _)| name == "Owner-Regular")
1728      .map(|(_, refusal)| refusal)
1729      .unwrap();
1730
1731    assert!(
1732      matches!(regular_refusal, NotRemovable::RecordUnreadable { path, .. } if path == &state_path),
1733      "expected an unreadable record carrying the path it could not read; got: {regular_refusal:?}"
1734    );
1735  }
1736
1737  #[test]
1738  fn a_removability_refusal_keeps_todays_wording_when_it_becomes_an_error() {
1739    let dir = tempdir().unwrap();
1740    let target_dir = dir.path();
1741    let eco = crate::ecosystem::Ecosystem::bundled();
1742
1743    modlist::write(target_dir, &[profile_mod("denikson-BepInExPack_Valheim")]).unwrap();
1744
1745    let refusal = ensure_removable(target_dir, &eco, "denikson-BepInExPack_Valheim").unwrap_err();
1746    let path = state_file_path(target_dir, "denikson-BepInExPack_Valheim");
1747
1748    // `uninstall_mod_in` propagates this refusal with `?`, so the sentence a
1749    // caller without its own wording sees has to survive the conversion intact.
1750    assert_eq!(
1751      Error::from(refusal).to_string(),
1752      format!(
1753        "Profile error: denikson-BepInExPack_Valheim is a mod loader with no \
1754         install record at {}, so its files cannot be removed exactly. Reinstall \
1755         it, then uninstall it again, or remove its files by hand.",
1756        path.display()
1757      )
1758    );
1759  }
1760
1761  #[test]
1762  fn a_reinstall_the_caller_did_not_name_keeps_its_disabled_state() {
1763    let eco = Ecosystem::bundled();
1764    let dir = tempdir().unwrap();
1765    let base = dir.path();
1766
1767    crate::profile::layout::create(base, "valheim", "Main").unwrap();
1768
1769    let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
1770    let extracted = tempdir().unwrap();
1771
1772    write_file(&extracted.path().join("plugins/Base.dll"), b"dll");
1773
1774    install_extracted(
1775      base,
1776      &eco,
1777      "valheim",
1778      "Main",
1779      "Author-Base",
1780      "Author",
1781      &version("Author-Base"),
1782      extracted.path(),
1783      None,
1784      1,
1785    )
1786    .unwrap();
1787
1788    disable_mod(base, &eco, "valheim", "Main", "Author-Base").unwrap();
1789
1790    assert!(!modlist::read(&profile_dir).unwrap()[0].enabled);
1791    assert!(
1792      profile_dir
1793        .join("BepInEx/plugins/Author-Base/Base.dll.old")
1794        .exists(),
1795      "the disable must have suffixed the file, or this test proves nothing"
1796    );
1797
1798    // Reinstalling Author-Base the way a dependency closure would: not named by
1799    // the user, so its disabled state must survive.
1800    let batch = plan_install_batch(&profile_dir, &["Author-Base".to_string()], &[]).unwrap();
1801
1802    assert_eq!(batch.protect_disabled, vec!["Author-Base".to_string()]);
1803
1804    install_extracted(
1805      base,
1806      &eco,
1807      "valheim",
1808      "Main",
1809      "Author-Base",
1810      "Author",
1811      &version("Author-Base"),
1812      extracted.path(),
1813      None,
1814      2,
1815    )
1816    .unwrap();
1817
1818    reapply_batch_disabled(&profile_dir, &eco, "valheim", &batch).unwrap();
1819
1820    let mods = modlist::read(&profile_dir).unwrap();
1821
1822    assert!(
1823      !mods[0].enabled,
1824      "a disabled dependency must not be re-enabled by an incidental reinstall"
1825    );
1826    assert!(
1827      profile_dir
1828        .join("BepInEx/plugins/Author-Base/Base.dll.old")
1829        .exists(),
1830      "the file must be left suffixed, not rewritten un-suffixed and orphaned"
1831    );
1832  }
1833
1834  #[test]
1835  fn planning_an_uninstall_batch_partitions_loaders_and_lists_untracked() {
1836    let eco = Ecosystem::bundled();
1837    let dir = tempdir().unwrap();
1838    let base = dir.path();
1839
1840    crate::profile::layout::create(base, "valheim", "Main").unwrap();
1841
1842    let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
1843    let extracted = tempdir().unwrap();
1844
1845    write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
1846    install_extracted(
1847      base,
1848      &eco,
1849      "valheim",
1850      "Main",
1851      "Author-Mod",
1852      "Author",
1853      &version("Author-Mod"),
1854      extracted.path(),
1855      None,
1856      1,
1857    )
1858    .unwrap();
1859
1860    // A folder mods.yml never recorded, which the sweep would take along.
1861    write_file(
1862      &profile_dir.join("BepInEx/plugins/Other-ByHand/x.dll"),
1863      b"dll",
1864    );
1865    // Not `<Owner-Name>`-shaped, so the sweep ignores it and the plan must too.
1866    write_file(&profile_dir.join("BepInEx/plugins/MMHOOK/h.dll"), b"dll");
1867
1868    let batch = plan_uninstall_batch(&profile_dir, &eco, "valheim", None).unwrap();
1869
1870    assert_eq!(batch.mods, vec!["Author-Mod".to_string()]);
1871    assert!(batch.loaders.is_empty());
1872    assert_eq!(
1873      batch.untracked,
1874      vec![std::path::PathBuf::from("BepInEx/plugins/Other-ByHand")]
1875    );
1876
1877    let outcome = uninstall_batch(&profile_dir, &eco, "valheim", &batch).unwrap();
1878
1879    assert!(outcome.is_ok(), "failed: {:?}", outcome.failed);
1880    assert_eq!(outcome.succeeded, vec!["Author-Mod".to_string()]);
1881    assert!(modlist::read(&profile_dir).unwrap().is_empty());
1882    assert!(profile_dir.join("BepInEx/plugins/MMHOOK/h.dll").exists());
1883  }
1884
1885  #[test]
1886  fn uninstalling_a_batch_removes_the_loader_after_ordinary_mods_and_its_own_files() {
1887    let eco = Ecosystem::bundled();
1888    let dir = tempdir().unwrap();
1889    let base = dir.path();
1890    let loader = "denikson-BepInExPack_Valheim";
1891
1892    crate::profile::layout::create(base, "valheim", "Main").unwrap();
1893
1894    let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
1895    let extracted = tempdir().unwrap();
1896
1897    write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
1898    install_extracted(
1899      base,
1900      &eco,
1901      "valheim",
1902      "Main",
1903      "Author-Mod",
1904      "Author",
1905      &version("Author-Mod"),
1906      extracted.path(),
1907      None,
1908      1,
1909    )
1910    .unwrap();
1911
1912    // A recorded loader, fabricated the same way `disable_mod_is_noop_for_modloader_pack`
1913    // does: its files land directly under the profile root, not through
1914    // `install_extracted`, since a real loader package's layout (a
1915    // `rootFolder` wrapper stripped by the download pipeline) is not what
1916    // that helper exercises.
1917    let winhttp = profile_dir.join("winhttp.dll");
1918    let core_dll = profile_dir.join("BepInEx/core/BepInEx.dll");
1919
1920    write_file(&winhttp, b"proxy");
1921    write_file(&core_dll, b"core");
1922
1923    let mut mods = modlist::read(&profile_dir).unwrap();
1924
1925    modlist::upsert(
1926      &mut mods,
1927      mod_entry_from_version(loader, "denikson", &version(loader), 1),
1928    );
1929    modlist::write(&profile_dir, &mods).unwrap();
1930
1931    let tracker = crate::install::ModFileTracker {
1932      mod_name: loader.to_string(),
1933      files: vec![
1934        (
1935          "unused-cache-source-1".to_string(),
1936          "winhttp.dll".to_string(),
1937        ),
1938        (
1939          "unused-cache-source-2".to_string(),
1940          "BepInEx/core/BepInEx.dll".to_string(),
1941        ),
1942      ],
1943    };
1944
1945    write_state_file(&profile_dir, &tracker).unwrap();
1946
1947    let batch = plan_uninstall_batch(&profile_dir, &eco, "valheim", None).unwrap();
1948
1949    assert_eq!(batch.mods, vec!["Author-Mod".to_string()]);
1950    assert_eq!(batch.loaders, vec![loader.to_string()]);
1951
1952    let outcome = uninstall_batch(&profile_dir, &eco, "valheim", &batch).unwrap();
1953
1954    assert!(outcome.is_ok(), "failed: {:?}", outcome.failed);
1955    // `uninstall_batch` chains `batch.mods` before `batch.loaders`, so the
1956    // order items were attempted in is what `succeeded` records them in. Two
1957    // items give no way to observe the timing of one removal relative to the
1958    // other beyond this: there is no shared file or intermediate read that
1959    // would tell mods-then-loader apart from an interleaved or reversed order
1960    // except the order they were pushed onto `succeeded` in, which is exactly
1961    // what this pins.
1962    assert_eq!(
1963      outcome.succeeded,
1964      vec!["Author-Mod".to_string(), loader.to_string()]
1965    );
1966    assert!(modlist::read(&profile_dir).unwrap().is_empty());
1967    // The loader's own `State`-tracked files are gone too, not just its
1968    // `mods.yml` entry.
1969    assert!(!winhttp.exists());
1970    assert!(!core_dll.exists());
1971  }
1972
1973  #[test]
1974  fn planning_an_uninstall_batch_sorts_and_removes_two_recorded_loaders() {
1975    let eco = Ecosystem::bundled();
1976    let dir = tempdir().unwrap();
1977    let base = dir.path();
1978    let first_loader = "denikson-BepInExPack_Valheim";
1979    // A second, unrelated loader pack recognised by the same registry:
1980    // `modloader_package` is a global lookup keyed on package identity, not
1981    // scoped to a game, so nothing about a Valheim profile rules out a second
1982    // recognised loader ending up in its `mods.yml` (a real entry in the
1983    // bundled registry).
1984    let second_loader = "1F31A-BepInEx_Valheim_Full";
1985
1986    crate::profile::layout::create(base, "valheim", "Main").unwrap();
1987
1988    let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
1989
1990    let mut mods = Vec::new();
1991
1992    for loader in [first_loader, second_loader] {
1993      modlist::upsert(
1994        &mut mods,
1995        mod_entry_from_version(loader, "owner", &version(loader), 1),
1996      );
1997
1998      // An empty tracker satisfies the install-record precondition without
1999      // claiming ownership of any real file, so removing both loaders cannot
2000      // conflict over shared files.
2001      write_state_file(
2002        &profile_dir,
2003        &crate::install::ModFileTracker {
2004          mod_name: loader.to_string(),
2005          files: Vec::new(),
2006        },
2007      )
2008      .unwrap();
2009    }
2010
2011    modlist::write(&profile_dir, &mods).unwrap();
2012
2013    let batch = plan_uninstall_batch(&profile_dir, &eco, "valheim", None).unwrap();
2014
2015    assert!(batch.mods.is_empty());
2016    // Sorted, so neither loader is silently dropped by the other overwriting it.
2017    assert_eq!(
2018      batch.loaders,
2019      vec![second_loader.to_string(), first_loader.to_string()]
2020    );
2021
2022    let outcome = uninstall_batch(&profile_dir, &eco, "valheim", &batch).unwrap();
2023
2024    assert!(outcome.is_ok(), "failed: {:?}", outcome.failed);
2025    assert!(modlist::read(&profile_dir).unwrap().is_empty());
2026  }
2027
2028  #[test]
2029  fn planning_a_toggle_batch_splits_changing_from_already_and_excludes_loaders() {
2030    let eco = Ecosystem::bundled();
2031    let dir = tempdir().unwrap();
2032    let base = dir.path();
2033    let loader = "denikson-BepInExPack_Valheim";
2034
2035    crate::profile::layout::create(base, "valheim", "Main").unwrap();
2036
2037    let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
2038    let extracted = tempdir().unwrap();
2039
2040    write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
2041
2042    for name in ["Author-One", "Author-Two"] {
2043      install_extracted(
2044        base,
2045        &eco,
2046        "valheim",
2047        "Main",
2048        name,
2049        "Author",
2050        &version(name),
2051        extracted.path(),
2052        None,
2053        1,
2054      )
2055      .unwrap();
2056    }
2057
2058    // A recorded loader, which has no disabled state and must be excluded.
2059    let mut mods = modlist::read(&profile_dir).unwrap();
2060
2061    mods.push(mod_entry_from_version(
2062      loader,
2063      "denikson",
2064      &version(loader),
2065      1,
2066    ));
2067    modlist::write(&profile_dir, &mods).unwrap();
2068
2069    disable_mod(base, &eco, "valheim", "Main", "Author-One").unwrap();
2070
2071    let batch = plan_set_enabled_batch(&profile_dir, &eco, None, false).unwrap();
2072
2073    assert_eq!(batch.changing, vec!["Author-Two".to_string()]);
2074    assert_eq!(batch.already, vec!["Author-One".to_string()]);
2075    assert_eq!(batch.loaders, vec![loader.to_string()]);
2076
2077    let outcome = set_enabled_batch(&profile_dir, &eco, "valheim", &batch).unwrap();
2078
2079    assert!(outcome.is_ok(), "failed: {:?}", outcome.failed);
2080    assert_eq!(outcome.succeeded, vec!["Author-Two".to_string()]);
2081
2082    let after = modlist::read(&profile_dir).unwrap();
2083
2084    assert!(
2085      !after
2086        .iter()
2087        .find(|m| m.name == "Author-Two")
2088        .unwrap()
2089        .enabled
2090    );
2091    // The loader was never touched, so its record still reads enabled.
2092    assert!(after.iter().find(|m| m.name == loader).unwrap().enabled);
2093  }
2094
2095  #[test]
2096  fn plan_set_enabled_batch_refuses_an_unrecorded_name() {
2097    let eco = Ecosystem::bundled();
2098    let dir = tempdir().unwrap();
2099    let base = dir.path();
2100
2101    crate::profile::layout::create(base, "valheim", "Main").unwrap();
2102
2103    let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
2104    let extracted = tempdir().unwrap();
2105
2106    write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
2107
2108    install_extracted(
2109      base,
2110      &eco,
2111      "valheim",
2112      "Main",
2113      "Author-One",
2114      "Author",
2115      &version("Author-One"),
2116      extracted.path(),
2117      None,
2118      1,
2119    )
2120    .unwrap();
2121
2122    let names = vec!["Author-Gone".to_string()];
2123    let error = plan_set_enabled_batch(&profile_dir, &eco, Some(&names), false).unwrap_err();
2124
2125    assert!(error.to_string().contains("Author-Gone"));
2126    assert!(error.to_string().contains("is not installed"));
2127  }
2128}