use crate::error::AppResult;
use crate::target::{GAME, Target};
use thunderstore_engine::client::ThunderstoreClient;
use thunderstore_engine::ecosystem::Ecosystem;
use thunderstore_engine::profile::modlist;
use thunderstore_engine::profile::portability::{self, ImportSource};
pub fn export_file(target: &Target) -> AppResult<()> {
let path = portability::export_to_file_in(
&target.dir,
target.name(),
&target.exports_dir(),
thunderstore_engine::profile::modlist::now_millis(),
)?;
println!("wrote {}", path.display());
Ok(())
}
pub async fn export_code(client: &ThunderstoreClient, target: &Target) -> AppResult<()> {
let code = portability::export_code_in(&target.dir, target.name(), client).await?;
println!("profile code: {code}");
Ok(())
}
pub async fn import(
client: &ThunderstoreClient,
eco: &Ecosystem,
target: &Target,
source: &str,
) -> AppResult<()> {
let before = modlist::read(&target.dir).unwrap_or_default();
let outcome = portability::import_in(
&target.dir,
&target.base,
eco,
client,
GAME,
source,
thunderstore_engine::profile::modlist::now_millis(),
)
.await?;
let recorded = modlist::read(&target.dir).unwrap_or_default();
let verb = match outcome.source {
ImportSource::R2modmanDir(_) => "imported",
ImportSource::Archive(_) | ImportSource::Code(_) => "installed",
};
super::report_recorded(&recorded, verb, &outcome.installed);
super::report_unchanged(&recorded, &outcome.unchanged);
super::report_state_changes(&before, &recorded, &outcome.unchanged);
if let ImportSource::R2modmanDir(_) = outcome.source {
report_reinstalled_loaders(&outcome.reinstalled);
}
Ok(())
}
fn report_reinstalled_loaders(reinstalled: &[String]) {
for loader in reinstalled {
println!(
"\nreinstalled {loader} at its latest version so it can be managed and \
uninstalled; the version your source profile recorded is not preserved"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::Fixture;
use base64::Engine as _;
use thunderstore_engine::profile::modlist;
use tokio::runtime::Runtime;
fn adopted_loader_mods_yml() -> &'static str {
"- manifestVersion: 1\n name: denikson-BepInExPack_Valheim\n \
authorName: denikson\n websiteUrl: ''\n displayName: BepInExPack_Valheim\n \
description: A mod\n gameVersion: '0'\n networkMode: both\n \
packageType: other\n installMode: managed\n installedAtTime: 1700000000000\n \
loaders: []\n dependencies: []\n incompatibilities: []\n \
optionalDependencies: []\n versionNumber: {major: 5, minor: 4, patch: 2200}\n \
enabled: true\n onlineSource: true\n trustedPackage: false\n"
}
#[test]
fn export_writes_an_r2z_into_the_exports_dir() {
let fixture = Fixture::new();
let target = fixture.target();
let eco = Ecosystem::bundled();
Runtime::new()
.unwrap()
.block_on(crate::commands::install::run(
&fixture.client,
&eco,
&target,
&["Owner-ModA".to_string()],
false,
))
.unwrap();
export_file(&target).unwrap();
let exports: Vec<_> = std::fs::read_dir(target.exports_dir())
.unwrap()
.flatten()
.map(|entry| entry.file_name().to_string_lossy().to_string())
.collect();
assert_eq!(exports.len(), 1);
assert!(exports[0].starts_with("default_"), "got: {:?}", exports[0]);
assert!(exports[0].ends_with(".r2z"));
}
#[test]
fn an_export_imports_into_another_target() {
let fixture = Fixture::new();
let source = fixture.target();
let eco = Ecosystem::bundled();
let runtime = Runtime::new().unwrap();
runtime
.block_on(crate::commands::install::run(
&fixture.client,
&eco,
&source,
&["Owner-ModA".to_string()],
false,
))
.unwrap();
export_file(&source).unwrap();
let archive = std::fs::read_dir(source.exports_dir())
.unwrap()
.flatten()
.next()
.unwrap()
.path();
let destination = fixture.profile_target("imported");
runtime
.block_on(import(
&fixture.client,
&eco,
&destination,
archive.to_str().unwrap(),
))
.unwrap();
let mods = modlist::read(&destination.dir).unwrap();
assert_eq!(mods.len(), 1);
assert_eq!(mods[0].name, "Owner-ModA");
assert!(
destination
.dir
.join("BepInEx/plugins/Owner-ModA/ModA.dll")
.exists()
);
}
#[test]
fn a_file_import_installs_each_mod_at_its_exported_version() {
let fixture = Fixture::new();
let source = fixture.target();
let eco = Ecosystem::bundled();
let runtime = Runtime::new().unwrap();
runtime
.block_on(crate::commands::install::run(
&fixture.client,
&eco,
&source,
&["Owner-ModD-1.0.0".to_string()],
false,
))
.unwrap();
export_file(&source).unwrap();
let archive = std::fs::read_dir(source.exports_dir())
.unwrap()
.flatten()
.next()
.unwrap()
.path();
let destination = fixture.profile_target("imported");
runtime
.block_on(import(
&fixture.client,
&eco,
&destination,
archive.to_str().unwrap(),
))
.unwrap();
let mods = modlist::read(&destination.dir).unwrap();
assert_eq!(
modlist::find(&mods, "Owner-ModD")
.unwrap()
.version_number
.to_string(),
"1.0.0"
);
}
#[test]
fn importing_a_profile_code_installs_the_shared_mods() {
let mut fixture = Fixture::new();
let source = fixture.target();
let eco = Ecosystem::bundled();
let runtime = Runtime::new().unwrap();
runtime
.block_on(crate::commands::install::run(
&fixture.client,
&eco,
&source,
&["Owner-ModA".to_string()],
false,
))
.unwrap();
export_file(&source).unwrap();
let archive = std::fs::read_dir(source.exports_dir())
.unwrap()
.flatten()
.next()
.unwrap()
.path();
let zip_bytes = std::fs::read(&archive).unwrap();
let payload = format!(
"#r2modman\n{}",
base64::engine::general_purpose::STANDARD.encode(zip_bytes)
);
fixture
.server
.mock("GET", "/api/experimental/legacyprofile/get/shared-code/")
.with_status(200)
.with_body(payload)
.create();
let url = fixture.server.url();
let index_url = format!("{url}/pkg/");
let client = ThunderstoreClient::builder()
.base_url(url)
.package_index_url(index_url)
.cache_dir(fixture.base.path())
.build()
.unwrap();
let destination = fixture.profile_target("shared");
runtime
.block_on(import(&client, &eco, &destination, "shared-code"))
.unwrap();
let mods = modlist::read(&destination.dir).unwrap();
assert_eq!(mods.len(), 1);
assert_eq!(mods[0].name, "Owner-ModA");
assert!(
destination
.dir
.join("BepInEx/plugins/Owner-ModA/ModA.dll")
.exists()
);
}
#[test]
fn importing_the_target_directory_itself_is_refused_before_anything_is_copied() {
let fixture = Fixture::new();
let target = fixture.target();
let eco = Ecosystem::bundled();
let runtime = Runtime::new().unwrap();
runtime
.block_on(crate::commands::install::run(
&fixture.client,
&eco,
&target,
&["Owner-ModA".to_string()],
false,
))
.unwrap();
let proxy = target.dir.join("winhttp.dll");
let config = target.dir.join("BepInEx/config/mod.cfg");
std::fs::write(&proxy, b"loader-proxy-bytes").unwrap();
std::fs::create_dir_all(config.parent().unwrap()).unwrap();
std::fs::write(&config, b"hand-tuned = true").unwrap();
let result = runtime.block_on(import(
&fixture.client,
&eco,
&target,
target.dir.to_str().unwrap(),
));
assert!(
result.is_err(),
"importing the install root into itself must be refused"
);
for path in [
&proxy,
&config,
&target.dir.join("BepInEx/plugins/Owner-ModA/ModA.dll"),
&target.mods_yml(),
] {
let size = std::fs::metadata(path).unwrap().len();
assert!(
size > 0,
"{} was truncated to {} bytes",
path.display(),
size
);
}
}
#[test]
fn importing_a_directory_adopts_it_as_is() {
let fixture = Fixture::new();
let source = fixture.target();
let eco = Ecosystem::bundled();
Runtime::new()
.unwrap()
.block_on(crate::commands::install::run(
&fixture.client,
&eco,
&source,
&["Owner-ModA".to_string()],
false,
))
.unwrap();
let destination = fixture.profile_target("adopted");
let mut trap = mockito::Server::new();
let no_manifest = trap.mock("GET", "/pkg/").expect(0).create();
let no_download = trap.mock("GET", "/dl/ModA.zip").expect(0).create();
let trap_client = ThunderstoreClient::builder()
.package_index_url(format!("{}/pkg/", trap.url()))
.cache_dir(fixture.base.path())
.build()
.unwrap();
Runtime::new()
.unwrap()
.block_on(import(
&trap_client,
&eco,
&destination,
source.dir.to_str().unwrap(),
))
.unwrap();
no_manifest.assert();
no_download.assert();
assert_eq!(modlist::read(&destination.dir).unwrap().len(), 1);
assert!(
destination
.dir
.join("BepInEx/plugins/Owner-ModA/ModA.dll")
.exists()
);
}
#[test]
fn a_directory_import_gives_the_loader_an_install_record() {
let fixture = Fixture::new();
let target = fixture.profile_target("destination");
let eco = Ecosystem::bundled();
let source = tempfile::TempDir::new().unwrap();
std::fs::write(source.path().join("mods.yml"), adopted_loader_mods_yml()).unwrap();
std::fs::create_dir_all(source.path().join("BepInEx/core")).unwrap();
std::fs::write(source.path().join("winhttp.dll"), b"proxy").unwrap();
Runtime::new()
.unwrap()
.block_on(import(
&fixture.client,
&eco,
&target,
source.path().to_str().unwrap(),
))
.unwrap();
assert!(
thunderstore_engine::install::state_file_path(&target.dir, "denikson-BepInExPack_Valheim")
.exists(),
"the adopted loader must end up with a _state tracker"
);
}
#[test]
fn reinstalling_the_loader_is_skipped_when_it_already_has_a_state_tracker() {
let fixture = Fixture::new();
let destination = fixture.profile_target("already-installed");
let eco = Ecosystem::bundled();
let runtime = Runtime::new().unwrap();
runtime
.block_on(crate::commands::install::run(
&fixture.client,
&eco,
&destination,
&["denikson-BepInExPack_Valheim".to_string()],
false,
))
.unwrap();
assert!(
thunderstore_engine::install::state_file_path(
&destination.dir,
"denikson-BepInExPack_Valheim"
)
.exists()
);
let source = tempfile::TempDir::new().unwrap();
std::fs::write(source.path().join("mods.yml"), adopted_loader_mods_yml()).unwrap();
std::fs::create_dir_all(source.path().join("BepInEx/core")).unwrap();
std::fs::write(source.path().join("winhttp.dll"), b"proxy").unwrap();
let mut trap = mockito::Server::new();
let no_manifest = trap.mock("GET", "/pkg/").expect(0).create();
let no_download = trap
.mock("GET", "/dl/BepInExPack_Valheim.zip")
.expect(0)
.create();
let trap_client = ThunderstoreClient::builder()
.package_index_url(format!("{}/pkg/", trap.url()))
.cache_dir(fixture.base.path())
.build()
.unwrap();
runtime
.block_on(import(
&trap_client,
&eco,
&destination,
source.path().to_str().unwrap(),
))
.unwrap();
no_manifest.assert();
no_download.assert();
}
}