Skip to main content

thunderstore_engine/
install.rs

1//! Schema-driven, root-relative mod install routing.
2//!
3//! Given a package's extracted contents and a game's
4//! [`GameProfile`](crate::ecosystem::GameProfile) from the Thunderstore ecosystem
5//! schema, this module computes an [`InstallPlan`](crate::install::InstallPlan): a
6//! list of source files mapped to destination paths *relative to a profile/loader
7//! root*. The caller supplies the absolute root and applies the plan with
8//! [`apply_install`](crate::install::apply_install), which records a
9//! [`FileManifest`](crate::install::FileManifest) of every written path so
10//! uninstall is exact and reversible.
11//!
12//! Routing is driven entirely by the schema's install rules (no per-game code):
13//! a package's override folders route by matching a rule's route leaf, loose files
14//! route by longest-matching extension, and anything else falls to the rule flagged
15//! as the default location. A package that appears in the ecosystem's mod-loader
16//! registry ([`Ecosystem::modloader_package`](crate::ecosystem::Ecosystem::modloader_package))
17//! is instead unpacked to the root.
18//!
19//! Deliberately, this module does NOT resolve the absolute game directory, do
20//! Steam/Epic discovery, or place the loader proxy next to the executable — those
21//! are game-specific concerns left to the caller.
22
23#![deny(missing_docs)]
24
25use crate::ecosystem::{Ecosystem, GameProfile, InstallRule, ModloaderPackage, TrackingMethod};
26use crate::error::{Error, Result};
27use crate::util::io_context;
28use serde::{Deserialize, Serialize};
29use std::collections::{BTreeMap, BTreeSet};
30use std::fs;
31use std::path::{Component, Path, PathBuf};
32
33/// Records the files a `State`-tracked mod installed, so enable/disable can undo
34/// and redo them. Mirrors r2modman's `_state/<mod>-state.yml` `ModFileTracker`:
35/// each pair is `(cache source path, profile-relative install path)`.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37#[serde(rename_all = "camelCase")]
38#[non_exhaustive]
39pub struct ModFileTracker {
40  /// The mod's `Owner-Name` identifier.
41  pub mod_name: String,
42  /// `(source, install-relative)` pairs, in plan order.
43  pub files: Vec<(String, String)>,
44}
45
46/// Collects the `State`-tracked entries of `plan` into a [`ModFileTracker`], or
47/// `None` when the plan installs no `State` files. Pure — no I/O.
48pub fn state_tracker_from_plan(plan: &InstallPlan, mod_name: &str) -> Option<ModFileTracker> {
49  let files: Vec<(String, String)> = plan
50    .entries
51    .iter()
52    .filter(|e| e.tracking == TrackingMethod::State)
53    .map(|e| {
54      (
55        e.source.to_string_lossy().to_string(),
56        e.dest_relative.to_string_lossy().replace('\\', "/"),
57      )
58    })
59    .collect();
60
61  if files.is_empty() {
62    return None;
63  }
64
65  Some(ModFileTracker {
66    mod_name: mod_name.to_string(),
67    files,
68  })
69}
70
71/// Whether every file `plan` tracks is still on disk under `root`, so a mod
72/// recorded at the planned version can be skipped instead of reinstalled.
73///
74/// A namespaced file must be at `name` while the mod is `enabled` and at
75/// `name.old` while it is not, since disabling renames it. A name that already
76/// ends in `.old` is never renamed, so it is checked as is. A mix of the two (a
77/// toggle that failed partway) is not on disk, so the reinstall repairs it. A
78/// `State` file needs the mod's `_state` tracker, and is only required on
79/// disk while the mod is `enabled`, since disabling removes it. Shared `none`
80/// files are not checked: a user may delete a config on purpose. A plan that
81/// tracks nothing (a modpack of only metadata) is trivially on disk.
82pub fn plan_is_on_disk(plan: &InstallPlan, root: &Path, mod_name: &str, enabled: bool) -> bool {
83  let has_tracker = state_file_path(root, mod_name).exists();
84
85  plan.entries.iter().all(|entry| {
86    let dest = root.join(&entry.dest_relative);
87
88    match entry.tracking {
89      TrackingMethod::None => true,
90      TrackingMethod::State => has_tracker && (!enabled || dest.exists()),
91      tracking if tracking.is_namespaced() => {
92        let already_old = dest.to_string_lossy().to_lowercase().ends_with(".old");
93
94        if enabled || already_old {
95          return dest.exists();
96        }
97
98        let mut disabled = dest.clone().into_os_string();
99
100        disabled.push(".old");
101
102        Path::new(&disabled).exists()
103      }
104      _ => dest.exists(),
105    }
106  })
107}
108
109/// The `_state/<mod_name>-state.yml` path within a profile directory.
110pub fn state_file_path(profile_dir: &Path, mod_name: &str) -> PathBuf {
111  profile_dir
112    .join("_state")
113    .join(format!("{mod_name}-state.yml"))
114}
115
116/// Writes a mod's state tracker to `<profile_dir>/_state/<mod>-state.yml`.
117pub fn write_state_file(profile_dir: &Path, tracker: &ModFileTracker) -> Result<()> {
118  let path = state_file_path(profile_dir, &tracker.mod_name);
119
120  if let Some(parent) = path.parent() {
121    fs::create_dir_all(parent)
122      .map_err(|e| io_context(format!("creating {}", parent.display()), e))?;
123  }
124
125  let text = serde_yaml_ng::to_string(tracker)
126    .map_err(|e| Error::Install(format!("serializing state file: {e}")))?;
127
128  fs::write(&path, text).map_err(|e| io_context(format!("writing {}", path.display()), e))
129}
130
131/// Reads a mod's state tracker, or `None` if the file does not exist.
132pub fn read_state_file(profile_dir: &Path, mod_name: &str) -> Result<Option<ModFileTracker>> {
133  let path = state_file_path(profile_dir, mod_name);
134
135  if !path.exists() {
136    return Ok(None);
137  }
138
139  let text =
140    fs::read_to_string(&path).map_err(|e| io_context(format!("reading {}", path.display()), e))?;
141  let tracker = serde_yaml_ng::from_str(&text)
142    .map_err(|e| Error::Install(format!("parsing {}: {e}", path.display())))?;
143
144  Ok(Some(tracker))
145}
146
147/// Removes a mod's `State`-tracked payload files and then its `_state` file, so
148/// an uninstall leaves nothing behind for a mod whose files live under
149/// non-namespaced routes (which [`remove_delisted`] does not reconcile).
150///
151/// A missing `_state` file is a no-op — the mod tracked no `State` files. A
152/// payload file that is already gone (e.g. the mod was disabled first) is
153/// skipped. Unlike [`disable_mod`], this also deletes the tracker itself, since
154/// after an uninstall there is nothing left to restore.
155pub fn remove_state_payload(profile_dir: &Path, mod_name: &str) -> Result<()> {
156  clear_state_files(profile_dir, mod_name)?;
157
158  remove_state_file(profile_dir, mod_name)
159}
160
161/// Removes a mod's `State`-tracked payload files but keeps its `_state` file, so
162/// a reinstall that fails before writing a new one still leaves the record that
163/// [`remove_state_payload`] needs to uninstall the mod. A missing `_state` file
164/// is a no-op.
165pub fn clear_state_files(profile_dir: &Path, mod_name: &str) -> Result<()> {
166  match read_state_file(profile_dir, mod_name)? {
167    Some(tracker) => remove_state_files(profile_dir, &tracker),
168    None => Ok(()),
169  }
170}
171
172/// Removes a mod's `_state` file, if it has one, leaving any payload files it
173/// names in place.
174pub fn remove_state_file(profile_dir: &Path, mod_name: &str) -> Result<()> {
175  let path = state_file_path(profile_dir, mod_name);
176
177  if !path.exists() {
178    return Ok(());
179  }
180
181  fs::remove_file(&path).map_err(|e| io_context(format!("removing {}", path.display()), e))
182}
183
184/// Validates that `dest` is a safe root-relative path: it must not be absolute,
185/// carry a path prefix/root, or use `..` to climb out of the install root.
186///
187/// This guards every destination computed from untrusted inputs — a package's
188/// `Owner-Name` identifier and the ecosystem schema's route `target` — so a
189/// malicious or malformed package cannot read or write outside the caller's root.
190fn ensure_safe_relative(dest: &Path) -> Result<()> {
191  for component in dest.components() {
192    match component {
193      Component::Normal(_) | Component::CurDir => {}
194      Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
195        return Err(Error::Install(format!(
196          "unsafe install path escapes the install root: {}",
197          dest.display()
198        )));
199      }
200    }
201  }
202
203  Ok(())
204}
205
206/// A single source -> destination mapping within an install plan.
207#[derive(Debug, Clone, PartialEq, Eq)]
208#[non_exhaustive]
209pub struct InstallEntry {
210  /// Absolute path to the source file within the extracted package (or, for a
211  /// `package-zip` install, the package archive itself).
212  pub source: PathBuf,
213  /// Destination path relative to the profile/loader root.
214  pub dest_relative: PathBuf,
215  /// How this file is tracked, which governs whether it is namespaced, recorded
216  /// for uninstall, and whether an existing file is preserved.
217  pub tracking: TrackingMethod,
218}
219
220/// A computed, root-relative plan for installing one package.
221#[derive(Debug, Clone, Default)]
222#[non_exhaustive]
223pub struct InstallPlan {
224  /// The ordered source -> destination mappings for this package's files.
225  pub entries: Vec<InstallEntry>,
226}
227
228/// Records every path written by [`apply_install`], for exact uninstall.
229#[derive(Debug, Clone, Default)]
230#[non_exhaustive]
231pub struct FileManifest {
232  /// Paths written, relative to the root passed to [`apply_install`].
233  pub written: Vec<PathBuf>,
234}
235
236/// Builds an [`InstallPlan`] for a package.
237///
238/// If `package_full_name` is in the ecosystem's mod-loader registry, the package
239/// is a loader pack and unpacks to the root; otherwise its files are routed by the
240/// profile's [`InstallRule`]s. `archive_path` is the package's original zip, needed
241/// only for `package-zip` loaders (e.g. GodotML); pass `None` when unavailable.
242pub fn plan_install(
243  ecosystem: &Ecosystem,
244  profile: &GameProfile,
245  package_full_name: &str,
246  extracted_root: &Path,
247  archive_path: Option<&Path>,
248) -> Result<InstallPlan> {
249  if let Some(pack) = ecosystem.modloader_package(package_full_name) {
250    return plan_loader_pack(pack, profile, extracted_root);
251  }
252
253  plan_regular_mod(profile, package_full_name, extracted_root, archive_path)
254}
255
256/// Plans a loader pack: unpack its payload to the profile root.
257///
258/// Many packs nest their payload under a single subfolder;
259/// [`ModloaderPackage::root_folder`](crate::ecosystem::ModloaderPackage::root_folder)
260/// names it so the folder's *contents* land at the root. Package-root metadata
261/// (`manifest.json`, `icon.png`, `README.md`) is dropped. Files landing under an
262/// untracked route (e.g. the pack's own `BepInEx/config/`) are preserved across
263/// reinstalls; the rest are recorded so the loader can be uninstalled exactly.
264fn plan_loader_pack(
265  pack: &ModloaderPackage,
266  profile: &GameProfile,
267  extracted_root: &Path,
268) -> Result<InstallPlan> {
269  let base = match extracted_root.join(&pack.root_folder) {
270    nested if !pack.root_folder.is_empty() && nested.is_dir() => nested,
271    _ => extracted_root.to_path_buf(),
272  };
273
274  let mut untracked = Vec::new();
275
276  collect_routes(
277    &profile.install_rules,
278    Path::new(""),
279    &|rule| rule.tracking_method == TrackingMethod::None,
280    &mut untracked,
281  );
282
283  let mut entries = Vec::new();
284
285  for file in walk_files(&base)? {
286    let rel = file.strip_prefix(&base).unwrap_or(&file).to_path_buf();
287
288    if let Some(tracking) =
289      classify_loader_file(&rel, &untracked, &profile.relative_file_exclusions)?
290    {
291      entries.push(InstallEntry {
292        source: file,
293        dest_relative: rel,
294        tracking,
295      });
296    }
297  }
298
299  Ok(InstallPlan { entries })
300}
301
302/// Decides whether and how a loader-pack file (path `rel`, relative to the pack
303/// payload root) is installed.
304///
305/// Returns `None` for package-root metadata (`manifest.json`, `icon.png`,
306/// `README.md`) and for a file whose name is in `exclusions` (e.g. `LICENCE`).
307/// Otherwise it returns the tracking method: `none` (shared config, preserved
308/// across reinstalls) when the file lands under one of the pack's own `untracked`
309/// routes, else `state` (recorded for exact uninstall). The safety check on `rel`
310/// is surfaced as an `Err`. Pure — no filesystem access.
311fn classify_loader_file(
312  rel: &Path,
313  untracked: &[PathBuf],
314  exclusions: &[String],
315) -> Result<Option<TrackingMethod>> {
316  if is_package_metadata(rel) {
317    return Ok(None);
318  }
319
320  if let Some(name) = rel.file_name().and_then(|n| n.to_str())
321    && exclusions.iter().any(|e| e == name)
322  {
323    return Ok(None);
324  }
325
326  ensure_safe_relative(rel)?;
327
328  let tracking = if untracked.iter().any(|route| rel.starts_with(route)) {
329    TrackingMethod::None
330  } else {
331    TrackingMethod::State
332  };
333
334  Ok(Some(tracking))
335}
336
337/// Plans a regular mod install by routing each file through the profile's rules.
338fn plan_regular_mod(
339  profile: &GameProfile,
340  ident: &str,
341  extracted_root: &Path,
342  archive_path: Option<&Path>,
343) -> Result<InstallPlan> {
344  let mut flat = Vec::new();
345
346  flatten_rules(&profile.install_rules, Path::new(""), &mut flat);
347
348  // Whole-package zip loaders (e.g. GodotML): the catch-all rule is `package-zip`,
349  // so the entire archive is installed as a single file rather than per-file.
350  if let Some(rule) = flat
351    .iter()
352    .find(|r| r.rule.is_default_location && r.rule.tracking_method == TrackingMethod::PackageZip)
353  {
354    return plan_package_zip(&rule.full_route, ident, archive_path);
355  }
356
357  let mut entries = Vec::new();
358  let mut claimed: BTreeMap<PathBuf, PathBuf> = BTreeMap::new();
359
360  for file in walk_files(extracted_root)? {
361    let rel = file
362      .strip_prefix(extracted_root)
363      .unwrap_or(&file)
364      .to_path_buf();
365
366    match route_file(profile, &flat, ident, &rel)? {
367      RouteOutcome::Route {
368        dest_relative,
369        tracking,
370      } => {
371        if let Some(earlier) = claimed.insert(dest_relative.clone(), rel.clone()) {
372          return Err(colliding_files(&earlier, &rel, &dest_relative));
373        }
374
375        entries.push(InstallEntry {
376          source: file,
377          dest_relative,
378          tracking,
379        });
380      }
381      RouteOutcome::Skip(reason) => log_route_skip(&rel, reason),
382    }
383  }
384
385  Ok(InstallPlan { entries })
386}
387
388/// The error for two package files routed onto one destination, which would
389/// otherwise let the later copy silently overwrite the earlier one.
390///
391/// This happens when files sit in ordinary folders: like r2modman, those files
392/// are flattened to their name under the mod's folder, so only an override
393/// folder such as `plugins/` keeps them apart. Refusing the package names the
394/// conflict instead of installing a mod that is missing a file.
395fn colliding_files(first: &Path, second: &Path, dest: &Path) -> Error {
396  let show = |path: &Path| path.to_string_lossy().replace('\\', "/");
397
398  Error::Install(format!(
399    "package files {} and {} would both install to {}; files outside an override \
400     folder such as plugins/ are flattened to their name, so the package must ship \
401     them under one to keep them apart",
402    show(first),
403    show(second),
404    show(dest)
405  ))
406}
407
408/// The outcome of routing a single extracted file through a profile's rules.
409#[derive(Debug, Clone, PartialEq, Eq)]
410enum RouteOutcome {
411  /// Route the file to `dest_relative` (relative to the profile root) tracked
412  /// as `tracking`.
413  Route {
414    dest_relative: PathBuf,
415    tracking: TrackingMethod,
416  },
417  /// The file is not installed, for the given reason.
418  Skip(SkipReason),
419}
420
421/// Why [`route_file`] declined to install a file.
422#[derive(Debug, Clone, PartialEq, Eq)]
423enum SkipReason {
424  /// The path has no usable file name.
425  NoName,
426  /// The file matched the profile's file exclusions (e.g. `LICENCE`).
427  Excluded,
428  /// No install rule claimed the file.
429  NoRoute,
430  /// The file routed to a rule whose tracking method has no per-file placement
431  /// (e.g. `package-zip`).
432  UnsupportedTracking(TrackingMethod),
433}
434
435/// Routes one extracted file (path `rel`, relative to the package root) through
436/// the profile's rules, returning where it should land or why it is skipped.
437///
438/// This is the whole per-file routing decision, lifted out of the filesystem
439/// walk in [`plan_regular_mod`]: it touches no disk, so the entire routing table
440/// — override-folder matching, longest-extension routing, the default location,
441/// exclusions, and `<ident>` namespacing — is unit-testable against string paths.
442/// The safety check on the computed destination is still enforced, surfaced as an
443/// `Err`.
444fn route_file(
445  profile: &GameProfile,
446  flat: &[FlatRule<'_>],
447  ident: &str,
448  rel: &Path,
449) -> Result<RouteOutcome> {
450  let name = match rel.file_name().and_then(|n| n.to_str()) {
451    Some(n) => n,
452    None => return Ok(RouteOutcome::Skip(SkipReason::NoName)),
453  };
454
455  if profile.relative_file_exclusions.iter().any(|e| e == name) {
456    return Ok(RouteOutcome::Skip(SkipReason::Excluded));
457  }
458
459  let resolution = resolve_override(&profile.install_rules, rel)
460    .or_else(|| loose_file_route(flat, name))
461    .or_else(|| default_route(flat, rel, name));
462
463  let RouteResolution {
464    route,
465    tracking,
466    remainder,
467  } = match resolution {
468    Some(resolved) => resolved,
469    None => return Ok(RouteOutcome::Skip(SkipReason::NoRoute)),
470  };
471
472  let dest = match build_dest(&route, tracking, ident, &remainder) {
473    Some(dest) => dest,
474    None => {
475      return Ok(RouteOutcome::Skip(SkipReason::UnsupportedTracking(
476        tracking,
477      )));
478    }
479  };
480
481  ensure_safe_relative(&dest)?;
482
483  Ok(RouteOutcome::Route {
484    dest_relative: dest,
485    tracking,
486  })
487}
488
489/// Emits the same skip warnings the inline routing loop used to, for the reasons
490/// worth surfacing (a file with no route, or an unsupported tracking method).
491fn log_route_skip(rel: &Path, reason: SkipReason) {
492  match reason {
493    SkipReason::NoRoute => tracing::warn!("no install route for {}; skipping", rel.display()),
494    SkipReason::UnsupportedTracking(tracking) => tracing::warn!(
495      "unsupported tracking {:?} for {}; skipping",
496      tracking,
497      rel.display()
498    ),
499    SkipReason::Excluded | SkipReason::NoName => {}
500  }
501}
502
503/// Plans a `package-zip` install: copy the original archive into the route.
504fn plan_package_zip(route: &Path, ident: &str, archive_path: Option<&Path>) -> Result<InstallPlan> {
505  let archive = match archive_path {
506    Some(archive) => archive,
507    None => {
508      tracing::warn!("package-zip loader requires the original archive; skipping install");
509
510      return Ok(InstallPlan::default());
511    }
512  };
513
514  let dest = route.join(format!("{ident}.zip"));
515
516  ensure_safe_relative(&dest)?;
517
518  Ok(InstallPlan {
519    entries: vec![InstallEntry {
520      source: archive.to_path_buf(),
521      dest_relative: dest,
522      tracking: TrackingMethod::PackageZip,
523    }],
524  })
525}
526
527/// A single rule with its route resolved to a full profile-root-relative path.
528struct FlatRule<'a> {
529  full_route: PathBuf,
530  rule: &'a InstallRule,
531}
532
533/// A resolved route for one file: the destination route, how it is tracked, and
534/// the remaining sub-path to place under the route.
535#[derive(Debug, Clone, PartialEq, Eq)]
536struct RouteResolution {
537  route: PathBuf,
538  tracking: TrackingMethod,
539  remainder: PathBuf,
540}
541
542/// Flattens the rule forest into `(full_route, rule)` pairs, resolving each nested
543/// rule's route against its parent so subroute routes become absolute.
544fn flatten_rules<'a>(rules: &'a [InstallRule], parent: &Path, out: &mut Vec<FlatRule<'a>>) {
545  for rule in rules {
546    let full = join_route(parent, &rule.route);
547
548    out.push(FlatRule {
549      full_route: full.clone(),
550      rule,
551    });
552
553    if !rule.sub_routes.is_empty() {
554      flatten_rules(&rule.sub_routes, &full, out);
555    }
556  }
557}
558
559/// Resolves a file through an override folder at any depth. When the top folder
560/// is not one (e.g. a wrapper such as `BepInEx/` or `ZenUI/`), the walk steps
561/// inside it and tries again, dropping the wrapper, as r2modman's
562/// buildInstallForRuleSubtype recurses into ordinary folders. Once a folder
563/// matches, everything beneath it is placed by [`resolve_dir`].
564fn resolve_override(rules: &[InstallRule], rel: &Path) -> Option<RouteResolution> {
565  let mut current = rel;
566
567  loop {
568    if let Some(found) = resolve_dir(rules, Path::new(""), current) {
569      return Some(found);
570    }
571
572    let mut comps = current.components();
573
574    comps.next()?;
575    current = comps.as_path();
576  }
577}
578
579/// Resolves a file whose top component names an override folder (recursively into
580/// subroutes), returning the destination route, its tracking, and the remaining
581/// path to place under it.
582///
583/// Only a directory matches an override folder: a lone file named like one (e.g.
584/// a root file called `plugins`) has nothing beneath it to place, so it is left
585/// to extension or default routing.
586fn resolve_dir(rules: &[InstallRule], parent_full: &Path, rel: &Path) -> Option<RouteResolution> {
587  let mut comps = rel.components();
588  let first = comps.next()?.as_os_str().to_str()?;
589  let rest = comps.as_path();
590
591  // A lone file (nothing beneath `first`) is not a folder match.
592  rest.components().next()?;
593
594  for rule in rules {
595    if !route_leaf(&rule.route).eq_ignore_ascii_case(first) {
596      continue;
597    }
598
599    let full = join_route(parent_full, &rule.route);
600
601    if !rule.sub_routes.is_empty() {
602      if let Some(found) = resolve_dir(&rule.sub_routes, &full, rest) {
603        return Some(found);
604      }
605
606      // A loose file directly inside this folder routes by subroute extension.
607      if rest.components().count() == 1
608        && let Some(name) = rest.file_name().and_then(|n| n.to_str())
609      {
610        let mut sub_flat = Vec::new();
611
612        flatten_rules(&rule.sub_routes, &full, &mut sub_flat);
613
614        if let Some(matched) = best_extension(&sub_flat, name) {
615          return Some(RouteResolution {
616            route: matched.full_route.clone(),
617            tracking: matched.rule.tracking_method,
618            remainder: PathBuf::from(name),
619          });
620        }
621      }
622    }
623
624    return Some(RouteResolution {
625      route: full,
626      tracking: rule.tracking_method,
627      remainder: rest.to_path_buf(),
628    });
629  }
630
631  None
632}
633
634/// Routes a file not claimed by an override folder by longest-matching extension.
635///
636/// This applies to any file — a root file or one inside an ordinary subfolder
637/// (e.g. `Wrapper/Hook.mm.dll` still routes to the `.mm.dll` rule) — so a matched
638/// file lands in its extension's route by base name rather than falling through to
639/// the default location.
640fn loose_file_route(flat: &[FlatRule<'_>], name: &str) -> Option<RouteResolution> {
641  let matched = best_extension(flat, name)?;
642
643  Some(RouteResolution {
644    route: matched.full_route.clone(),
645    tracking: matched.rule.tracking_method,
646    remainder: PathBuf::from(name),
647  })
648}
649
650/// Routes a file that matched nothing to the profile's default-location rule.
651///
652/// A `subdir` default flattens the file to its base name (`name`), as r2modman's
653/// installSubDir does for a file it reached by walking into ordinary folders;
654/// every other method preserves the full relative path under the route.
655fn default_route(flat: &[FlatRule<'_>], rel: &Path, name: &str) -> Option<RouteResolution> {
656  let matched = flat.iter().find(|r| r.rule.is_default_location)?;
657  let tracking = matched.rule.tracking_method;
658
659  let remainder = match tracking {
660    TrackingMethod::Subdir => PathBuf::from(name),
661    _ => rel.to_path_buf(),
662  };
663
664  Some(RouteResolution {
665    route: matched.full_route.clone(),
666    tracking,
667    remainder,
668  })
669}
670
671/// Finds the flattened rule whose longest `default_file_extensions` suffix matches
672/// `name`, so a more specific extension (e.g. `.lib.dll`) wins over `.dll`.
673fn best_extension<'a>(flat: &'a [FlatRule<'a>], name: &str) -> Option<&'a FlatRule<'a>> {
674  flat
675    .iter()
676    .filter_map(|fr| {
677      fr.rule
678        .default_file_extensions
679        .iter()
680        .filter(|ext| name.ends_with(ext.as_str()))
681        .map(|ext| ext.len())
682        .max()
683        .map(|len| (len, fr))
684    })
685    .max_by_key(|(len, _)| *len)
686    .map(|(_, fr)| fr)
687}
688
689/// Computes the destination for a routed file, applying `<ident>` namespacing for
690/// per-mod tracking methods. Returns `None` for methods with no per-file placement
691/// (`package-zip`, unknown), which the caller skips.
692fn build_dest(
693  route: &Path,
694  tracking: TrackingMethod,
695  ident: &str,
696  remainder: &Path,
697) -> Option<PathBuf> {
698  match tracking {
699    // Both namespace the file under `route/<ident>/` and keep the remainder as
700    // resolved. Where `subdir` flattens (a file outside an override folder),
701    // the resolver has already reduced the remainder to the base name.
702    TrackingMethod::Subdir | TrackingMethod::SubdirNoFlatten => {
703      Some(route.join(ident).join(remainder))
704    }
705    TrackingMethod::None | TrackingMethod::State => Some(route.join(remainder)),
706    TrackingMethod::PackageZip | TrackingMethod::Other => None,
707  }
708}
709
710/// The final path segment of a route, i.e. the override-folder name matched at a
711/// package's root (`"BepInEx/plugins"` -> `"plugins"`).
712fn route_leaf(route: &str) -> &str {
713  route.rsplit(['/', '\\']).next().unwrap_or(route)
714}
715
716/// Joins a parent full route with a (possibly slash-separated) child route.
717fn join_route(parent_full: &Path, child_route: &str) -> PathBuf {
718  let child = PathBuf::from(child_route);
719
720  if parent_full.as_os_str().is_empty() {
721    child
722  } else {
723    parent_full.join(child)
724  }
725}
726
727/// Whether `rel` is package-root metadata that a loader pack should not install.
728fn is_package_metadata(rel: &Path) -> bool {
729  if rel.components().count() != 1 {
730    return false;
731  }
732
733  match rel.file_name().and_then(|n| n.to_str()) {
734    Some(name) => {
735      let lower = name.to_ascii_lowercase();
736
737      lower == "manifest.json" || lower == "icon.png" || lower == "readme.md"
738    }
739    None => false,
740  }
741}
742
743/// Collects the full routes of every rule (recursively) matching `pred`.
744///
745/// Used with a `none`-tracking predicate to find a loader pack's shared/untracked
746/// routes (preserved on reinstall), and with a namespaced predicate to find the
747/// per-mod `<Owner-Name>/` routes reconciled by [`remove_delisted`].
748fn collect_routes<F: Fn(&InstallRule) -> bool>(
749  rules: &[InstallRule],
750  parent: &Path,
751  pred: &F,
752  out: &mut Vec<PathBuf>,
753) {
754  for rule in rules {
755    let full = join_route(parent, &rule.route);
756
757    if pred(rule) {
758      out.push(full.clone());
759    }
760
761    if !rule.sub_routes.is_empty() {
762      collect_routes(&rule.sub_routes, &full, pred, out);
763    }
764  }
765}
766
767/// Applies an install plan into `root`, copying files and recording what was
768/// written.
769///
770/// `root` is the caller-supplied profile/loader root. Parent directories are
771/// created as needed. Files tracked as `none` (shared config) are written only if
772/// absent and are never recorded, so a user's edits survive reinstalls and they are
773/// never auto-removed; all other files are (over)written and recorded in the
774/// returned [`FileManifest`] (paths relative to `root`).
775///
776/// The apply is all-or-nothing: if any entry fails, every file this call created
777/// is removed before the error is returned — along with any directory tree this
778/// call newly created (now left empty) — so a partial install leaves `root` as it
779/// found it, with no untracked files or empty directories behind.
780///
781/// For a `none`-tracked (shared config) destination that already exists — a
782/// regular file or a symlink the user planted — the entry is skipped and left
783/// untouched, so a user's edits (or their symlinked config) survive. For a
784/// tracked destination, a symlink already sitting there is refused rather than
785/// followed, so a pre-planted leaf link cannot redirect the write. This is
786/// best-effort: it inspects the final path component only (not symlinked parent
787/// directories) and is not race-free against a concurrent attacker mutating
788/// `root` mid-install.
789pub fn apply_install(plan: &InstallPlan, root: &Path) -> Result<FileManifest> {
790  let mut written = Vec::with_capacity(plan.entries.len());
791  let mut created: Vec<PathBuf> = Vec::new();
792  let mut created_dirs: Vec<PathBuf> = Vec::new();
793
794  for entry in &plan.entries {
795    if let Err(err) = ensure_safe_relative(&entry.dest_relative) {
796      rollback_partial_install(&created, &created_dirs);
797
798      return Err(err);
799    }
800
801    let dest = root.join(&entry.dest_relative);
802
803    let dest_is_symlink = fs::symlink_metadata(&dest)
804      .map(|metadata| metadata.file_type().is_symlink())
805      .unwrap_or(false);
806
807    match classify_entry(entry.tracking, dest.exists(), dest_is_symlink) {
808      // Shared config already present (a file or a symlink the user planted) is
809      // left untouched so a user's edits survive.
810      ApplyAction::SkipExistingConfig => continue,
811      // A pre-planted symlink at a tracked destination is refused rather than
812      // followed, so it cannot redirect the write elsewhere.
813      ApplyAction::RefuseSymlink => {
814        rollback_partial_install(&created, &created_dirs);
815
816        return Err(Error::Install(format!(
817          "refusing to install through a symlink at {}",
818          dest.display()
819        )));
820      }
821      ApplyAction::Write { record } => {
822        if let Err(err) = copy_entry(entry, &dest, root, &mut created_dirs) {
823          rollback_partial_install(&created, &created_dirs);
824
825          return Err(err);
826        }
827
828        created.push(dest);
829
830        if record {
831          written.push(entry.dest_relative.clone());
832        }
833      }
834    }
835  }
836
837  Ok(FileManifest { written })
838}
839
840/// What [`apply_install`] should do with one entry, given the state at its
841/// destination.
842#[derive(Debug, Clone, Copy, PartialEq, Eq)]
843enum ApplyAction {
844  /// Copy the source into place; `record` says whether to add it to the
845  /// [`FileManifest`] (tracked files are recorded, shared config is not).
846  Write { record: bool },
847  /// Leave an existing shared-config (`none`-tracked) file/symlink untouched.
848  SkipExistingConfig,
849  /// Refuse to write through a pre-existing symlink at a tracked destination.
850  RefuseSymlink,
851}
852
853/// Decides what to do with an entry from its tracking method and whether the
854/// destination already exists / is a symlink. Pure — no filesystem access — so
855/// the write/skip/refuse policy is exhaustively table-testable.
856fn classify_entry(tracking: TrackingMethod, exists: bool, is_symlink: bool) -> ApplyAction {
857  if tracking == TrackingMethod::None && (exists || is_symlink) {
858    return ApplyAction::SkipExistingConfig;
859  }
860
861  if is_symlink {
862    return ApplyAction::RefuseSymlink;
863  }
864
865  ApplyAction::Write {
866    record: tracking != TrackingMethod::None,
867  }
868}
869
870/// Copies one entry's source to `dest`, creating parent directories as needed and
871/// recording any it newly created in `created_dirs` so a failed apply can undo them.
872fn copy_entry(
873  entry: &InstallEntry,
874  dest: &Path,
875  root: &Path,
876  created_dirs: &mut Vec<PathBuf>,
877) -> Result<()> {
878  if let Some(parent) = dest.parent() {
879    record_created_dirs(parent, root, created_dirs);
880
881    fs::create_dir_all(parent).map_err(|e| {
882      Error::Install(format!(
883        "failed to create install directory {}: {}",
884        parent.display(),
885        e
886      ))
887    })?;
888  }
889
890  fs::copy(&entry.source, dest).map_err(|e| {
891    Error::Install(format!(
892      "failed to copy {} to {}: {}",
893      entry.source.display(),
894      dest.display(),
895      e
896    ))
897  })?;
898
899  Ok(())
900}
901
902/// Records, into `created_dirs`, each not-yet-existing ancestor of `dir` (down to
903/// and including `dir`) that lies strictly below `root`, ordered shallowest first.
904///
905/// Called before `create_dir_all` so a failed apply can remove exactly the
906/// directory trees this call introduced — never `root` itself and never a
907/// directory that already existed. Already-recorded (now-existing) directories are
908/// skipped, so entries sharing a parent do not double-record it.
909fn record_created_dirs(dir: &Path, root: &Path, created_dirs: &mut Vec<PathBuf>) {
910  let mut missing = Vec::new();
911  let mut current = dir;
912
913  while current != root && current.starts_with(root) && !current.exists() {
914    missing.push(current.to_path_buf());
915
916    match current.parent() {
917      Some(parent) => current = parent,
918      None => break,
919    }
920  }
921
922  for path in missing.into_iter().rev() {
923    created_dirs.push(path);
924  }
925}
926
927/// Best-effort removal of the files an [`apply_install`] created before it
928/// failed, then the now-empty directory trees it newly created, so a failed apply
929/// leaves `root` as it found it rather than half applied. Directories are removed
930/// deepest-first (children before parents) and only if empty, so a pre-existing or
931/// still-populated directory is never taken. Cleanup errors are logged, not
932/// propagated: the caller needs to see the original failure, not a secondary one.
933fn rollback_partial_install(created: &[PathBuf], created_dirs: &[PathBuf]) {
934  for path in created.iter().rev() {
935    if let Err(err) = fs::remove_file(path) {
936      tracing::warn!(
937        "failed to roll back partially-installed file {}: {}",
938        path.display(),
939        err
940      );
941    }
942  }
943
944  for dir in created_dirs.iter().rev() {
945    if let Err(err) = fs::remove_dir(dir) {
946      tracing::warn!(
947        "failed to roll back partially-created directory {}: {}",
948        dir.display(),
949        err
950      );
951    }
952  }
953}
954
955/// The `<Owner-Name>`-shaped mod folders under `profile`'s namespaced install
956/// routes that `keep` does not name, as root-relative paths. These are exactly
957/// the directories [`remove_delisted`] would delete.
958///
959/// Each regular mod installs into its own `<Owner-Name>` folder under a
960/// namespaced route (e.g. `BepInEx/plugins/<Owner-Name>/`), so the folders
961/// present under those routes *are* the record of what is installed.
962/// Non-namespaced areas are never considered, which is why loader files at the
963/// root and shared/`none` routes like `BepInEx/config` never appear here.
964///
965/// Public so a caller can ask what a sweep would take before taking it, which is
966/// what lets a CLI warn about folders it does not manage. Sharing this with
967/// [`remove_delisted`] is the point: a second implementation of "what does the
968/// sweep touch" can drift from the one that actually deletes.
969///
970/// Sorted and deduplicated, which `read_dir` order alone does not give, so a
971/// caller listing these one per line reads the same list every run.
972pub fn plan_delisted(
973  profile: &GameProfile,
974  root: &Path,
975  keep: &BTreeSet<String>,
976) -> Result<Vec<PathBuf>> {
977  let mut routes = Vec::new();
978
979  collect_routes(
980    &profile.install_rules,
981    Path::new(""),
982    &|rule| rule.tracking_method.is_namespaced(),
983    &mut routes,
984  );
985
986  let mut planned = BTreeSet::new();
987
988  for route in routes {
989    ensure_safe_relative(&route)?;
990
991    let dir = root.join(&route);
992
993    if !dir.is_dir() {
994      continue;
995    }
996
997    let mut listing = Vec::new();
998
999    for entry in fs::read_dir(&dir)
1000      .map_err(|e| io_context(format!("reading install route {}", dir.display()), e))?
1001    {
1002      let path = entry
1003        .map_err(|e| io_context(format!("reading an entry under {}", dir.display()), e))?
1004        .path();
1005      let metadata = fs::symlink_metadata(&path)
1006        .map_err(|e| io_context(format!("inspecting {}", path.display()), e))?;
1007
1008      listing.push((path, metadata.is_dir(), metadata.file_type().is_symlink()));
1009    }
1010
1011    for path in plan_reconcile(&listing, keep) {
1012      planned.insert(path.strip_prefix(root).unwrap_or(&path).to_path_buf());
1013    }
1014  }
1015
1016  Ok(planned.into_iter().collect())
1017}
1018
1019/// Reconciles the on-disk install to a desired set, removing the folders of mods
1020/// that are no longer wanted, without any sidecar state file.
1021///
1022/// This is [`plan_delisted`] followed by the removal, so there is one enumeration
1023/// and one decision about what the sweep covers. Returns the removed directories
1024/// (relative to `root`), which is exactly what `plan_delisted` returned.
1025pub fn remove_delisted(
1026  profile: &GameProfile,
1027  root: &Path,
1028  keep: &BTreeSet<String>,
1029) -> Result<Vec<PathBuf>> {
1030  let planned = plan_delisted(profile, root, keep)?;
1031
1032  for relative in &planned {
1033    let path = root.join(relative);
1034
1035    fs::remove_dir_all(&path)
1036      .map_err(|e| io_context(format!("removing delisted mod {}", path.display()), e))?;
1037  }
1038
1039  Ok(planned)
1040}
1041
1042/// Removes one mod's `<route>/<ident>/` folder under every namespaced route, so
1043/// a reinstall starts from an empty folder instead of writing beside files the
1044/// new version no longer ships (or a disabled install's `.old` files).
1045///
1046/// Shared routes (`none`, `state`) are untouched; a mod's `State`-tracked files
1047/// are removed by [`remove_state_payload`] instead. A symlink where the folder
1048/// would be is left alone, never followed, as [`remove_delisted`] does.
1049pub fn remove_mod_folders(profile: &GameProfile, root: &Path, ident: &str) -> Result<()> {
1050  let mut components = Path::new(ident).components();
1051
1052  if !matches!(
1053    (components.next(), components.next()),
1054    (Some(Component::Normal(_)), None)
1055  ) {
1056    return Err(Error::Install(format!(
1057      "refusing to remove folders for unsafe mod name {ident:?}"
1058    )));
1059  }
1060
1061  let mut routes = Vec::new();
1062
1063  collect_routes(
1064    &profile.install_rules,
1065    Path::new(""),
1066    &|rule| rule.tracking_method.is_namespaced(),
1067    &mut routes,
1068  );
1069
1070  for route in routes {
1071    let relative = route.join(ident);
1072
1073    ensure_safe_relative(&relative)?;
1074
1075    let path = root.join(&relative);
1076
1077    let is_real_dir = match fs::symlink_metadata(&path) {
1078      Ok(metadata) => metadata.is_dir(),
1079      Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
1080      Err(e) => return Err(io_context(format!("inspecting {}", path.display()), e)),
1081    };
1082
1083    if is_real_dir {
1084      fs::remove_dir_all(&path)
1085        .map_err(|e| io_context(format!("removing mod folder {}", path.display()), e))?;
1086    }
1087  }
1088
1089  Ok(())
1090}
1091
1092/// Decides which directories under a namespaced route should be removed, given a
1093/// directory `listing` and the set of mod idents to `keep`.
1094///
1095/// Each tuple is `(path, is_dir, is_symlink)`. A symlink or non-directory is
1096/// ignored — never followed or removed. A real directory whose name has the
1097/// `<Owner-Name>` shape ([`looks_like_mod_ident`]) and is not in `keep` is
1098/// selected for removal; a loader-shipped subfolder (e.g. `MMHOOK`) or a
1099/// user-created folder that does not match that shape is left alone. Pure — no
1100/// filesystem access — so the reconcile decision is testable without planting
1101/// real directory trees.
1102fn plan_reconcile(listing: &[(PathBuf, bool, bool)], keep: &BTreeSet<String>) -> Vec<PathBuf> {
1103  listing
1104    .iter()
1105    .filter(|(path, is_dir, is_symlink)| {
1106      if *is_symlink || !*is_dir {
1107        return false;
1108      }
1109
1110      match path.file_name().and_then(|n| n.to_str()) {
1111        Some(ident) => looks_like_mod_ident(ident) && !keep.contains(ident),
1112        None => false,
1113      }
1114    })
1115    .map(|(path, _, _)| path.clone())
1116    .collect()
1117}
1118
1119/// Whether a directory name has the `<Owner-Name>` shape a mod folder uses: at
1120/// least one `-` separating segments of `[A-Za-z0-9_]`. This is the identifier
1121/// [`build_dest`] namespaces under, so it distinguishes per-mod folders from
1122/// loader-shipped or user-created folders under the same route.
1123fn looks_like_mod_ident(name: &str) -> bool {
1124  name.contains('-')
1125    && name
1126      .chars()
1127      .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
1128}
1129
1130/// Recursively collects all file paths under `dir` (skipping directories).
1131fn walk_files(dir: &Path) -> Result<Vec<PathBuf>> {
1132  let mut files = Vec::new();
1133  let mut stack = vec![dir.to_path_buf()];
1134
1135  while let Some(current) = stack.pop() {
1136    for entry in fs::read_dir(&current)
1137      .map_err(|e| io_context(format!("reading package dir {}", current.display()), e))?
1138    {
1139      let entry = entry
1140        .map_err(|e| io_context(format!("reading an entry under {}", current.display()), e))?;
1141      let path = entry.path();
1142      let metadata = fs::symlink_metadata(&path)
1143        .map_err(|e| io_context(format!("inspecting {}", path.display()), e))?;
1144
1145      if metadata.file_type().is_symlink() {
1146        // Never follow symlinks: a symlinked directory could let an extracted
1147        // package escape its root or create a traversal cycle.
1148        tracing::warn!("Skipping symlink in package contents: {}", path.display());
1149
1150        continue;
1151      }
1152
1153      if metadata.is_dir() {
1154        stack.push(path);
1155      } else {
1156        files.push(path);
1157      }
1158    }
1159  }
1160
1161  files.sort();
1162
1163  Ok(files)
1164}
1165
1166/// Computes the rename each file needs to toggle its enabled state, omitting
1167/// files already in the desired state. Disabling appends `.old` to a file that
1168/// does not already end in `.old`; enabling strips a trailing `.old`. Pure — no
1169/// I/O — so the policy is table-testable (mirrors `classify_entry`).
1170pub fn plan_suffix_renames(files: &[PathBuf], disable: bool) -> Vec<(PathBuf, PathBuf)> {
1171  let mut renames = Vec::new();
1172
1173  for file in files {
1174    let name = file.to_string_lossy();
1175    let is_old = name.to_lowercase().ends_with(".old");
1176
1177    if disable && !is_old {
1178      renames.push((file.clone(), PathBuf::from(format!("{name}.old"))));
1179    } else if !disable && is_old {
1180      renames.push((file.clone(), PathBuf::from(&name[..name.len() - 4])));
1181    }
1182  }
1183
1184  renames
1185}
1186
1187/// Disables an installed mod: renames its `Subdir`-tracked files to `.old` and
1188/// removes its `State`-tracked files (recoverable via the `_state` file on
1189/// enable). Leaves shared/`none` config and the loader untouched.
1190pub fn disable_mod(profile: &GameProfile, profile_dir: &Path, mod_name: &str) -> Result<()> {
1191  let tracker = read_state_file(profile_dir, mod_name)?;
1192
1193  toggle_subdir_files(profile, profile_dir, mod_name, true)?;
1194
1195  toggle_state_files(profile_dir, tracker, true)
1196}
1197
1198/// Enables an installed mod: strips `.old` from its `Subdir`-tracked files and
1199/// restores its `State`-tracked files by copying them back from the cache
1200/// sources recorded in the `_state` file.
1201pub fn enable_mod(profile: &GameProfile, profile_dir: &Path, mod_name: &str) -> Result<()> {
1202  let tracker = read_state_file(profile_dir, mod_name)?;
1203
1204  toggle_subdir_files(profile, profile_dir, mod_name, false)?;
1205
1206  toggle_state_files(profile_dir, tracker, false)
1207}
1208
1209/// Applies the `.old` rename policy to every file under each namespaced route's
1210/// `<route>/<mod_name>/` folder.
1211fn toggle_subdir_files(
1212  profile: &GameProfile,
1213  profile_dir: &Path,
1214  mod_name: &str,
1215  disable: bool,
1216) -> Result<()> {
1217  let mut routes = Vec::new();
1218
1219  collect_routes(
1220    &profile.install_rules,
1221    Path::new(""),
1222    &|rule| rule.tracking_method.is_namespaced(),
1223    &mut routes,
1224  );
1225
1226  for route in routes {
1227    let mod_dir = profile_dir.join(&route).join(mod_name);
1228
1229    if !mod_dir.is_dir() {
1230      continue;
1231    }
1232
1233    let files = walk_files(&mod_dir)?;
1234
1235    for (from, to) in plan_suffix_renames(&files, disable) {
1236      fs::rename(&from, &to).map_err(|e| io_context(format!("toggling {}", from.display()), e))?;
1237    }
1238  }
1239
1240  Ok(())
1241}
1242
1243/// Removes (disable) or restores from cache (enable) the `State`-tracked files
1244/// recorded in `tracker`. `None` is a no-op: the mod tracked no `State` files.
1245///
1246/// Takes an already-read tracker rather than reading one itself so its caller can
1247/// do that read before any other mutation. Reading it here instead meant an
1248/// unreadable record failed only after [`toggle_subdir_files`] had renamed the
1249/// mod's files, leaving them toggled on disk while the caller's `mods.yml` still
1250/// recorded the old state.
1251fn toggle_state_files(
1252  profile_dir: &Path,
1253  tracker: Option<ModFileTracker>,
1254  disable: bool,
1255) -> Result<()> {
1256  let Some(tracker) = tracker else {
1257    return Ok(());
1258  };
1259
1260  if disable {
1261    return remove_state_files(profile_dir, &tracker);
1262  }
1263
1264  restore_state_files(profile_dir, &tracker)
1265}
1266
1267/// Removes each `State`-tracked destination that still exists, leaving the
1268/// `_state` file itself in place so [`restore_state_files`] can undo this.
1269///
1270/// Each recorded path is re-validated before use: the `_state` file sits in a
1271/// caller-owned directory and could be hand-edited, so a tampered entry must not
1272/// delete a file outside the profile root.
1273fn remove_state_files(profile_dir: &Path, tracker: &ModFileTracker) -> Result<()> {
1274  for (_, install_rel) in &tracker.files {
1275    ensure_safe_relative(Path::new(install_rel))?;
1276
1277    let dest = profile_dir.join(install_rel);
1278
1279    if !dest.exists() {
1280      continue;
1281    }
1282
1283    fs::remove_file(&dest).map_err(|e| io_context(format!("removing {}", dest.display()), e))?;
1284  }
1285
1286  Ok(())
1287}
1288
1289/// Copies each `State`-tracked file back from its recorded cache source,
1290/// creating any missing destination directories.
1291fn restore_state_files(profile_dir: &Path, tracker: &ModFileTracker) -> Result<()> {
1292  for (source, install_rel) in &tracker.files {
1293    let dest = profile_dir.join(install_rel);
1294
1295    if let Some(parent) = dest.parent() {
1296      fs::create_dir_all(parent)
1297        .map_err(|e| io_context(format!("creating {}", parent.display()), e))?;
1298    }
1299
1300    fs::copy(source, &dest)
1301      .map_err(|e| io_context(format!("restoring {} from {}", dest.display(), source), e))?;
1302  }
1303
1304  Ok(())
1305}
1306
1307#[cfg(test)]
1308mod tests {
1309  use super::*;
1310  use crate::ecosystem::{Ecosystem, GameProfile};
1311  use std::fs::File;
1312  use std::io::Write;
1313  use tempfile::tempdir;
1314
1315  fn eco() -> Ecosystem {
1316    Ecosystem::bundled()
1317  }
1318
1319  fn profile(eco: &Ecosystem, slug: &str) -> GameProfile {
1320    eco.game(slug).unwrap().profile().unwrap().clone()
1321  }
1322
1323  fn write(path: &Path, contents: &[u8]) {
1324    if let Some(parent) = path.parent() {
1325      fs::create_dir_all(parent).unwrap();
1326    }
1327
1328    let mut f = File::create(path).unwrap();
1329    f.write_all(contents).unwrap();
1330  }
1331
1332  fn dest_set(plan: &InstallPlan) -> Vec<String> {
1333    let mut v: Vec<String> = plan
1334      .entries
1335      .iter()
1336      .map(|e| e.dest_relative.to_string_lossy().replace('\\', "/"))
1337      .collect();
1338    v.sort();
1339
1340    v
1341  }
1342
1343  #[test]
1344  fn test_bepinex_regular_mod_routing() {
1345    let eco = eco();
1346    let profile = profile(&eco, "valheim");
1347
1348    let pkg = tempdir().unwrap();
1349    let root = pkg.path();
1350
1351    write(&root.join("MyMod.dll"), b"dll");
1352    write(&root.join("manifest.json"), b"{}");
1353    write(&root.join("config/MyMod.cfg"), b"cfg");
1354    write(&root.join("patchers/MyPatcher.dll"), b"patch");
1355    write(&root.join("MyHook.mm.dll"), b"hook");
1356
1357    let plan = plan_install(&eco, &profile, "Author-MyMod", root, None).unwrap();
1358    let dests = dest_set(&plan);
1359
1360    assert!(dests.contains(&"BepInEx/plugins/Author-MyMod/MyMod.dll".to_string()));
1361    // BepInEx defines no exclusions, so metadata routes to the plugin folder.
1362    assert!(dests.contains(&"BepInEx/plugins/Author-MyMod/manifest.json".to_string()));
1363    // config is shared/untracked (not namespaced).
1364    assert!(dests.contains(&"BepInEx/config/MyMod.cfg".to_string()));
1365    // override patchers folder routes (namespaced), contents only.
1366    assert!(dests.contains(&"BepInEx/patchers/Author-MyMod/MyPatcher.dll".to_string()));
1367    // .mm.dll extension wins over .dll and routes to monomod.
1368    assert!(dests.contains(&"BepInEx/monomod/Author-MyMod/MyHook.mm.dll".to_string()));
1369  }
1370
1371  #[test]
1372  fn route_file_routes_by_table_without_disk() {
1373    let eco = eco();
1374    let profile = profile(&eco, "valheim");
1375
1376    let mut flat = Vec::new();
1377
1378    flatten_rules(&profile.install_rules, Path::new(""), &mut flat);
1379
1380    let ident = "Author-MyMod";
1381
1382    // A loose DLL routes to the (subdir-tracked, namespaced) plugins folder.
1383    match route_file(&profile, &flat, ident, Path::new("MyMod.dll")).unwrap() {
1384      RouteOutcome::Route {
1385        dest_relative,
1386        tracking,
1387      } => {
1388        assert_eq!(
1389          dest_relative,
1390          PathBuf::from("BepInEx/plugins/Author-MyMod/MyMod.dll")
1391        );
1392        assert_eq!(tracking, TrackingMethod::Subdir);
1393      }
1394      other => panic!("expected a route, got {other:?}"),
1395    }
1396
1397    // The most-specific extension wins: `.mm.dll` beats `.dll` and routes to monomod.
1398    match route_file(&profile, &flat, ident, Path::new("MyHook.mm.dll")).unwrap() {
1399      RouteOutcome::Route { dest_relative, .. } => assert_eq!(
1400        dest_relative,
1401        PathBuf::from("BepInEx/monomod/Author-MyMod/MyHook.mm.dll")
1402      ),
1403      other => panic!("expected a route, got {other:?}"),
1404    }
1405
1406    // A config file is shared/untracked (`none`), not namespaced.
1407    match route_file(&profile, &flat, ident, Path::new("config/MyMod.cfg")).unwrap() {
1408      RouteOutcome::Route {
1409        dest_relative,
1410        tracking,
1411      } => {
1412        assert_eq!(dest_relative, PathBuf::from("BepInEx/config/MyMod.cfg"));
1413        assert_eq!(tracking, TrackingMethod::None);
1414      }
1415      other => panic!("expected a route, got {other:?}"),
1416    }
1417
1418    // A path with no file name is skipped rather than routed.
1419    assert_eq!(
1420      route_file(&profile, &flat, ident, Path::new("")).unwrap(),
1421      RouteOutcome::Skip(SkipReason::NoName)
1422    );
1423  }
1424
1425  #[test]
1426  fn route_leaf_handles_both_separators() {
1427    assert_eq!(route_leaf("BepInEx/plugins"), "plugins");
1428    assert_eq!(route_leaf("BepInEx\\plugins"), "plugins");
1429    assert_eq!(route_leaf("plugins"), "plugins");
1430  }
1431
1432  #[test]
1433  fn classify_entry_truth_table() {
1434    // Shared config (`none`): skip if a file or a symlink already sits there;
1435    // otherwise write it, but never record it in the manifest.
1436    assert_eq!(
1437      classify_entry(TrackingMethod::None, true, false),
1438      ApplyAction::SkipExistingConfig
1439    );
1440    assert_eq!(
1441      classify_entry(TrackingMethod::None, false, true),
1442      ApplyAction::SkipExistingConfig
1443    );
1444    assert_eq!(
1445      classify_entry(TrackingMethod::None, false, false),
1446      ApplyAction::Write { record: false }
1447    );
1448
1449    // Tracked: refuse a pre-existing symlink; otherwise write and record it.
1450    assert_eq!(
1451      classify_entry(TrackingMethod::Subdir, false, true),
1452      ApplyAction::RefuseSymlink
1453    );
1454    assert_eq!(
1455      classify_entry(TrackingMethod::State, true, false),
1456      ApplyAction::Write { record: true }
1457    );
1458    assert_eq!(
1459      classify_entry(TrackingMethod::SubdirNoFlatten, false, false),
1460      ApplyAction::Write { record: true }
1461    );
1462  }
1463
1464  #[test]
1465  fn plan_reconcile_selects_only_unwanted_mod_dirs() {
1466    let keep: BTreeSet<String> = ["Author-Keep".to_string()].into_iter().collect();
1467
1468    // (path, is_dir, is_symlink)
1469    let listing = vec![
1470      (PathBuf::from("Author-Keep"), true, false),  // kept
1471      (PathBuf::from("Author-Drop"), true, false),  // removed
1472      (PathBuf::from("MMHOOK"), true, false),       // not an ident -> ignored
1473      (PathBuf::from("Author-Link"), true, true),   // symlink -> ignored
1474      (PathBuf::from("Author-File"), false, false), // not a dir -> ignored
1475    ];
1476
1477    let removable = plan_reconcile(&listing, &keep);
1478
1479    assert_eq!(removable, vec![PathBuf::from("Author-Drop")]);
1480  }
1481
1482  #[test]
1483  fn classify_loader_file_drops_metadata_excluded_and_routes_rest() {
1484    let untracked = vec![PathBuf::from("BepInEx/config")];
1485    let exclusions = vec!["LICENCE".to_string()];
1486
1487    // Package-root metadata is dropped.
1488    assert_eq!(
1489      classify_loader_file(Path::new("manifest.json"), &untracked, &exclusions).unwrap(),
1490      None
1491    );
1492
1493    // An excluded file is dropped.
1494    assert_eq!(
1495      classify_loader_file(Path::new("LICENCE"), &untracked, &exclusions).unwrap(),
1496      None
1497    );
1498
1499    // A file under an untracked route is preserved (`none` tracking).
1500    assert_eq!(
1501      classify_loader_file(Path::new("BepInEx/config/x.cfg"), &untracked, &exclusions).unwrap(),
1502      Some(TrackingMethod::None)
1503    );
1504
1505    // Anything else is recorded (`state` tracking).
1506    assert_eq!(
1507      classify_loader_file(
1508        Path::new("BepInEx/core/loader.dll"),
1509        &untracked,
1510        &exclusions
1511      )
1512      .unwrap(),
1513      Some(TrackingMethod::State)
1514    );
1515
1516    // A traversal path is rejected.
1517    assert!(classify_loader_file(Path::new("../evil"), &untracked, &exclusions).is_err());
1518  }
1519
1520  #[test]
1521  fn apply_install_empty_plan_writes_nothing() {
1522    let root = tempdir().unwrap();
1523
1524    let manifest = apply_install(&InstallPlan::default(), root.path()).unwrap();
1525
1526    assert!(manifest.written.is_empty());
1527  }
1528
1529  #[test]
1530  fn test_subdir_override_folder_preserves_subpath() {
1531    let eco = eco();
1532    let profile = profile(&eco, "valheim");
1533
1534    let pkg = tempdir().unwrap();
1535    let root = pkg.path();
1536
1537    // A file nested inside the subdir-tracked plugins override folder keeps its
1538    // path beneath that folder; only the matched `plugins/` itself is stripped.
1539    write(&root.join("plugins/nested/Deep.dll"), b"deep");
1540    write(&root.join("plugins/Translations/German/german.json"), b"de");
1541
1542    let plan = plan_install(&eco, &profile, "Author-MyMod", root, None).unwrap();
1543    let dests = dest_set(&plan);
1544
1545    assert!(dests.contains(&"BepInEx/plugins/Author-MyMod/nested/Deep.dll".to_string()));
1546    assert!(
1547      dests.contains(&"BepInEx/plugins/Author-MyMod/Translations/German/german.json".to_string())
1548    );
1549  }
1550
1551  #[test]
1552  fn test_subdir_override_folder_keeps_same_named_files_apart() {
1553    let eco = eco();
1554    let profile = profile(&eco, "valheim");
1555
1556    let pkg = tempdir().unwrap();
1557    let root = pkg.path();
1558
1559    write(&root.join("plugins/English/strings.json"), b"en");
1560    write(&root.join("plugins/German/strings.json"), b"de");
1561
1562    let plan = plan_install(&eco, &profile, "Author-MyMod", root, None).unwrap();
1563    let dests = dest_set(&plan);
1564
1565    assert_eq!(
1566      dests,
1567      vec![
1568        "BepInEx/plugins/Author-MyMod/English/strings.json".to_string(),
1569        "BepInEx/plugins/Author-MyMod/German/strings.json".to_string(),
1570      ]
1571    );
1572  }
1573
1574  #[test]
1575  fn test_override_folder_inside_a_wrapper_folder_is_matched() {
1576    let eco = eco();
1577    let profile = profile(&eco, "valheim");
1578
1579    let pkg = tempdir().unwrap();
1580    let root = pkg.path();
1581
1582    // r2modman walks into a folder that is not an override folder and matches
1583    // one further down, dropping the wrapper.
1584    write(&root.join("BepInEx/plugins/Mod.dll"), b"dll");
1585    write(&root.join("BepInEx/config/Mod.cfg"), b"cfg");
1586    write(
1587      &root.join("ZenUI/plugins/Translations/German/strings.json"),
1588      b"de",
1589    );
1590    write(
1591      &root.join("ZenUI/plugins/Translations/English/strings.json"),
1592      b"en",
1593    );
1594
1595    let plan = plan_install(&eco, &profile, "Author-Mod", root, None).unwrap();
1596    let dests = dest_set(&plan);
1597
1598    assert_eq!(
1599      dests,
1600      vec![
1601        "BepInEx/config/Mod.cfg".to_string(),
1602        "BepInEx/plugins/Author-Mod/Mod.dll".to_string(),
1603        "BepInEx/plugins/Author-Mod/Translations/English/strings.json".to_string(),
1604        "BepInEx/plugins/Author-Mod/Translations/German/strings.json".to_string(),
1605      ]
1606    );
1607
1608    let cfg = plan
1609      .entries
1610      .iter()
1611      .find(|e| e.dest_relative == Path::new("BepInEx/config/Mod.cfg"))
1612      .unwrap();
1613
1614    assert_eq!(cfg.tracking, TrackingMethod::None);
1615  }
1616
1617  #[test]
1618  fn test_wrapper_folder_without_an_override_folder_still_flattens() {
1619    let eco = eco();
1620    let profile = profile(&eco, "valheim");
1621
1622    let pkg = tempdir().unwrap();
1623    let root = pkg.path();
1624
1625    write(&root.join("Wrapper/assets/bundle.dat"), b"data");
1626    write(&root.join("Wrapper/Hook.mm.dll"), b"hook");
1627
1628    let plan = plan_install(&eco, &profile, "Author-Mod", root, None).unwrap();
1629    let dests = dest_set(&plan);
1630
1631    assert_eq!(
1632      dests,
1633      vec![
1634        "BepInEx/monomod/Author-Mod/Hook.mm.dll".to_string(),
1635        "BepInEx/plugins/Author-Mod/bundle.dat".to_string(),
1636      ]
1637    );
1638  }
1639
1640  #[test]
1641  fn test_subdir_default_route_flattens_nested_file() {
1642    let eco = eco();
1643    let profile = profile(&eco, "valheim");
1644
1645    let pkg = tempdir().unwrap();
1646    let root = pkg.path();
1647
1648    // `web` is not an override folder, so its files fall through to the default
1649    // (subdir-tracked) plugins route one by one and land by base name, matching
1650    // r2modman, which flattens files it reaches by walking into ordinary folders.
1651    write(&root.join("web/js/card.js"), b"card");
1652
1653    let plan = plan_install(&eco, &profile, "Author-Mod", root, None).unwrap();
1654    let dests = dest_set(&plan);
1655
1656    assert_eq!(
1657      dests,
1658      vec!["BepInEx/plugins/Author-Mod/card.js".to_string()]
1659    );
1660  }
1661
1662  #[test]
1663  fn test_plan_refuses_files_flattened_onto_the_same_destination() {
1664    let eco = eco();
1665    let profile = profile(&eco, "valheim");
1666
1667    let pkg = tempdir().unwrap();
1668    let root = pkg.path();
1669
1670    write(&root.join("web/a/index.js"), b"a");
1671    write(&root.join("web/b/index.js"), b"b");
1672
1673    let error = plan_install(&eco, &profile, "Author-Mod", root, None).unwrap_err();
1674    let message = error.to_string();
1675
1676    assert!(
1677      message.contains("BepInEx/plugins/Author-Mod/index.js"),
1678      "{message}"
1679    );
1680    assert!(message.contains("web/a/index.js"), "{message}");
1681    assert!(message.contains("web/b/index.js"), "{message}");
1682  }
1683
1684  #[test]
1685  fn test_root_file_named_like_override_folder_is_not_a_folder_match() {
1686    let eco = eco();
1687    let profile = profile(&eco, "valheim");
1688
1689    let pkg = tempdir().unwrap();
1690    let root = pkg.path();
1691
1692    // Only a directory matches an override folder. A loose file that happens to be
1693    // named `plugins` routes like any other file instead of resolving to an empty
1694    // remainder (which would place it at the route folder itself).
1695    write(&root.join("plugins"), b"not a folder");
1696
1697    let plan = plan_install(&eco, &profile, "Author-Mod", root, None).unwrap();
1698    let dests = dest_set(&plan);
1699
1700    assert_eq!(
1701      dests,
1702      vec!["BepInEx/plugins/Author-Mod/plugins".to_string()]
1703    );
1704  }
1705
1706  #[test]
1707  fn test_toggle_and_uninstall_handle_nested_subdir_files() {
1708    let eco = eco();
1709    let profile = profile(&eco, "valheim");
1710
1711    let pkg = tempdir().unwrap();
1712    write(&pkg.path().join("plugins/Mod.dll"), b"dll");
1713    write(
1714      &pkg.path().join("plugins/Translations/German/german.json"),
1715      b"de",
1716    );
1717
1718    let plan = plan_install(&eco, &profile, "Author-Mod", pkg.path(), None).unwrap();
1719
1720    let profile_dir = tempdir().unwrap();
1721    let root = profile_dir.path();
1722
1723    apply_install(&plan, root).unwrap();
1724
1725    let mod_dir = root.join("BepInEx/plugins/Author-Mod");
1726    let nested = mod_dir.join("Translations/German/german.json");
1727
1728    assert!(nested.exists());
1729
1730    disable_mod(&profile, root, "Author-Mod").unwrap();
1731
1732    assert!(!nested.exists());
1733    assert!(mod_dir.join("Translations/German/german.json.old").exists());
1734    assert!(mod_dir.join("Mod.dll.old").exists());
1735
1736    enable_mod(&profile, root, "Author-Mod").unwrap();
1737
1738    assert!(nested.exists());
1739    assert!(!mod_dir.join("Translations/German/german.json.old").exists());
1740    assert!(mod_dir.join("Mod.dll").exists());
1741
1742    remove_delisted(&profile, root, &BTreeSet::new()).unwrap();
1743
1744    assert!(!mod_dir.exists());
1745  }
1746
1747  #[test]
1748  fn test_subdir_no_flatten_preserves_subpath() {
1749    let eco = eco();
1750    let profile = profile(&eco, "rumble");
1751
1752    let pkg = tempdir().unwrap();
1753    let root = pkg.path();
1754
1755    // rumble's default location (UserData/ModManager) is subdir-no-flatten, so a
1756    // nested file keeps its relative sub-path under the mod's namespaced folder.
1757    write(&root.join("data/nested/asset.bin"), b"asset");
1758
1759    let plan = plan_install(&eco, &profile, "Author-Mod", root, None).unwrap();
1760    let dests = dest_set(&plan);
1761
1762    assert!(dests.contains(&"UserData/ModManager/Author-Mod/data/nested/asset.bin".to_string()));
1763  }
1764
1765  #[test]
1766  fn test_extension_routing_applies_to_nested_files() {
1767    let eco = eco();
1768    let profile = profile(&eco, "valheim");
1769
1770    let pkg = tempdir().unwrap();
1771    let root = pkg.path();
1772
1773    // A .mm.dll inside an ordinary (non-override) subfolder still routes by its
1774    // extension to monomod rather than falling to the default plugins route; the
1775    // subdir flatten then drops the wrapper directory.
1776    write(&root.join("Wrapper/Hook.mm.dll"), b"hook");
1777
1778    let plan = plan_install(&eco, &profile, "Author-MyMod", root, None).unwrap();
1779    let dests = dest_set(&plan);
1780
1781    assert!(dests.contains(&"BepInEx/monomod/Author-MyMod/Hook.mm.dll".to_string()));
1782    assert!(!dests.iter().any(|d| d.contains("Wrapper")));
1783  }
1784
1785  #[test]
1786  fn test_loader_pack_unpacks_to_root_stripping_nested_subfolder() {
1787    let eco = eco();
1788    let profile = profile(&eco, "valheim");
1789
1790    let pkg = tempdir().unwrap();
1791    let root = pkg.path();
1792
1793    write(&root.join("BepInExPack_Valheim/winhttp.dll"), b"proxy");
1794    write(
1795      &root.join("BepInExPack_Valheim/BepInEx/core/BepInEx.dll"),
1796      b"core",
1797    );
1798    write(&root.join("manifest.json"), b"{}");
1799    write(&root.join("icon.png"), b"png");
1800
1801    let plan = plan_install(&eco, &profile, "denikson-BepInExPack_Valheim", root, None).unwrap();
1802    let dests = dest_set(&plan);
1803
1804    assert!(dests.contains(&"winhttp.dll".to_string()));
1805    assert!(dests.contains(&"BepInEx/core/BepInEx.dll".to_string()));
1806    assert!(!dests.iter().any(|d| d.contains("manifest.json")));
1807    assert!(!dests.iter().any(|d| d.contains("BepInExPack_Valheim")));
1808    assert!(!dests.iter().any(|d| d.contains("icon.png")));
1809  }
1810
1811  #[test]
1812  fn test_loader_pack_preserves_config_on_reinstall() {
1813    let eco = eco();
1814    let profile = profile(&eco, "valheim");
1815
1816    let pkg = tempdir().unwrap();
1817    let pkg_root = pkg.path();
1818    write(&pkg_root.join("BepInExPack_Valheim/winhttp.dll"), b"proxy");
1819    write(
1820      &pkg_root.join("BepInExPack_Valheim/BepInEx/config/BepInEx.cfg"),
1821      b"default",
1822    );
1823
1824    let profile_dir = tempdir().unwrap();
1825    let root = profile_dir.path();
1826
1827    let plan = plan_install(
1828      &eco,
1829      &profile,
1830      "denikson-BepInExPack_Valheim",
1831      pkg_root,
1832      None,
1833    )
1834    .unwrap();
1835    apply_install(&plan, root).unwrap();
1836
1837    let cfg = root.join("BepInEx/config/BepInEx.cfg");
1838    write(&cfg, b"user-edited");
1839
1840    let plan = plan_install(
1841      &eco,
1842      &profile,
1843      "denikson-BepInExPack_Valheim",
1844      pkg_root,
1845      None,
1846    )
1847    .unwrap();
1848    apply_install(&plan, root).unwrap();
1849
1850    assert_eq!(fs::read_to_string(&cfg).unwrap(), "user-edited");
1851    assert!(root.join("winhttp.dll").exists());
1852  }
1853
1854  #[test]
1855  fn test_loader_pack_without_nesting_falls_back_to_root() {
1856    let eco = eco();
1857    let profile = profile(&eco, "valheim");
1858
1859    let pkg = tempdir().unwrap();
1860    let root = pkg.path();
1861
1862    // The pack declares a nested root folder, but it is absent here: copy the
1863    // package root directly.
1864    write(&root.join("winhttp.dll"), b"proxy");
1865
1866    let plan = plan_install(&eco, &profile, "denikson-BepInExPack_Valheim", root, None).unwrap();
1867    let dests = dest_set(&plan);
1868
1869    assert!(dests.contains(&"winhttp.dll".to_string()));
1870  }
1871
1872  #[test]
1873  fn test_loader_pack_skips_relative_file_exclusions() {
1874    let eco = eco();
1875    // The boneworks profile lists LICENCE in relativeFileExclusions.
1876    let profile = profile(&eco, "boneworks");
1877
1878    let pkg = tempdir().unwrap();
1879    let root = pkg.path();
1880
1881    // A loader pack from the bundled registry whose declared root folder is absent,
1882    // so its own root is copied directly.
1883    write(&root.join("winhttp.dll"), b"proxy");
1884    write(&root.join("LICENCE"), b"license text");
1885
1886    let plan = plan_install(&eco, &profile, "denikson-BepInExPack_Valheim", root, None).unwrap();
1887    let dests = dest_set(&plan);
1888
1889    assert!(dests.contains(&"winhttp.dll".to_string()));
1890    // The profile-excluded LICENCE file is neither installed nor recorded, just
1891    // like plan_regular_mod honors the same exclusions.
1892    assert!(!dests.iter().any(|d| d.contains("LICENCE")));
1893  }
1894
1895  #[test]
1896  fn test_config_is_preserved_across_reinstall() {
1897    let eco = eco();
1898    let profile = profile(&eco, "valheim");
1899
1900    let pkg = tempdir().unwrap();
1901    let pkg_root = pkg.path();
1902    write(&pkg_root.join("MyMod.dll"), b"dll");
1903    write(&pkg_root.join("config/MyMod.cfg"), b"default-config");
1904
1905    let profile_dir = tempdir().unwrap();
1906    let root = profile_dir.path();
1907
1908    let plan = plan_install(&eco, &profile, "Author-MyMod", pkg_root, None).unwrap();
1909    let first = apply_install(&plan, root).unwrap();
1910
1911    assert_eq!(first.written.len(), 1);
1912    assert!(root.join("BepInEx/config/MyMod.cfg").exists());
1913
1914    let cfg = root.join("BepInEx/config/MyMod.cfg");
1915    write(&cfg, b"user-edited");
1916
1917    let plan = plan_install(&eco, &profile, "Author-MyMod", pkg_root, None).unwrap();
1918    let second = apply_install(&plan, root).unwrap();
1919
1920    assert_eq!(fs::read_to_string(&cfg).unwrap(), "user-edited");
1921    assert!(second.written.iter().any(|p| p.ends_with("MyMod.dll")));
1922    assert!(
1923      !second
1924        .written
1925        .iter()
1926        .any(|p| p.to_string_lossy().contains("MyMod.cfg"))
1927    );
1928  }
1929
1930  #[test]
1931  fn test_apply_and_manifest_roundtrip() {
1932    let eco = eco();
1933    let profile = profile(&eco, "valheim");
1934
1935    let pkg = tempdir().unwrap();
1936    let pkg_root = pkg.path();
1937    write(&pkg_root.join("MyMod.dll"), b"dll");
1938    write(&pkg_root.join("config/MyMod.cfg"), b"cfg");
1939
1940    let plan = plan_install(&eco, &profile, "Author-MyMod", pkg_root, None).unwrap();
1941
1942    let profile_dir = tempdir().unwrap();
1943    let root = profile_dir.path();
1944    let manifest = apply_install(&plan, root).unwrap();
1945
1946    assert_eq!(manifest.written.len(), 1);
1947    assert!(root.join("BepInEx/plugins/Author-MyMod/MyMod.dll").exists());
1948    assert!(root.join("BepInEx/config/MyMod.cfg").exists());
1949
1950    for rel in &manifest.written {
1951      fs::remove_file(root.join(rel)).unwrap();
1952    }
1953
1954    assert!(!root.join("BepInEx/plugins/Author-MyMod/MyMod.dll").exists());
1955  }
1956
1957  #[test]
1958  fn test_apply_install_rolls_back_on_failure() {
1959    let src = tempdir().unwrap();
1960
1961    let good = src.path().join("good.dll");
1962    write(&good, b"good");
1963
1964    let plan = InstallPlan {
1965      entries: vec![
1966        InstallEntry {
1967          source: good,
1968          dest_relative: PathBuf::from("BepInEx/plugins/Author-Mod/good.dll"),
1969          tracking: TrackingMethod::State,
1970        },
1971        InstallEntry {
1972          source: src.path().join("does_not_exist.dll"),
1973          dest_relative: PathBuf::from("BepInEx/plugins/Author-Mod/missing.dll"),
1974          tracking: TrackingMethod::State,
1975        },
1976      ],
1977    };
1978
1979    let profile_dir = tempdir().unwrap();
1980    let root = profile_dir.path();
1981
1982    let result = apply_install(&plan, root);
1983
1984    // The second entry fails to copy (its source is absent); the first,
1985    // already-written file is rolled back so no untracked file is left behind.
1986    assert!(result.is_err());
1987    assert!(!root.join("BepInEx/plugins/Author-Mod/good.dll").exists());
1988    // The directory tree this apply newly created is removed too, so no empty
1989    // directories are left behind.
1990    assert!(!root.join("BepInEx/plugins/Author-Mod").exists());
1991    assert!(!root.join("BepInEx/plugins").exists());
1992    assert!(!root.join("BepInEx").exists());
1993    // The caller-supplied root itself is left in place.
1994    assert!(root.exists());
1995  }
1996
1997  #[test]
1998  #[cfg(unix)]
1999  fn test_apply_install_preserves_symlinked_shared_config() {
2000    use std::os::unix::fs::symlink;
2001
2002    let src = tempdir().unwrap();
2003    let source = src.path().join("mod.cfg");
2004    write(&source, b"packaged-default");
2005
2006    let outside = tempdir().unwrap();
2007    let user_config = outside.path().join("user.cfg");
2008    write(&user_config, b"user-edited");
2009
2010    let profile_dir = tempdir().unwrap();
2011    let root = profile_dir.path();
2012
2013    // The user symlinked their shared config to a file they maintain elsewhere.
2014    let dest_relative = PathBuf::from("BepInEx/config/mod.cfg");
2015    let dest = root.join(&dest_relative);
2016    fs::create_dir_all(dest.parent().unwrap()).unwrap();
2017    symlink(&user_config, &dest).unwrap();
2018
2019    let plan = InstallPlan {
2020      entries: vec![InstallEntry {
2021        source,
2022        dest_relative,
2023        tracking: TrackingMethod::None,
2024      }],
2025    };
2026
2027    let manifest = apply_install(&plan, root).unwrap();
2028
2029    // The install succeeds, the None entry is not recorded, and the user's
2030    // symlinked config is left exactly as it was.
2031    assert!(manifest.written.is_empty());
2032    assert_eq!(fs::read_to_string(&user_config).unwrap(), "user-edited");
2033  }
2034
2035  #[test]
2036  #[cfg(unix)]
2037  fn test_apply_install_refuses_to_write_through_symlink() {
2038    use std::os::unix::fs::symlink;
2039
2040    let src = tempdir().unwrap();
2041    let source = src.path().join("mod.cfg");
2042    write(&source, b"new-content");
2043
2044    let outside = tempdir().unwrap();
2045    let secret = outside.path().join("secret.txt");
2046    write(&secret, b"original");
2047
2048    let profile_dir = tempdir().unwrap();
2049    let root = profile_dir.path();
2050
2051    // Plant a symlink at the destination pointing outside the root.
2052    let dest_relative = PathBuf::from("BepInEx/config/mod.cfg");
2053    let dest = root.join(&dest_relative);
2054    fs::create_dir_all(dest.parent().unwrap()).unwrap();
2055    symlink(&secret, &dest).unwrap();
2056
2057    let plan = InstallPlan {
2058      entries: vec![InstallEntry {
2059        source,
2060        dest_relative,
2061        tracking: TrackingMethod::State,
2062      }],
2063    };
2064
2065    let result = apply_install(&plan, root);
2066
2067    // The write is refused and the symlink target outside the root is untouched.
2068    assert!(result.is_err());
2069    assert_eq!(fs::read_to_string(&secret).unwrap(), "original");
2070  }
2071
2072  #[test]
2073  fn test_remove_delisted_removes_only_unwanted_mod_folders() {
2074    let eco = eco();
2075    let profile = profile(&eco, "valheim");
2076
2077    let profile_dir = tempdir().unwrap();
2078    let root = profile_dir.path();
2079
2080    write(&root.join("BepInEx/plugins/Owner-ModA/A.dll"), b"a");
2081    write(&root.join("BepInEx/plugins/Owner-ModB/B.dll"), b"b");
2082    write(&root.join("BepInEx/core/BepInEx.dll"), b"loader");
2083    write(&root.join("BepInEx/config/BepInEx.cfg"), b"cfg");
2084
2085    let keep: BTreeSet<String> = ["Owner-ModA".to_string()].into_iter().collect();
2086    let removed = remove_delisted(&profile, root, &keep).unwrap();
2087
2088    assert!(root.join("BepInEx/plugins/Owner-ModA/A.dll").exists());
2089    assert!(!root.join("BepInEx/plugins/Owner-ModB").exists());
2090    // A loose loader file in core (not a per-mod folder) and user config survive.
2091    assert!(root.join("BepInEx/core/BepInEx.dll").exists());
2092    assert!(root.join("BepInEx/config/BepInEx.cfg").exists());
2093
2094    assert_eq!(removed.len(), 1);
2095  }
2096
2097  #[test]
2098  fn plan_delisted_lists_what_remove_delisted_would_take_without_taking_it() {
2099    let eco = eco();
2100    let profile = profile(&eco, "valheim");
2101
2102    let profile_dir = tempdir().unwrap();
2103    let root = profile_dir.path();
2104
2105    write(&root.join("BepInEx/plugins/Owner-ModA/A.dll"), b"a");
2106    write(&root.join("BepInEx/plugins/Owner-ModB/B.dll"), b"b");
2107    write(&root.join("BepInEx/plugins/Owner-ModC/C.dll"), b"c");
2108    // Not `<Owner-Name>`-shaped, so the sweep never takes it.
2109    write(&root.join("BepInEx/plugins/MMHOOK/hook.dll"), b"hook");
2110
2111    let keep: BTreeSet<String> = ["Owner-ModA".to_string()].into_iter().collect();
2112    let planned = plan_delisted(&profile, root, &keep).unwrap();
2113
2114    // Root-relative and sorted, so a caller listing these one per line gets the
2115    // same order every run rather than whatever `read_dir` happened to yield.
2116    assert_eq!(
2117      planned,
2118      vec![
2119        PathBuf::from("BepInEx/plugins/Owner-ModB"),
2120        PathBuf::from("BepInEx/plugins/Owner-ModC"),
2121      ]
2122    );
2123    // Planning is not applying.
2124    assert!(root.join("BepInEx/plugins/Owner-ModB/B.dll").exists());
2125    assert!(root.join("BepInEx/plugins/Owner-ModC/C.dll").exists());
2126
2127    // What was planned is exactly what the apply takes, because they share the
2128    // one enumeration rather than each deciding for themselves.
2129    let removed = remove_delisted(&profile, root, &keep).unwrap();
2130
2131    assert_eq!(removed, planned);
2132    assert!(!root.join("BepInEx/plugins/Owner-ModB").exists());
2133    assert!(!root.join("BepInEx/plugins/Owner-ModC").exists());
2134    assert!(root.join("BepInEx/plugins/Owner-ModA/A.dll").exists());
2135    assert!(root.join("BepInEx/plugins/MMHOOK/hook.dll").exists());
2136  }
2137
2138  #[test]
2139  fn test_remove_delisted_ignores_non_mod_directories() {
2140    let eco = eco();
2141    let profile = profile(&eco, "valheim");
2142
2143    let profile_dir = tempdir().unwrap();
2144    let root = profile_dir.path();
2145
2146    // A per-mod folder (has the `Owner-Name` shape) and a loader-shipped folder
2147    // with no hyphen sitting directly under the namespaced plugins route.
2148    write(&root.join("BepInEx/plugins/Owner-Delete/x.dll"), b"x");
2149    write(&root.join("BepInEx/plugins/MMHOOK/hook.dll"), b"hook");
2150
2151    let keep: BTreeSet<String> = BTreeSet::new();
2152    let removed = remove_delisted(&profile, root, &keep).unwrap();
2153
2154    // The mod folder is reconciled away; the non-ident loader folder is left.
2155    assert!(!root.join("BepInEx/plugins/Owner-Delete").exists());
2156    assert!(root.join("BepInEx/plugins/MMHOOK/hook.dll").exists());
2157    assert_eq!(removed.len(), 1);
2158  }
2159
2160  #[test]
2161  fn test_melonloader_flat_routing() {
2162    let eco = eco();
2163    let profile = profile(&eco, "boneworks");
2164
2165    let pkg = tempdir().unwrap();
2166    let root = pkg.path();
2167    write(&root.join("MyMod.dll"), b"dll");
2168    write(&root.join("manifest.json"), b"{}");
2169
2170    let plan = plan_install(&eco, &profile, "Author-MyMod", root, None).unwrap();
2171    let dests = dest_set(&plan);
2172
2173    // .dll routes to Mods (state tracking, not namespaced).
2174    assert!(dests.contains(&"Mods/MyMod.dll".to_string()));
2175    // manifest.json is excluded by the profile's relativeFileExclusions.
2176    assert!(!dests.iter().any(|d| d.contains("manifest.json")));
2177  }
2178
2179  #[test]
2180  fn test_melonloader_specific_extension_wins_over_generic() {
2181    let eco = eco();
2182    let profile = profile(&eco, "boneworks");
2183
2184    let pkg = tempdir().unwrap();
2185    let root = pkg.path();
2186
2187    write(&root.join("MyLib.lib.dll"), b"lib");
2188    write(&root.join("MyMod.dll"), b"dll");
2189
2190    let plan = plan_install(&eco, &profile, "Author-MyMod", root, None).unwrap();
2191    let dests = dest_set(&plan);
2192
2193    assert!(dests.contains(&"UserLibs/MyLib.lib.dll".to_string()));
2194    assert!(dests.contains(&"Mods/MyMod.dll".to_string()));
2195  }
2196
2197  #[test]
2198  fn test_melonloader_subroute_extension_routing() {
2199    let eco = eco();
2200    let profile = profile(&eco, "bonelab");
2201
2202    let pkg = tempdir().unwrap();
2203    let root = pkg.path();
2204
2205    // A loose .melon routes to the UserData/CustomItems subroute by extension.
2206    write(&root.join("cool.melon"), b"item");
2207    // A file already under UserData routes via folder + subroute extension.
2208    write(&root.join("UserData/other.melon"), b"item2");
2209
2210    let plan = plan_install(&eco, &profile, "Author-MyMod", root, None).unwrap();
2211    let dests = dest_set(&plan);
2212
2213    assert!(dests.contains(&"UserData/CustomItems/cool.melon".to_string()));
2214    assert!(dests.contains(&"UserData/CustomItems/other.melon".to_string()));
2215  }
2216
2217  #[test]
2218  fn test_northstar_state_routing_and_exclusions() {
2219    let eco = eco();
2220    let profile = profile(&eco, "northstar");
2221
2222    let pkg = tempdir().unwrap();
2223    let root = pkg.path();
2224
2225    write(&root.join("mods/Author.Mod/mod.json"), b"{}");
2226    write(&root.join("manifest.json"), b"{}");
2227
2228    let plan = plan_install(&eco, &profile, "Author-Mod", root, None).unwrap();
2229    let dests = dest_set(&plan);
2230
2231    // mods/ matches the R2Northstar/mods route (state, structure preserved).
2232    assert!(dests.contains(&"R2Northstar/mods/Author.Mod/mod.json".to_string()));
2233    // manifest.json is excluded.
2234    assert!(!dests.iter().any(|d| d.contains("manifest.json")));
2235  }
2236
2237  #[test]
2238  fn test_godotml_package_zip() {
2239    let eco = eco();
2240    let profile = profile(&eco, "brotato");
2241
2242    let pkg = tempdir().unwrap();
2243    let root = pkg.path();
2244    write(&root.join("something.pck"), b"pck");
2245
2246    let archive = pkg.path().join("Author-Mod-1.0.0.zip");
2247    write(&archive, b"zipbytes");
2248
2249    let plan = plan_install(&eco, &profile, "Author-Mod", root, Some(&archive)).unwrap();
2250    let dests = dest_set(&plan);
2251
2252    assert_eq!(dests, vec!["mods/Author-Mod.zip".to_string()]);
2253    assert_eq!(plan.entries[0].tracking, TrackingMethod::PackageZip);
2254
2255    // Without the archive, the package-zip install is skipped rather than wrong.
2256    let empty = plan_install(&eco, &profile, "Author-Mod", root, None).unwrap();
2257    assert!(empty.entries.is_empty());
2258  }
2259
2260  #[test]
2261  fn test_plan_install_rejects_traversal_in_package_name() {
2262    let eco = eco();
2263    let profile = profile(&eco, "valheim");
2264
2265    let pkg = tempdir().unwrap();
2266    let root = pkg.path();
2267
2268    write(&root.join("MyMod.dll"), b"dll");
2269
2270    let result = plan_install(&eco, &profile, "../../etc/Author-Evil", root, None);
2271
2272    assert!(result.is_err());
2273  }
2274
2275  #[test]
2276  fn test_ensure_safe_relative_rejects_unsafe_paths() {
2277    assert!(ensure_safe_relative(Path::new("BepInEx/plugins/Author-Mod/x.dll")).is_ok());
2278    assert!(ensure_safe_relative(Path::new("../escape")).is_err());
2279    assert!(ensure_safe_relative(Path::new("a/../../escape")).is_err());
2280    assert!(ensure_safe_relative(Path::new("/abs/path")).is_err());
2281  }
2282
2283  #[cfg(unix)]
2284  #[test]
2285  fn test_walk_files_skips_symlinks() {
2286    use std::os::unix::fs::symlink;
2287
2288    let dir = tempdir().unwrap();
2289    let root = dir.path();
2290
2291    write(&root.join("real.txt"), b"real");
2292    symlink("/etc/passwd", root.join("link.txt")).unwrap();
2293
2294    let files = walk_files(root).unwrap();
2295
2296    assert!(files.iter().any(|p| p.ends_with("real.txt")));
2297    assert!(!files.iter().any(|p| p.ends_with("link.txt")));
2298  }
2299
2300  #[test]
2301  fn state_tracker_collects_only_state_entries() {
2302    let plan = InstallPlan {
2303      entries: vec![
2304        InstallEntry {
2305          source: PathBuf::from("/cache/Author-Mod/1.0.0/loader.dll"),
2306          dest_relative: PathBuf::from("BepInEx/core/loader.dll"),
2307          tracking: TrackingMethod::State,
2308        },
2309        InstallEntry {
2310          source: PathBuf::from("/cache/Author-Mod/1.0.0/plugin.dll"),
2311          dest_relative: PathBuf::from("BepInEx/plugins/Author-Mod/plugin.dll"),
2312          tracking: TrackingMethod::Subdir,
2313        },
2314      ],
2315    };
2316
2317    let tracker = state_tracker_from_plan(&plan, "Author-Mod").unwrap();
2318
2319    assert_eq!(tracker.mod_name, "Author-Mod");
2320    assert_eq!(tracker.files.len(), 1);
2321    assert_eq!(tracker.files[0].1, "BepInEx/core/loader.dll");
2322  }
2323
2324  #[test]
2325  fn state_tracker_none_when_no_state_entries() {
2326    let plan = InstallPlan {
2327      entries: vec![InstallEntry {
2328        source: PathBuf::from("/cache/x/plugin.dll"),
2329        dest_relative: PathBuf::from("BepInEx/plugins/Author-Mod/plugin.dll"),
2330        tracking: TrackingMethod::Subdir,
2331      }],
2332    };
2333
2334    assert!(state_tracker_from_plan(&plan, "Author-Mod").is_none());
2335  }
2336
2337  #[test]
2338  fn remove_state_payload_deletes_files_then_tracker() {
2339    let dir = tempdir().unwrap();
2340    let root = dir.path();
2341
2342    write(&root.join("BepInEx/core/loader.dll"), b"loader");
2343
2344    let tracker = ModFileTracker {
2345      mod_name: "Author-Loader".to_string(),
2346      files: vec![
2347        (
2348          "/cache/loader.dll".to_string(),
2349          "BepInEx/core/loader.dll".to_string(),
2350        ),
2351        // An already-removed payload (e.g. the mod was disabled first) is skipped.
2352        (
2353          "/cache/gone.dll".to_string(),
2354          "BepInEx/core/gone.dll".to_string(),
2355        ),
2356      ],
2357    };
2358
2359    write_state_file(root, &tracker).unwrap();
2360    remove_state_payload(root, "Author-Loader").unwrap();
2361
2362    assert!(!root.join("BepInEx/core/loader.dll").exists());
2363    assert!(!state_file_path(root, "Author-Loader").exists());
2364
2365    // A mod that tracked no `State` files has no `_state` file: a no-op, not an error.
2366    assert!(remove_state_payload(root, "Author-Untracked").is_ok());
2367  }
2368
2369  #[test]
2370  fn remove_state_payload_rejects_traversal_in_tracker() {
2371    let dir = tempdir().unwrap();
2372    let root = dir.path().join("profile");
2373
2374    let outside = dir.path().join("secret.txt");
2375    write(&outside, b"secret");
2376
2377    let tracker = ModFileTracker {
2378      mod_name: "Author-Evil".to_string(),
2379      files: vec![("/cache/x".to_string(), "../secret.txt".to_string())],
2380    };
2381
2382    write_state_file(&root, &tracker).unwrap();
2383
2384    // A hand-edited tracker cannot delete a file outside the profile root.
2385    assert!(remove_state_payload(&root, "Author-Evil").is_err());
2386    assert!(outside.exists());
2387  }
2388
2389  #[test]
2390  fn state_file_round_trips() {
2391    let dir = tempdir().unwrap();
2392    let tracker = ModFileTracker {
2393      mod_name: "Author-Mod".to_string(),
2394      files: vec![("/cache/a".to_string(), "BepInEx/core/a.dll".to_string())],
2395    };
2396
2397    write_state_file(dir.path(), &tracker).unwrap();
2398
2399    assert!(dir.path().join("_state/Author-Mod-state.yml").exists());
2400
2401    let back = read_state_file(dir.path(), "Author-Mod").unwrap().unwrap();
2402
2403    assert_eq!(back.mod_name, "Author-Mod");
2404    assert_eq!(back.files, tracker.files);
2405    assert!(read_state_file(dir.path(), "Missing").unwrap().is_none());
2406  }
2407
2408  #[test]
2409  fn plan_suffix_renames_adds_and_strips_old() {
2410    let files = vec![
2411      PathBuf::from("a/b/plugin.dll"),
2412      PathBuf::from("a/b/data.cfg.old"),
2413    ];
2414
2415    let disable = plan_suffix_renames(&files, true);
2416
2417    // plugin.dll -> plugin.dll.old ; the already-.old file is left alone (omitted).
2418    assert_eq!(
2419      disable,
2420      vec![(
2421        PathBuf::from("a/b/plugin.dll"),
2422        PathBuf::from("a/b/plugin.dll.old"),
2423      )]
2424    );
2425
2426    let enable = plan_suffix_renames(&files, false);
2427
2428    // data.cfg.old -> data.cfg ; the non-.old file is left alone.
2429    assert_eq!(
2430      enable,
2431      vec![(
2432        PathBuf::from("a/b/data.cfg.old"),
2433        PathBuf::from("a/b/data.cfg"),
2434      )]
2435    );
2436  }
2437
2438  #[test]
2439  fn disable_enable_subdir_renames_files() {
2440    let eco = eco();
2441    let profile = profile(&eco, "valheim");
2442
2443    let profile_dir = tempdir().unwrap();
2444    let root = profile_dir.path();
2445    write(&root.join("BepInEx/plugins/Author-Mod/plugin.dll"), b"x");
2446
2447    disable_mod(&profile, root, "Author-Mod").unwrap();
2448
2449    assert!(!root.join("BepInEx/plugins/Author-Mod/plugin.dll").exists());
2450    assert!(
2451      root
2452        .join("BepInEx/plugins/Author-Mod/plugin.dll.old")
2453        .exists()
2454    );
2455
2456    enable_mod(&profile, root, "Author-Mod").unwrap();
2457
2458    assert!(root.join("BepInEx/plugins/Author-Mod/plugin.dll").exists());
2459  }
2460
2461  #[test]
2462  fn disable_enable_state_removes_and_restores_from_cache() {
2463    let eco = eco();
2464    let profile = profile(&eco, "valheim");
2465
2466    let profile_dir = tempdir().unwrap();
2467    let root = profile_dir.path();
2468
2469    // A cached source file and its installed copy under a State route.
2470    let cache = tempdir().unwrap();
2471    let src = cache.path().join("loader.dll");
2472    write(&src, b"loader");
2473    write(&root.join("BepInEx/core/loader.dll"), b"loader");
2474
2475    let tracker = ModFileTracker {
2476      mod_name: "Author-Loader".to_string(),
2477      files: vec![(
2478        src.to_string_lossy().to_string(),
2479        "BepInEx/core/loader.dll".to_string(),
2480      )],
2481    };
2482    write_state_file(root, &tracker).unwrap();
2483
2484    disable_mod(&profile, root, "Author-Loader").unwrap();
2485
2486    assert!(!root.join("BepInEx/core/loader.dll").exists());
2487
2488    enable_mod(&profile, root, "Author-Loader").unwrap();
2489
2490    assert!(root.join("BepInEx/core/loader.dll").exists());
2491  }
2492}