#![deny(missing_docs)]
use crate::ecosystem::{Ecosystem, GameProfile, InstallRule, ModloaderPackage, TrackingMethod};
use crate::error::{Error, Result};
use crate::util::io_context;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::fs;
use std::path::{Component, Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct ModFileTracker {
pub mod_name: String,
pub files: Vec<(String, String)>,
}
pub fn state_tracker_from_plan(plan: &InstallPlan, mod_name: &str) -> Option<ModFileTracker> {
let files: Vec<(String, String)> = plan
.entries
.iter()
.filter(|e| e.tracking == TrackingMethod::State)
.map(|e| {
(
e.source.to_string_lossy().to_string(),
e.dest_relative.to_string_lossy().replace('\\', "/"),
)
})
.collect();
if files.is_empty() {
return None;
}
Some(ModFileTracker {
mod_name: mod_name.to_string(),
files,
})
}
pub fn state_file_path(profile_dir: &Path, mod_name: &str) -> PathBuf {
profile_dir
.join("_state")
.join(format!("{mod_name}-state.yml"))
}
pub fn write_state_file(profile_dir: &Path, tracker: &ModFileTracker) -> Result<()> {
let path = state_file_path(profile_dir, &tracker.mod_name);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| io_context(format!("creating {}", parent.display()), e))?;
}
let text = serde_yaml_ng::to_string(tracker)
.map_err(|e| Error::Install(format!("serializing state file: {e}")))?;
fs::write(&path, text).map_err(|e| io_context(format!("writing {}", path.display()), e))
}
pub fn read_state_file(profile_dir: &Path, mod_name: &str) -> Result<Option<ModFileTracker>> {
let path = state_file_path(profile_dir, mod_name);
if !path.exists() {
return Ok(None);
}
let text =
fs::read_to_string(&path).map_err(|e| io_context(format!("reading {}", path.display()), e))?;
let tracker = serde_yaml_ng::from_str(&text)
.map_err(|e| Error::Install(format!("parsing {}: {e}", path.display())))?;
Ok(Some(tracker))
}
pub fn remove_state_payload(profile_dir: &Path, mod_name: &str) -> Result<()> {
let tracker = match read_state_file(profile_dir, mod_name)? {
Some(tracker) => tracker,
None => return Ok(()),
};
remove_state_files(profile_dir, &tracker)?;
let path = state_file_path(profile_dir, mod_name);
fs::remove_file(&path).map_err(|e| io_context(format!("removing {}", path.display()), e))
}
fn ensure_safe_relative(dest: &Path) -> Result<()> {
for component in dest.components() {
match component {
Component::Normal(_) | Component::CurDir => {}
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
return Err(Error::Install(format!(
"unsafe install path escapes the install root: {}",
dest.display()
)));
}
}
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct InstallEntry {
pub source: PathBuf,
pub dest_relative: PathBuf,
pub tracking: TrackingMethod,
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct InstallPlan {
pub entries: Vec<InstallEntry>,
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct FileManifest {
pub written: Vec<PathBuf>,
}
pub fn plan_install(
ecosystem: &Ecosystem,
profile: &GameProfile,
package_full_name: &str,
extracted_root: &Path,
archive_path: Option<&Path>,
) -> Result<InstallPlan> {
if let Some(pack) = ecosystem.modloader_package(package_full_name) {
return plan_loader_pack(pack, profile, extracted_root);
}
plan_regular_mod(profile, package_full_name, extracted_root, archive_path)
}
fn plan_loader_pack(
pack: &ModloaderPackage,
profile: &GameProfile,
extracted_root: &Path,
) -> Result<InstallPlan> {
let base = match extracted_root.join(&pack.root_folder) {
nested if !pack.root_folder.is_empty() && nested.is_dir() => nested,
_ => extracted_root.to_path_buf(),
};
let mut untracked = Vec::new();
collect_routes(
&profile.install_rules,
Path::new(""),
&|rule| rule.tracking_method == TrackingMethod::None,
&mut untracked,
);
let mut entries = Vec::new();
for file in walk_files(&base)? {
let rel = file.strip_prefix(&base).unwrap_or(&file).to_path_buf();
if let Some(tracking) =
classify_loader_file(&rel, &untracked, &profile.relative_file_exclusions)?
{
entries.push(InstallEntry {
source: file,
dest_relative: rel,
tracking,
});
}
}
Ok(InstallPlan { entries })
}
fn classify_loader_file(
rel: &Path,
untracked: &[PathBuf],
exclusions: &[String],
) -> Result<Option<TrackingMethod>> {
if is_package_metadata(rel) {
return Ok(None);
}
if let Some(name) = rel.file_name().and_then(|n| n.to_str())
&& exclusions.iter().any(|e| e == name)
{
return Ok(None);
}
ensure_safe_relative(rel)?;
let tracking = if untracked.iter().any(|route| rel.starts_with(route)) {
TrackingMethod::None
} else {
TrackingMethod::State
};
Ok(Some(tracking))
}
fn plan_regular_mod(
profile: &GameProfile,
ident: &str,
extracted_root: &Path,
archive_path: Option<&Path>,
) -> Result<InstallPlan> {
let mut flat = Vec::new();
flatten_rules(&profile.install_rules, Path::new(""), &mut flat);
if let Some(rule) = flat
.iter()
.find(|r| r.rule.is_default_location && r.rule.tracking_method == TrackingMethod::PackageZip)
{
return plan_package_zip(&rule.full_route, ident, archive_path);
}
let mut entries = Vec::new();
for file in walk_files(extracted_root)? {
let rel = file
.strip_prefix(extracted_root)
.unwrap_or(&file)
.to_path_buf();
match route_file(profile, &flat, ident, &rel)? {
RouteOutcome::Route {
dest_relative,
tracking,
} => entries.push(InstallEntry {
source: file,
dest_relative,
tracking,
}),
RouteOutcome::Skip(reason) => log_route_skip(&rel, reason),
}
}
Ok(InstallPlan { entries })
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum RouteOutcome {
Route {
dest_relative: PathBuf,
tracking: TrackingMethod,
},
Skip(SkipReason),
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum SkipReason {
NoName,
Excluded,
NoRoute,
UnsupportedTracking(TrackingMethod),
}
fn route_file(
profile: &GameProfile,
flat: &[FlatRule<'_>],
ident: &str,
rel: &Path,
) -> Result<RouteOutcome> {
let name = match rel.file_name().and_then(|n| n.to_str()) {
Some(n) => n,
None => return Ok(RouteOutcome::Skip(SkipReason::NoName)),
};
if profile.relative_file_exclusions.iter().any(|e| e == name) {
return Ok(RouteOutcome::Skip(SkipReason::Excluded));
}
let resolution = resolve_dir(&profile.install_rules, Path::new(""), rel)
.or_else(|| loose_file_route(flat, name))
.or_else(|| default_route(flat, rel));
let RouteResolution {
route,
tracking,
remainder,
} = match resolution {
Some(resolved) => resolved,
None => return Ok(RouteOutcome::Skip(SkipReason::NoRoute)),
};
let dest = match build_dest(&route, tracking, ident, &remainder) {
Some(dest) => dest,
None => {
return Ok(RouteOutcome::Skip(SkipReason::UnsupportedTracking(
tracking,
)));
}
};
ensure_safe_relative(&dest)?;
Ok(RouteOutcome::Route {
dest_relative: dest,
tracking,
})
}
fn log_route_skip(rel: &Path, reason: SkipReason) {
match reason {
SkipReason::NoRoute => tracing::warn!("no install route for {}; skipping", rel.display()),
SkipReason::UnsupportedTracking(tracking) => tracing::warn!(
"unsupported tracking {:?} for {}; skipping",
tracking,
rel.display()
),
SkipReason::Excluded | SkipReason::NoName => {}
}
}
fn plan_package_zip(route: &Path, ident: &str, archive_path: Option<&Path>) -> Result<InstallPlan> {
let archive = match archive_path {
Some(archive) => archive,
None => {
tracing::warn!("package-zip loader requires the original archive; skipping install");
return Ok(InstallPlan::default());
}
};
let dest = route.join(format!("{ident}.zip"));
ensure_safe_relative(&dest)?;
Ok(InstallPlan {
entries: vec![InstallEntry {
source: archive.to_path_buf(),
dest_relative: dest,
tracking: TrackingMethod::PackageZip,
}],
})
}
struct FlatRule<'a> {
full_route: PathBuf,
rule: &'a InstallRule,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct RouteResolution {
route: PathBuf,
tracking: TrackingMethod,
remainder: PathBuf,
}
fn flatten_rules<'a>(rules: &'a [InstallRule], parent: &Path, out: &mut Vec<FlatRule<'a>>) {
for rule in rules {
let full = join_route(parent, &rule.route);
out.push(FlatRule {
full_route: full.clone(),
rule,
});
if !rule.sub_routes.is_empty() {
flatten_rules(&rule.sub_routes, &full, out);
}
}
}
fn resolve_dir(rules: &[InstallRule], parent_full: &Path, rel: &Path) -> Option<RouteResolution> {
let mut comps = rel.components();
let first = comps.next()?.as_os_str().to_str()?;
let rest = comps.as_path();
for rule in rules {
if !route_leaf(&rule.route).eq_ignore_ascii_case(first) {
continue;
}
let full = join_route(parent_full, &rule.route);
if !rule.sub_routes.is_empty() && rest.components().next().is_some() {
if let Some(found) = resolve_dir(&rule.sub_routes, &full, rest) {
return Some(found);
}
if rest.components().count() == 1
&& let Some(name) = rest.file_name().and_then(|n| n.to_str())
{
let mut sub_flat = Vec::new();
flatten_rules(&rule.sub_routes, &full, &mut sub_flat);
if let Some(matched) = best_extension(&sub_flat, name) {
return Some(RouteResolution {
route: matched.full_route.clone(),
tracking: matched.rule.tracking_method,
remainder: PathBuf::from(name),
});
}
}
}
return Some(RouteResolution {
route: full,
tracking: rule.tracking_method,
remainder: rest.to_path_buf(),
});
}
None
}
fn loose_file_route(flat: &[FlatRule<'_>], name: &str) -> Option<RouteResolution> {
let matched = best_extension(flat, name)?;
Some(RouteResolution {
route: matched.full_route.clone(),
tracking: matched.rule.tracking_method,
remainder: PathBuf::from(name),
})
}
fn default_route(flat: &[FlatRule<'_>], rel: &Path) -> Option<RouteResolution> {
let matched = flat.iter().find(|r| r.rule.is_default_location)?;
Some(RouteResolution {
route: matched.full_route.clone(),
tracking: matched.rule.tracking_method,
remainder: rel.to_path_buf(),
})
}
fn best_extension<'a>(flat: &'a [FlatRule<'a>], name: &str) -> Option<&'a FlatRule<'a>> {
flat
.iter()
.filter_map(|fr| {
fr.rule
.default_file_extensions
.iter()
.filter(|ext| name.ends_with(ext.as_str()))
.map(|ext| ext.len())
.max()
.map(|len| (len, fr))
})
.max_by_key(|(len, _)| *len)
.map(|(_, fr)| fr)
}
fn build_dest(
route: &Path,
tracking: TrackingMethod,
ident: &str,
remainder: &Path,
) -> Option<PathBuf> {
match tracking {
TrackingMethod::Subdir => remainder
.file_name()
.map(|name| route.join(ident).join(name)),
TrackingMethod::SubdirNoFlatten => Some(route.join(ident).join(remainder)),
TrackingMethod::None | TrackingMethod::State => Some(route.join(remainder)),
TrackingMethod::PackageZip | TrackingMethod::Other => None,
}
}
fn route_leaf(route: &str) -> &str {
route.rsplit(['/', '\\']).next().unwrap_or(route)
}
fn join_route(parent_full: &Path, child_route: &str) -> PathBuf {
let child = PathBuf::from(child_route);
if parent_full.as_os_str().is_empty() {
child
} else {
parent_full.join(child)
}
}
fn is_package_metadata(rel: &Path) -> bool {
if rel.components().count() != 1 {
return false;
}
match rel.file_name().and_then(|n| n.to_str()) {
Some(name) => {
let lower = name.to_ascii_lowercase();
lower == "manifest.json" || lower == "icon.png" || lower == "readme.md"
}
None => false,
}
}
fn collect_routes<F: Fn(&InstallRule) -> bool>(
rules: &[InstallRule],
parent: &Path,
pred: &F,
out: &mut Vec<PathBuf>,
) {
for rule in rules {
let full = join_route(parent, &rule.route);
if pred(rule) {
out.push(full.clone());
}
if !rule.sub_routes.is_empty() {
collect_routes(&rule.sub_routes, &full, pred, out);
}
}
}
pub fn apply_install(plan: &InstallPlan, root: &Path) -> Result<FileManifest> {
let mut written = Vec::with_capacity(plan.entries.len());
let mut created: Vec<PathBuf> = Vec::new();
let mut created_dirs: Vec<PathBuf> = Vec::new();
for entry in &plan.entries {
if let Err(err) = ensure_safe_relative(&entry.dest_relative) {
rollback_partial_install(&created, &created_dirs);
return Err(err);
}
let dest = root.join(&entry.dest_relative);
let dest_is_symlink = fs::symlink_metadata(&dest)
.map(|metadata| metadata.file_type().is_symlink())
.unwrap_or(false);
match classify_entry(entry.tracking, dest.exists(), dest_is_symlink) {
ApplyAction::SkipExistingConfig => continue,
ApplyAction::RefuseSymlink => {
rollback_partial_install(&created, &created_dirs);
return Err(Error::Install(format!(
"refusing to install through a symlink at {}",
dest.display()
)));
}
ApplyAction::Write { record } => {
if let Err(err) = copy_entry(entry, &dest, root, &mut created_dirs) {
rollback_partial_install(&created, &created_dirs);
return Err(err);
}
created.push(dest);
if record {
written.push(entry.dest_relative.clone());
}
}
}
}
Ok(FileManifest { written })
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ApplyAction {
Write { record: bool },
SkipExistingConfig,
RefuseSymlink,
}
fn classify_entry(tracking: TrackingMethod, exists: bool, is_symlink: bool) -> ApplyAction {
if tracking == TrackingMethod::None && (exists || is_symlink) {
return ApplyAction::SkipExistingConfig;
}
if is_symlink {
return ApplyAction::RefuseSymlink;
}
ApplyAction::Write {
record: tracking != TrackingMethod::None,
}
}
fn copy_entry(
entry: &InstallEntry,
dest: &Path,
root: &Path,
created_dirs: &mut Vec<PathBuf>,
) -> Result<()> {
if let Some(parent) = dest.parent() {
record_created_dirs(parent, root, created_dirs);
fs::create_dir_all(parent).map_err(|e| {
Error::Install(format!(
"failed to create install directory {}: {}",
parent.display(),
e
))
})?;
}
fs::copy(&entry.source, dest).map_err(|e| {
Error::Install(format!(
"failed to copy {} to {}: {}",
entry.source.display(),
dest.display(),
e
))
})?;
Ok(())
}
fn record_created_dirs(dir: &Path, root: &Path, created_dirs: &mut Vec<PathBuf>) {
let mut missing = Vec::new();
let mut current = dir;
while current != root && current.starts_with(root) && !current.exists() {
missing.push(current.to_path_buf());
match current.parent() {
Some(parent) => current = parent,
None => break,
}
}
for path in missing.into_iter().rev() {
created_dirs.push(path);
}
}
fn rollback_partial_install(created: &[PathBuf], created_dirs: &[PathBuf]) {
for path in created.iter().rev() {
if let Err(err) = fs::remove_file(path) {
tracing::warn!(
"failed to roll back partially-installed file {}: {}",
path.display(),
err
);
}
}
for dir in created_dirs.iter().rev() {
if let Err(err) = fs::remove_dir(dir) {
tracing::warn!(
"failed to roll back partially-created directory {}: {}",
dir.display(),
err
);
}
}
}
pub fn plan_delisted(
profile: &GameProfile,
root: &Path,
keep: &BTreeSet<String>,
) -> Result<Vec<PathBuf>> {
let mut routes = Vec::new();
collect_routes(
&profile.install_rules,
Path::new(""),
&|rule| rule.tracking_method.is_namespaced(),
&mut routes,
);
let mut planned = BTreeSet::new();
for route in routes {
ensure_safe_relative(&route)?;
let dir = root.join(&route);
if !dir.is_dir() {
continue;
}
let mut listing = Vec::new();
for entry in fs::read_dir(&dir)
.map_err(|e| io_context(format!("reading install route {}", dir.display()), e))?
{
let path = entry
.map_err(|e| io_context(format!("reading an entry under {}", dir.display()), e))?
.path();
let metadata = fs::symlink_metadata(&path)
.map_err(|e| io_context(format!("inspecting {}", path.display()), e))?;
listing.push((path, metadata.is_dir(), metadata.file_type().is_symlink()));
}
for path in plan_reconcile(&listing, keep) {
planned.insert(path.strip_prefix(root).unwrap_or(&path).to_path_buf());
}
}
Ok(planned.into_iter().collect())
}
pub fn remove_delisted(
profile: &GameProfile,
root: &Path,
keep: &BTreeSet<String>,
) -> Result<Vec<PathBuf>> {
let planned = plan_delisted(profile, root, keep)?;
for relative in &planned {
let path = root.join(relative);
fs::remove_dir_all(&path)
.map_err(|e| io_context(format!("removing delisted mod {}", path.display()), e))?;
}
Ok(planned)
}
fn plan_reconcile(listing: &[(PathBuf, bool, bool)], keep: &BTreeSet<String>) -> Vec<PathBuf> {
listing
.iter()
.filter(|(path, is_dir, is_symlink)| {
if *is_symlink || !*is_dir {
return false;
}
match path.file_name().and_then(|n| n.to_str()) {
Some(ident) => looks_like_mod_ident(ident) && !keep.contains(ident),
None => false,
}
})
.map(|(path, _, _)| path.clone())
.collect()
}
fn looks_like_mod_ident(name: &str) -> bool {
name.contains('-')
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}
fn walk_files(dir: &Path) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(current) = stack.pop() {
for entry in fs::read_dir(¤t)
.map_err(|e| io_context(format!("reading package dir {}", current.display()), e))?
{
let entry = entry
.map_err(|e| io_context(format!("reading an entry under {}", current.display()), e))?;
let path = entry.path();
let metadata = fs::symlink_metadata(&path)
.map_err(|e| io_context(format!("inspecting {}", path.display()), e))?;
if metadata.file_type().is_symlink() {
tracing::warn!("Skipping symlink in package contents: {}", path.display());
continue;
}
if metadata.is_dir() {
stack.push(path);
} else {
files.push(path);
}
}
}
files.sort();
Ok(files)
}
pub fn plan_suffix_renames(files: &[PathBuf], disable: bool) -> Vec<(PathBuf, PathBuf)> {
let mut renames = Vec::new();
for file in files {
let name = file.to_string_lossy();
let is_old = name.to_lowercase().ends_with(".old");
if disable && !is_old {
renames.push((file.clone(), PathBuf::from(format!("{name}.old"))));
} else if !disable && is_old {
renames.push((file.clone(), PathBuf::from(&name[..name.len() - 4])));
}
}
renames
}
pub fn disable_mod(profile: &GameProfile, profile_dir: &Path, mod_name: &str) -> Result<()> {
let tracker = read_state_file(profile_dir, mod_name)?;
toggle_subdir_files(profile, profile_dir, mod_name, true)?;
toggle_state_files(profile_dir, tracker, true)
}
pub fn enable_mod(profile: &GameProfile, profile_dir: &Path, mod_name: &str) -> Result<()> {
let tracker = read_state_file(profile_dir, mod_name)?;
toggle_subdir_files(profile, profile_dir, mod_name, false)?;
toggle_state_files(profile_dir, tracker, false)
}
fn toggle_subdir_files(
profile: &GameProfile,
profile_dir: &Path,
mod_name: &str,
disable: bool,
) -> Result<()> {
let mut routes = Vec::new();
collect_routes(
&profile.install_rules,
Path::new(""),
&|rule| rule.tracking_method.is_namespaced(),
&mut routes,
);
for route in routes {
let mod_dir = profile_dir.join(&route).join(mod_name);
if !mod_dir.is_dir() {
continue;
}
let files = walk_files(&mod_dir)?;
for (from, to) in plan_suffix_renames(&files, disable) {
fs::rename(&from, &to).map_err(|e| io_context(format!("toggling {}", from.display()), e))?;
}
}
Ok(())
}
fn toggle_state_files(
profile_dir: &Path,
tracker: Option<ModFileTracker>,
disable: bool,
) -> Result<()> {
let Some(tracker) = tracker else {
return Ok(());
};
if disable {
return remove_state_files(profile_dir, &tracker);
}
restore_state_files(profile_dir, &tracker)
}
fn remove_state_files(profile_dir: &Path, tracker: &ModFileTracker) -> Result<()> {
for (_, install_rel) in &tracker.files {
ensure_safe_relative(Path::new(install_rel))?;
let dest = profile_dir.join(install_rel);
if !dest.exists() {
continue;
}
fs::remove_file(&dest).map_err(|e| io_context(format!("removing {}", dest.display()), e))?;
}
Ok(())
}
fn restore_state_files(profile_dir: &Path, tracker: &ModFileTracker) -> Result<()> {
for (source, install_rel) in &tracker.files {
let dest = profile_dir.join(install_rel);
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)
.map_err(|e| io_context(format!("creating {}", parent.display()), e))?;
}
fs::copy(source, &dest)
.map_err(|e| io_context(format!("restoring {} from {}", dest.display(), source), e))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ecosystem::{Ecosystem, GameProfile};
use std::fs::File;
use std::io::Write;
use tempfile::tempdir;
fn eco() -> Ecosystem {
Ecosystem::bundled()
}
fn profile(eco: &Ecosystem, slug: &str) -> GameProfile {
eco.game(slug).unwrap().profile().unwrap().clone()
}
fn write(path: &Path, contents: &[u8]) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
let mut f = File::create(path).unwrap();
f.write_all(contents).unwrap();
}
fn dest_set(plan: &InstallPlan) -> Vec<String> {
let mut v: Vec<String> = plan
.entries
.iter()
.map(|e| e.dest_relative.to_string_lossy().replace('\\', "/"))
.collect();
v.sort();
v
}
#[test]
fn test_bepinex_regular_mod_routing() {
let eco = eco();
let profile = profile(&eco, "valheim");
let pkg = tempdir().unwrap();
let root = pkg.path();
write(&root.join("MyMod.dll"), b"dll");
write(&root.join("manifest.json"), b"{}");
write(&root.join("config/MyMod.cfg"), b"cfg");
write(&root.join("patchers/MyPatcher.dll"), b"patch");
write(&root.join("MyHook.mm.dll"), b"hook");
let plan = plan_install(&eco, &profile, "Author-MyMod", root, None).unwrap();
let dests = dest_set(&plan);
assert!(dests.contains(&"BepInEx/plugins/Author-MyMod/MyMod.dll".to_string()));
assert!(dests.contains(&"BepInEx/plugins/Author-MyMod/manifest.json".to_string()));
assert!(dests.contains(&"BepInEx/config/MyMod.cfg".to_string()));
assert!(dests.contains(&"BepInEx/patchers/Author-MyMod/MyPatcher.dll".to_string()));
assert!(dests.contains(&"BepInEx/monomod/Author-MyMod/MyHook.mm.dll".to_string()));
}
#[test]
fn route_file_routes_by_table_without_disk() {
let eco = eco();
let profile = profile(&eco, "valheim");
let mut flat = Vec::new();
flatten_rules(&profile.install_rules, Path::new(""), &mut flat);
let ident = "Author-MyMod";
match route_file(&profile, &flat, ident, Path::new("MyMod.dll")).unwrap() {
RouteOutcome::Route {
dest_relative,
tracking,
} => {
assert_eq!(
dest_relative,
PathBuf::from("BepInEx/plugins/Author-MyMod/MyMod.dll")
);
assert_eq!(tracking, TrackingMethod::Subdir);
}
other => panic!("expected a route, got {other:?}"),
}
match route_file(&profile, &flat, ident, Path::new("MyHook.mm.dll")).unwrap() {
RouteOutcome::Route { dest_relative, .. } => assert_eq!(
dest_relative,
PathBuf::from("BepInEx/monomod/Author-MyMod/MyHook.mm.dll")
),
other => panic!("expected a route, got {other:?}"),
}
match route_file(&profile, &flat, ident, Path::new("config/MyMod.cfg")).unwrap() {
RouteOutcome::Route {
dest_relative,
tracking,
} => {
assert_eq!(dest_relative, PathBuf::from("BepInEx/config/MyMod.cfg"));
assert_eq!(tracking, TrackingMethod::None);
}
other => panic!("expected a route, got {other:?}"),
}
assert_eq!(
route_file(&profile, &flat, ident, Path::new("")).unwrap(),
RouteOutcome::Skip(SkipReason::NoName)
);
}
#[test]
fn route_leaf_handles_both_separators() {
assert_eq!(route_leaf("BepInEx/plugins"), "plugins");
assert_eq!(route_leaf("BepInEx\\plugins"), "plugins");
assert_eq!(route_leaf("plugins"), "plugins");
}
#[test]
fn classify_entry_truth_table() {
assert_eq!(
classify_entry(TrackingMethod::None, true, false),
ApplyAction::SkipExistingConfig
);
assert_eq!(
classify_entry(TrackingMethod::None, false, true),
ApplyAction::SkipExistingConfig
);
assert_eq!(
classify_entry(TrackingMethod::None, false, false),
ApplyAction::Write { record: false }
);
assert_eq!(
classify_entry(TrackingMethod::Subdir, false, true),
ApplyAction::RefuseSymlink
);
assert_eq!(
classify_entry(TrackingMethod::State, true, false),
ApplyAction::Write { record: true }
);
assert_eq!(
classify_entry(TrackingMethod::SubdirNoFlatten, false, false),
ApplyAction::Write { record: true }
);
}
#[test]
fn plan_reconcile_selects_only_unwanted_mod_dirs() {
let keep: BTreeSet<String> = ["Author-Keep".to_string()].into_iter().collect();
let listing = vec![
(PathBuf::from("Author-Keep"), true, false), (PathBuf::from("Author-Drop"), true, false), (PathBuf::from("MMHOOK"), true, false), (PathBuf::from("Author-Link"), true, true), (PathBuf::from("Author-File"), false, false), ];
let removable = plan_reconcile(&listing, &keep);
assert_eq!(removable, vec![PathBuf::from("Author-Drop")]);
}
#[test]
fn classify_loader_file_drops_metadata_excluded_and_routes_rest() {
let untracked = vec![PathBuf::from("BepInEx/config")];
let exclusions = vec!["LICENCE".to_string()];
assert_eq!(
classify_loader_file(Path::new("manifest.json"), &untracked, &exclusions).unwrap(),
None
);
assert_eq!(
classify_loader_file(Path::new("LICENCE"), &untracked, &exclusions).unwrap(),
None
);
assert_eq!(
classify_loader_file(Path::new("BepInEx/config/x.cfg"), &untracked, &exclusions).unwrap(),
Some(TrackingMethod::None)
);
assert_eq!(
classify_loader_file(
Path::new("BepInEx/core/loader.dll"),
&untracked,
&exclusions
)
.unwrap(),
Some(TrackingMethod::State)
);
assert!(classify_loader_file(Path::new("../evil"), &untracked, &exclusions).is_err());
}
#[test]
fn apply_install_empty_plan_writes_nothing() {
let root = tempdir().unwrap();
let manifest = apply_install(&InstallPlan::default(), root.path()).unwrap();
assert!(manifest.written.is_empty());
}
#[test]
fn test_subdir_tracking_flattens_nested_file() {
let eco = eco();
let profile = profile(&eco, "valheim");
let pkg = tempdir().unwrap();
let root = pkg.path();
write(&root.join("plugins/nested/Deep.dll"), b"deep");
let plan = plan_install(&eco, &profile, "Author-MyMod", root, None).unwrap();
let dests = dest_set(&plan);
assert!(dests.contains(&"BepInEx/plugins/Author-MyMod/Deep.dll".to_string()));
assert!(!dests.iter().any(|d| d.contains("nested")));
}
#[test]
fn test_subdir_no_flatten_preserves_subpath() {
let eco = eco();
let profile = profile(&eco, "rumble");
let pkg = tempdir().unwrap();
let root = pkg.path();
write(&root.join("data/nested/asset.bin"), b"asset");
let plan = plan_install(&eco, &profile, "Author-Mod", root, None).unwrap();
let dests = dest_set(&plan);
assert!(dests.contains(&"UserData/ModManager/Author-Mod/data/nested/asset.bin".to_string()));
}
#[test]
fn test_extension_routing_applies_to_nested_files() {
let eco = eco();
let profile = profile(&eco, "valheim");
let pkg = tempdir().unwrap();
let root = pkg.path();
write(&root.join("Wrapper/Hook.mm.dll"), b"hook");
let plan = plan_install(&eco, &profile, "Author-MyMod", root, None).unwrap();
let dests = dest_set(&plan);
assert!(dests.contains(&"BepInEx/monomod/Author-MyMod/Hook.mm.dll".to_string()));
assert!(!dests.iter().any(|d| d.contains("Wrapper")));
}
#[test]
fn test_loader_pack_unpacks_to_root_stripping_nested_subfolder() {
let eco = eco();
let profile = profile(&eco, "valheim");
let pkg = tempdir().unwrap();
let root = pkg.path();
write(&root.join("BepInExPack_Valheim/winhttp.dll"), b"proxy");
write(
&root.join("BepInExPack_Valheim/BepInEx/core/BepInEx.dll"),
b"core",
);
write(&root.join("manifest.json"), b"{}");
write(&root.join("icon.png"), b"png");
let plan = plan_install(&eco, &profile, "denikson-BepInExPack_Valheim", root, None).unwrap();
let dests = dest_set(&plan);
assert!(dests.contains(&"winhttp.dll".to_string()));
assert!(dests.contains(&"BepInEx/core/BepInEx.dll".to_string()));
assert!(!dests.iter().any(|d| d.contains("manifest.json")));
assert!(!dests.iter().any(|d| d.contains("BepInExPack_Valheim")));
assert!(!dests.iter().any(|d| d.contains("icon.png")));
}
#[test]
fn test_loader_pack_preserves_config_on_reinstall() {
let eco = eco();
let profile = profile(&eco, "valheim");
let pkg = tempdir().unwrap();
let pkg_root = pkg.path();
write(&pkg_root.join("BepInExPack_Valheim/winhttp.dll"), b"proxy");
write(
&pkg_root.join("BepInExPack_Valheim/BepInEx/config/BepInEx.cfg"),
b"default",
);
let profile_dir = tempdir().unwrap();
let root = profile_dir.path();
let plan = plan_install(
&eco,
&profile,
"denikson-BepInExPack_Valheim",
pkg_root,
None,
)
.unwrap();
apply_install(&plan, root).unwrap();
let cfg = root.join("BepInEx/config/BepInEx.cfg");
write(&cfg, b"user-edited");
let plan = plan_install(
&eco,
&profile,
"denikson-BepInExPack_Valheim",
pkg_root,
None,
)
.unwrap();
apply_install(&plan, root).unwrap();
assert_eq!(fs::read_to_string(&cfg).unwrap(), "user-edited");
assert!(root.join("winhttp.dll").exists());
}
#[test]
fn test_loader_pack_without_nesting_falls_back_to_root() {
let eco = eco();
let profile = profile(&eco, "valheim");
let pkg = tempdir().unwrap();
let root = pkg.path();
write(&root.join("winhttp.dll"), b"proxy");
let plan = plan_install(&eco, &profile, "denikson-BepInExPack_Valheim", root, None).unwrap();
let dests = dest_set(&plan);
assert!(dests.contains(&"winhttp.dll".to_string()));
}
#[test]
fn test_loader_pack_skips_relative_file_exclusions() {
let eco = eco();
let profile = profile(&eco, "boneworks");
let pkg = tempdir().unwrap();
let root = pkg.path();
write(&root.join("winhttp.dll"), b"proxy");
write(&root.join("LICENCE"), b"license text");
let plan = plan_install(&eco, &profile, "denikson-BepInExPack_Valheim", root, None).unwrap();
let dests = dest_set(&plan);
assert!(dests.contains(&"winhttp.dll".to_string()));
assert!(!dests.iter().any(|d| d.contains("LICENCE")));
}
#[test]
fn test_config_is_preserved_across_reinstall() {
let eco = eco();
let profile = profile(&eco, "valheim");
let pkg = tempdir().unwrap();
let pkg_root = pkg.path();
write(&pkg_root.join("MyMod.dll"), b"dll");
write(&pkg_root.join("config/MyMod.cfg"), b"default-config");
let profile_dir = tempdir().unwrap();
let root = profile_dir.path();
let plan = plan_install(&eco, &profile, "Author-MyMod", pkg_root, None).unwrap();
let first = apply_install(&plan, root).unwrap();
assert_eq!(first.written.len(), 1);
assert!(root.join("BepInEx/config/MyMod.cfg").exists());
let cfg = root.join("BepInEx/config/MyMod.cfg");
write(&cfg, b"user-edited");
let plan = plan_install(&eco, &profile, "Author-MyMod", pkg_root, None).unwrap();
let second = apply_install(&plan, root).unwrap();
assert_eq!(fs::read_to_string(&cfg).unwrap(), "user-edited");
assert!(second.written.iter().any(|p| p.ends_with("MyMod.dll")));
assert!(
!second
.written
.iter()
.any(|p| p.to_string_lossy().contains("MyMod.cfg"))
);
}
#[test]
fn test_apply_and_manifest_roundtrip() {
let eco = eco();
let profile = profile(&eco, "valheim");
let pkg = tempdir().unwrap();
let pkg_root = pkg.path();
write(&pkg_root.join("MyMod.dll"), b"dll");
write(&pkg_root.join("config/MyMod.cfg"), b"cfg");
let plan = plan_install(&eco, &profile, "Author-MyMod", pkg_root, None).unwrap();
let profile_dir = tempdir().unwrap();
let root = profile_dir.path();
let manifest = apply_install(&plan, root).unwrap();
assert_eq!(manifest.written.len(), 1);
assert!(root.join("BepInEx/plugins/Author-MyMod/MyMod.dll").exists());
assert!(root.join("BepInEx/config/MyMod.cfg").exists());
for rel in &manifest.written {
fs::remove_file(root.join(rel)).unwrap();
}
assert!(!root.join("BepInEx/plugins/Author-MyMod/MyMod.dll").exists());
}
#[test]
fn test_apply_install_rolls_back_on_failure() {
let src = tempdir().unwrap();
let good = src.path().join("good.dll");
write(&good, b"good");
let plan = InstallPlan {
entries: vec![
InstallEntry {
source: good,
dest_relative: PathBuf::from("BepInEx/plugins/Author-Mod/good.dll"),
tracking: TrackingMethod::State,
},
InstallEntry {
source: src.path().join("does_not_exist.dll"),
dest_relative: PathBuf::from("BepInEx/plugins/Author-Mod/missing.dll"),
tracking: TrackingMethod::State,
},
],
};
let profile_dir = tempdir().unwrap();
let root = profile_dir.path();
let result = apply_install(&plan, root);
assert!(result.is_err());
assert!(!root.join("BepInEx/plugins/Author-Mod/good.dll").exists());
assert!(!root.join("BepInEx/plugins/Author-Mod").exists());
assert!(!root.join("BepInEx/plugins").exists());
assert!(!root.join("BepInEx").exists());
assert!(root.exists());
}
#[test]
#[cfg(unix)]
fn test_apply_install_preserves_symlinked_shared_config() {
use std::os::unix::fs::symlink;
let src = tempdir().unwrap();
let source = src.path().join("mod.cfg");
write(&source, b"packaged-default");
let outside = tempdir().unwrap();
let user_config = outside.path().join("user.cfg");
write(&user_config, b"user-edited");
let profile_dir = tempdir().unwrap();
let root = profile_dir.path();
let dest_relative = PathBuf::from("BepInEx/config/mod.cfg");
let dest = root.join(&dest_relative);
fs::create_dir_all(dest.parent().unwrap()).unwrap();
symlink(&user_config, &dest).unwrap();
let plan = InstallPlan {
entries: vec![InstallEntry {
source,
dest_relative,
tracking: TrackingMethod::None,
}],
};
let manifest = apply_install(&plan, root).unwrap();
assert!(manifest.written.is_empty());
assert_eq!(fs::read_to_string(&user_config).unwrap(), "user-edited");
}
#[test]
#[cfg(unix)]
fn test_apply_install_refuses_to_write_through_symlink() {
use std::os::unix::fs::symlink;
let src = tempdir().unwrap();
let source = src.path().join("mod.cfg");
write(&source, b"new-content");
let outside = tempdir().unwrap();
let secret = outside.path().join("secret.txt");
write(&secret, b"original");
let profile_dir = tempdir().unwrap();
let root = profile_dir.path();
let dest_relative = PathBuf::from("BepInEx/config/mod.cfg");
let dest = root.join(&dest_relative);
fs::create_dir_all(dest.parent().unwrap()).unwrap();
symlink(&secret, &dest).unwrap();
let plan = InstallPlan {
entries: vec![InstallEntry {
source,
dest_relative,
tracking: TrackingMethod::State,
}],
};
let result = apply_install(&plan, root);
assert!(result.is_err());
assert_eq!(fs::read_to_string(&secret).unwrap(), "original");
}
#[test]
fn test_remove_delisted_removes_only_unwanted_mod_folders() {
let eco = eco();
let profile = profile(&eco, "valheim");
let profile_dir = tempdir().unwrap();
let root = profile_dir.path();
write(&root.join("BepInEx/plugins/Owner-ModA/A.dll"), b"a");
write(&root.join("BepInEx/plugins/Owner-ModB/B.dll"), b"b");
write(&root.join("BepInEx/core/BepInEx.dll"), b"loader");
write(&root.join("BepInEx/config/BepInEx.cfg"), b"cfg");
let keep: BTreeSet<String> = ["Owner-ModA".to_string()].into_iter().collect();
let removed = remove_delisted(&profile, root, &keep).unwrap();
assert!(root.join("BepInEx/plugins/Owner-ModA/A.dll").exists());
assert!(!root.join("BepInEx/plugins/Owner-ModB").exists());
assert!(root.join("BepInEx/core/BepInEx.dll").exists());
assert!(root.join("BepInEx/config/BepInEx.cfg").exists());
assert_eq!(removed.len(), 1);
}
#[test]
fn plan_delisted_lists_what_remove_delisted_would_take_without_taking_it() {
let eco = eco();
let profile = profile(&eco, "valheim");
let profile_dir = tempdir().unwrap();
let root = profile_dir.path();
write(&root.join("BepInEx/plugins/Owner-ModA/A.dll"), b"a");
write(&root.join("BepInEx/plugins/Owner-ModB/B.dll"), b"b");
write(&root.join("BepInEx/plugins/Owner-ModC/C.dll"), b"c");
write(&root.join("BepInEx/plugins/MMHOOK/hook.dll"), b"hook");
let keep: BTreeSet<String> = ["Owner-ModA".to_string()].into_iter().collect();
let planned = plan_delisted(&profile, root, &keep).unwrap();
assert_eq!(
planned,
vec![
PathBuf::from("BepInEx/plugins/Owner-ModB"),
PathBuf::from("BepInEx/plugins/Owner-ModC"),
]
);
assert!(root.join("BepInEx/plugins/Owner-ModB/B.dll").exists());
assert!(root.join("BepInEx/plugins/Owner-ModC/C.dll").exists());
let removed = remove_delisted(&profile, root, &keep).unwrap();
assert_eq!(removed, planned);
assert!(!root.join("BepInEx/plugins/Owner-ModB").exists());
assert!(!root.join("BepInEx/plugins/Owner-ModC").exists());
assert!(root.join("BepInEx/plugins/Owner-ModA/A.dll").exists());
assert!(root.join("BepInEx/plugins/MMHOOK/hook.dll").exists());
}
#[test]
fn test_remove_delisted_ignores_non_mod_directories() {
let eco = eco();
let profile = profile(&eco, "valheim");
let profile_dir = tempdir().unwrap();
let root = profile_dir.path();
write(&root.join("BepInEx/plugins/Owner-Delete/x.dll"), b"x");
write(&root.join("BepInEx/plugins/MMHOOK/hook.dll"), b"hook");
let keep: BTreeSet<String> = BTreeSet::new();
let removed = remove_delisted(&profile, root, &keep).unwrap();
assert!(!root.join("BepInEx/plugins/Owner-Delete").exists());
assert!(root.join("BepInEx/plugins/MMHOOK/hook.dll").exists());
assert_eq!(removed.len(), 1);
}
#[test]
fn test_melonloader_flat_routing() {
let eco = eco();
let profile = profile(&eco, "boneworks");
let pkg = tempdir().unwrap();
let root = pkg.path();
write(&root.join("MyMod.dll"), b"dll");
write(&root.join("manifest.json"), b"{}");
let plan = plan_install(&eco, &profile, "Author-MyMod", root, None).unwrap();
let dests = dest_set(&plan);
assert!(dests.contains(&"Mods/MyMod.dll".to_string()));
assert!(!dests.iter().any(|d| d.contains("manifest.json")));
}
#[test]
fn test_melonloader_specific_extension_wins_over_generic() {
let eco = eco();
let profile = profile(&eco, "boneworks");
let pkg = tempdir().unwrap();
let root = pkg.path();
write(&root.join("MyLib.lib.dll"), b"lib");
write(&root.join("MyMod.dll"), b"dll");
let plan = plan_install(&eco, &profile, "Author-MyMod", root, None).unwrap();
let dests = dest_set(&plan);
assert!(dests.contains(&"UserLibs/MyLib.lib.dll".to_string()));
assert!(dests.contains(&"Mods/MyMod.dll".to_string()));
}
#[test]
fn test_melonloader_subroute_extension_routing() {
let eco = eco();
let profile = profile(&eco, "bonelab");
let pkg = tempdir().unwrap();
let root = pkg.path();
write(&root.join("cool.melon"), b"item");
write(&root.join("UserData/other.melon"), b"item2");
let plan = plan_install(&eco, &profile, "Author-MyMod", root, None).unwrap();
let dests = dest_set(&plan);
assert!(dests.contains(&"UserData/CustomItems/cool.melon".to_string()));
assert!(dests.contains(&"UserData/CustomItems/other.melon".to_string()));
}
#[test]
fn test_northstar_state_routing_and_exclusions() {
let eco = eco();
let profile = profile(&eco, "northstar");
let pkg = tempdir().unwrap();
let root = pkg.path();
write(&root.join("mods/Author.Mod/mod.json"), b"{}");
write(&root.join("manifest.json"), b"{}");
let plan = plan_install(&eco, &profile, "Author-Mod", root, None).unwrap();
let dests = dest_set(&plan);
assert!(dests.contains(&"R2Northstar/mods/Author.Mod/mod.json".to_string()));
assert!(!dests.iter().any(|d| d.contains("manifest.json")));
}
#[test]
fn test_godotml_package_zip() {
let eco = eco();
let profile = profile(&eco, "brotato");
let pkg = tempdir().unwrap();
let root = pkg.path();
write(&root.join("something.pck"), b"pck");
let archive = pkg.path().join("Author-Mod-1.0.0.zip");
write(&archive, b"zipbytes");
let plan = plan_install(&eco, &profile, "Author-Mod", root, Some(&archive)).unwrap();
let dests = dest_set(&plan);
assert_eq!(dests, vec!["mods/Author-Mod.zip".to_string()]);
assert_eq!(plan.entries[0].tracking, TrackingMethod::PackageZip);
let empty = plan_install(&eco, &profile, "Author-Mod", root, None).unwrap();
assert!(empty.entries.is_empty());
}
#[test]
fn test_plan_install_rejects_traversal_in_package_name() {
let eco = eco();
let profile = profile(&eco, "valheim");
let pkg = tempdir().unwrap();
let root = pkg.path();
write(&root.join("MyMod.dll"), b"dll");
let result = plan_install(&eco, &profile, "../../etc/Author-Evil", root, None);
assert!(result.is_err());
}
#[test]
fn test_ensure_safe_relative_rejects_unsafe_paths() {
assert!(ensure_safe_relative(Path::new("BepInEx/plugins/Author-Mod/x.dll")).is_ok());
assert!(ensure_safe_relative(Path::new("../escape")).is_err());
assert!(ensure_safe_relative(Path::new("a/../../escape")).is_err());
assert!(ensure_safe_relative(Path::new("/abs/path")).is_err());
}
#[cfg(unix)]
#[test]
fn test_walk_files_skips_symlinks() {
use std::os::unix::fs::symlink;
let dir = tempdir().unwrap();
let root = dir.path();
write(&root.join("real.txt"), b"real");
symlink("/etc/passwd", root.join("link.txt")).unwrap();
let files = walk_files(root).unwrap();
assert!(files.iter().any(|p| p.ends_with("real.txt")));
assert!(!files.iter().any(|p| p.ends_with("link.txt")));
}
#[test]
fn state_tracker_collects_only_state_entries() {
let plan = InstallPlan {
entries: vec![
InstallEntry {
source: PathBuf::from("/cache/Author-Mod/1.0.0/loader.dll"),
dest_relative: PathBuf::from("BepInEx/core/loader.dll"),
tracking: TrackingMethod::State,
},
InstallEntry {
source: PathBuf::from("/cache/Author-Mod/1.0.0/plugin.dll"),
dest_relative: PathBuf::from("BepInEx/plugins/Author-Mod/plugin.dll"),
tracking: TrackingMethod::Subdir,
},
],
};
let tracker = state_tracker_from_plan(&plan, "Author-Mod").unwrap();
assert_eq!(tracker.mod_name, "Author-Mod");
assert_eq!(tracker.files.len(), 1);
assert_eq!(tracker.files[0].1, "BepInEx/core/loader.dll");
}
#[test]
fn state_tracker_none_when_no_state_entries() {
let plan = InstallPlan {
entries: vec![InstallEntry {
source: PathBuf::from("/cache/x/plugin.dll"),
dest_relative: PathBuf::from("BepInEx/plugins/Author-Mod/plugin.dll"),
tracking: TrackingMethod::Subdir,
}],
};
assert!(state_tracker_from_plan(&plan, "Author-Mod").is_none());
}
#[test]
fn remove_state_payload_deletes_files_then_tracker() {
let dir = tempdir().unwrap();
let root = dir.path();
write(&root.join("BepInEx/core/loader.dll"), b"loader");
let tracker = ModFileTracker {
mod_name: "Author-Loader".to_string(),
files: vec![
(
"/cache/loader.dll".to_string(),
"BepInEx/core/loader.dll".to_string(),
),
(
"/cache/gone.dll".to_string(),
"BepInEx/core/gone.dll".to_string(),
),
],
};
write_state_file(root, &tracker).unwrap();
remove_state_payload(root, "Author-Loader").unwrap();
assert!(!root.join("BepInEx/core/loader.dll").exists());
assert!(!state_file_path(root, "Author-Loader").exists());
assert!(remove_state_payload(root, "Author-Untracked").is_ok());
}
#[test]
fn remove_state_payload_rejects_traversal_in_tracker() {
let dir = tempdir().unwrap();
let root = dir.path().join("profile");
let outside = dir.path().join("secret.txt");
write(&outside, b"secret");
let tracker = ModFileTracker {
mod_name: "Author-Evil".to_string(),
files: vec![("/cache/x".to_string(), "../secret.txt".to_string())],
};
write_state_file(&root, &tracker).unwrap();
assert!(remove_state_payload(&root, "Author-Evil").is_err());
assert!(outside.exists());
}
#[test]
fn state_file_round_trips() {
let dir = tempdir().unwrap();
let tracker = ModFileTracker {
mod_name: "Author-Mod".to_string(),
files: vec![("/cache/a".to_string(), "BepInEx/core/a.dll".to_string())],
};
write_state_file(dir.path(), &tracker).unwrap();
assert!(dir.path().join("_state/Author-Mod-state.yml").exists());
let back = read_state_file(dir.path(), "Author-Mod").unwrap().unwrap();
assert_eq!(back.mod_name, "Author-Mod");
assert_eq!(back.files, tracker.files);
assert!(read_state_file(dir.path(), "Missing").unwrap().is_none());
}
#[test]
fn plan_suffix_renames_adds_and_strips_old() {
let files = vec![
PathBuf::from("a/b/plugin.dll"),
PathBuf::from("a/b/data.cfg.old"),
];
let disable = plan_suffix_renames(&files, true);
assert_eq!(
disable,
vec![(
PathBuf::from("a/b/plugin.dll"),
PathBuf::from("a/b/plugin.dll.old"),
)]
);
let enable = plan_suffix_renames(&files, false);
assert_eq!(
enable,
vec![(
PathBuf::from("a/b/data.cfg.old"),
PathBuf::from("a/b/data.cfg"),
)]
);
}
#[test]
fn disable_enable_subdir_renames_files() {
let eco = eco();
let profile = profile(&eco, "valheim");
let profile_dir = tempdir().unwrap();
let root = profile_dir.path();
write(&root.join("BepInEx/plugins/Author-Mod/plugin.dll"), b"x");
disable_mod(&profile, root, "Author-Mod").unwrap();
assert!(!root.join("BepInEx/plugins/Author-Mod/plugin.dll").exists());
assert!(
root
.join("BepInEx/plugins/Author-Mod/plugin.dll.old")
.exists()
);
enable_mod(&profile, root, "Author-Mod").unwrap();
assert!(root.join("BepInEx/plugins/Author-Mod/plugin.dll").exists());
}
#[test]
fn disable_enable_state_removes_and_restores_from_cache() {
let eco = eco();
let profile = profile(&eco, "valheim");
let profile_dir = tempdir().unwrap();
let root = profile_dir.path();
let cache = tempdir().unwrap();
let src = cache.path().join("loader.dll");
write(&src, b"loader");
write(&root.join("BepInEx/core/loader.dll"), b"loader");
let tracker = ModFileTracker {
mod_name: "Author-Loader".to_string(),
files: vec![(
src.to_string_lossy().to_string(),
"BepInEx/core/loader.dll".to_string(),
)],
};
write_state_file(root, &tracker).unwrap();
disable_mod(&profile, root, "Author-Loader").unwrap();
assert!(!root.join("BepInEx/core/loader.dll").exists());
enable_mod(&profile, root, "Author-Loader").unwrap();
assert!(root.join("BepInEx/core/loader.dll").exists());
}
}