use crate::config::AppConfig;
use crate::error::{AppError, AppResult};
use crate::target::{GAME, Target};
use thunderstore_engine::client::ThunderstoreClient;
use thunderstore_engine::ecosystem::Ecosystem;
use thunderstore_engine::profile::portability;
pub fn needs_migration(target: &Target, config: &AppConfig) -> bool {
if config.mod_list.is_empty() {
return false;
}
portability::needs_adoption(&target.dir, &config.mod_list).unwrap_or(true)
}
fn unadopted_names(target: &Target, config: &AppConfig) -> Vec<String> {
let installed = thunderstore_engine::profile::modlist::read(&target.dir).unwrap_or_default();
config
.mod_list
.iter()
.filter(|name| thunderstore_engine::profile::modlist::find(&installed, name).is_none())
.cloned()
.collect()
}
fn unmigrated_hint(target: &Target, config: &AppConfig) -> String {
let unadopted = unadopted_names(target, config);
crate::error::advice_message(
"your config's `mod_list` names mods that aren't recorded as installed.",
&format!(
"{} does not record:\n\n {}\n\n`mod_list` is deprecated and only \
mods.yml is consulted, so commands that read this target may not see \
them. If they were never adopted, migrating adopts them and you can \
then delete `mod_list`. If you uninstalled one of them on purpose, \
delete just that entry from `mod_list` instead, or migrating will \
install it again.",
target.mods_yml().display(),
unadopted.join("\n ")
),
&["vmm migrate"],
)
}
pub fn hint_if_unmigrated(target: &Target, config: &AppConfig) {
if !needs_migration(target, config) {
return;
}
eprintln!("vmm: {}", unmigrated_hint(target, config));
}
pub async fn run(
client: &ThunderstoreClient,
eco: &Ecosystem,
target: &Target,
config: &AppConfig,
) -> AppResult<()> {
if !needs_migration(target, config) {
if config.mod_list.is_empty() {
println!(
"vmm: {}",
crate::error::advice_message(
"there's nothing to migrate.",
"Your config has no `mod_list` key, so you're already on the current \
format. Nothing was changed.",
&["vmm install <Owner-ModName>"],
)
);
return Ok(());
}
return Err(AppError::advice(
"your `mod_list` has already been migrated.",
format!(
"Every mod it names is already recorded in {}, so there is nothing left \
to adopt and nothing was changed.\n\nYou can delete `mod_list` from \
your config.",
target.mods_yml().display()
),
&["vmm list"],
));
}
super::read_modlist(target)?;
let index = client.get_manifest().await?;
println!("adopting {} mods from mod_list", config.mod_list.len());
let outcome = portability::adopt_names_in(
&target.dir,
&target.base,
eco,
&index,
client,
GAME,
&config.mod_list,
thunderstore_engine::profile::modlist::now_millis(),
)
.await?;
let recorded = thunderstore_engine::profile::modlist::read(&target.dir).unwrap_or_default();
super::report_recorded(&recorded, "installed", &outcome.adopted);
super::report_unchanged(&recorded, &outcome.unchanged);
for dir in &outcome.swept {
println!("swept stale folder {}", dir.display());
}
if !outcome.remaining.is_empty() {
return Err(unadopted_error(&outcome.remaining, &outcome.failed));
}
if !outcome.failed.is_empty() {
return Err(failed_reinstall_error(target, &outcome.failed));
}
println!(
"\nwrote {}\n\nYou can now delete `mod_list` from your config.",
target.mods_yml().display()
);
Ok(())
}
fn unadopted_error(
remaining: &[String],
failed: &[(String, thunderstore_engine::error::Error)],
) -> AppError {
let mut detail = format!(
"These mods were not adopted: {}\n\nTheir files are still on disk from \
the old installer, but mods.yml does not record them, so a command \
that reconciles installs would sweep them as delisted. Finish the \
migration before running one.",
remaining.join(", ")
);
if !failed.is_empty() {
let reasons = failed
.iter()
.map(|(name, error)| format!("{name}: {error}"))
.collect::<Vec<_>>()
.join("; ");
detail.push_str(&format!("\n\nKnown reasons: {reasons}"));
}
AppError::advice(
"migration stopped part way through.",
detail,
&["vmm migrate"],
)
}
fn failed_reinstall_error(
target: &Target,
failed: &[(String, thunderstore_engine::error::Error)],
) -> AppError {
let named: Vec<String> = failed
.iter()
.map(|(name, error)| format!("{name}: {error}"))
.collect();
AppError::advice(
format!(
"{} could not be reinstalled during the migration.",
super::describe_count(failed.len())
),
format!(
"Every `mod_list` entry is now recorded in {}, so the migration is \
finished and you can delete `mod_list` from your config. These are \
still recorded, but their files may be incomplete until they are \
installed again:\n\n {}",
target.mods_yml().display(),
named.join("\n ")
),
&["vmm update mods"],
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::AppConfig;
use crate::test_support::Fixture;
use thunderstore_engine::profile::modlist;
use tokio::runtime::Runtime;
fn config_with(mods: &[&str]) -> AppConfig {
AppConfig {
mod_list: mods.iter().map(|name| name.to_string()).collect(),
..Default::default()
}
}
#[test]
fn migrate_adopts_mod_list_and_sweeps_stale_folders() {
let fixture = Fixture::new();
let target = fixture.target();
let eco = Ecosystem::bundled();
let stale = target.dir.join("BepInEx/plugins/Owner-Dropped");
std::fs::create_dir_all(&stale).unwrap();
std::fs::write(stale.join("Dropped.dll"), b"dll").unwrap();
Runtime::new()
.unwrap()
.block_on(run(
&fixture.client,
&eco,
&target,
&config_with(&["Owner-ModA"]),
))
.unwrap();
let mods = modlist::read(&target.dir).unwrap();
assert_eq!(mods.len(), 1);
assert_eq!(mods[0].name, "Owner-ModA");
assert!(
target
.dir
.join("BepInEx/plugins/Owner-ModA/ModA.dll")
.exists()
);
assert!(!stale.exists(), "a dropped mod's folder should be swept");
}
#[test]
fn migrate_adopts_into_the_profile_not_the_game_dir() {
let fixture = Fixture::new();
let target = fixture.profile_target("experiment");
let eco = Ecosystem::bundled();
let stale = target.dir.join("BepInEx/plugins/Owner-Dropped");
let in_game_dir = fixture
.game_dir
.path()
.join("BepInEx/plugins/Owner-Untouched");
std::fs::create_dir_all(&stale).unwrap();
std::fs::create_dir_all(&in_game_dir).unwrap();
std::fs::write(in_game_dir.join("Untouched.dll"), b"dll").unwrap();
Runtime::new()
.unwrap()
.block_on(run(
&fixture.client,
&eco,
&target,
&config_with(&["Owner-ModA"]),
))
.unwrap();
assert!(target.mods_yml().exists());
assert!(
target
.dir
.join("BepInEx/plugins/Owner-ModA/ModA.dll")
.exists()
);
assert!(!stale.exists(), "the profile's stale folder is swept");
assert!(
!fixture
.game_dir
.path()
.join("BepInEx/plugins/Owner-ModA")
.exists()
);
assert!(!fixture.game_dir.path().join("mods.yml").exists());
assert!(in_game_dir.join("Untouched.dll").exists());
}
#[test]
fn migrate_refuses_when_every_mod_list_entry_is_already_recorded() {
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 result = runtime.block_on(run(
&fixture.client,
&eco,
&target,
&config_with(&["Owner-ModA"]),
));
assert!(result.is_err());
assert_eq!(modlist::read(&target.dir).unwrap().len(), 1);
}
#[test]
fn migrate_with_no_mod_list_is_a_friendly_no_op() {
let fixture = Fixture::new();
let target = fixture.target();
let eco = Ecosystem::bundled();
let result =
Runtime::new()
.unwrap()
.block_on(run(&fixture.client, &eco, &target, &config_with(&[])));
assert!(result.is_ok());
assert!(!target.mods_yml().exists());
}
#[test]
fn an_unreadable_mods_yml_reports_its_path_before_migrating() {
let fixture = Fixture::new();
let target = fixture.target();
let eco = Ecosystem::bundled();
std::fs::write(target.mods_yml(), "not: [valid").unwrap();
let message = Runtime::new()
.unwrap()
.block_on(run(
&fixture.client,
&eco,
&target,
&config_with(&["Owner-ModA"]),
))
.unwrap_err()
.to_string();
assert!(message.contains("the install record for"), "got: {message}");
assert!(message.contains(&target.mods_yml().display().to_string()));
}
#[test]
fn migrate_stops_and_leaves_a_resumable_state_on_an_unresolvable_entry() {
let fixture = Fixture::new();
let target = fixture.target();
let eco = Ecosystem::bundled();
let result = Runtime::new().unwrap().block_on(run(
&fixture.client,
&eco,
&target,
&config_with(&["Owner-ModA", "Owner-Delisted", "Owner-ModB"]),
));
let error = result.unwrap_err();
assert!(matches!(error, AppError::Other(_)));
let message = error.to_string();
assert!(message.contains("migration stopped part way through."));
assert!(message.contains("Owner-Delisted"));
assert!(message.contains("vmm migrate"));
assert!(message.contains("Known reasons:"));
assert!(message.contains("could not resolve Owner-Delisted"));
}
#[test]
fn migrate_reports_a_recorded_entry_that_failed_to_reinstall() {
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 folder = target.dir.join("BepInEx/plugins/Owner-ModA");
std::fs::remove_dir_all(&folder).unwrap();
std::fs::write(&folder, b"in the way").unwrap();
let message = runtime
.block_on(run(
&fixture.client,
&eco,
&target,
&config_with(&["Owner-ModA", "Owner-ModB"]),
))
.unwrap_err()
.to_string();
assert!(
message.contains("1 mod could not be reinstalled"),
"got: {message}"
);
assert!(message.contains("Owner-ModA:"), "got: {message}");
assert!(message.contains("vmm update mods"), "got: {message}");
assert!(!message.contains("vmm install"), "got: {message}");
assert!(!message.contains("part way through"), "got: {message}");
let mods = modlist::read(&target.dir).unwrap();
assert!(modlist::find(&mods, "Owner-ModA").is_some());
assert!(modlist::find(&mods, "Owner-ModB").is_some());
}
#[test]
fn migrate_is_resumable_after_a_partial_failure() {
let fixture = Fixture::new();
let target = fixture.target();
let eco = Ecosystem::bundled();
let runtime = Runtime::new().unwrap();
runtime
.block_on(run(
&fixture.client,
&eco,
&target,
&config_with(&["Owner-ModA", "Owner-Delisted"]),
))
.unwrap_err();
assert!(needs_migration(
&target,
&config_with(&["Owner-ModA", "Owner-Delisted"])
));
let result = runtime.block_on(run(
&fixture.client,
&eco,
&target,
&config_with(&["Owner-ModA", "Owner-ModB"]),
));
assert!(result.is_ok());
let mods = modlist::read(&target.dir).unwrap();
assert_eq!(mods.len(), 2);
assert!(modlist::find(&mods, "Owner-ModA").is_some());
assert!(modlist::find(&mods, "Owner-ModB").is_some());
}
#[test]
fn the_hint_names_what_is_unadopted_and_warns_that_migrating_undoes_a_removal() {
let fixture = Fixture::new();
let target = fixture.target();
let eco = Ecosystem::bundled();
let runtime = Runtime::new().unwrap();
let config = config_with(&["Owner-ModA", "Owner-ModB"]);
runtime
.block_on(crate::commands::install::run(
&fixture.client,
&eco,
&target,
&["Owner-ModA".to_string(), "Owner-ModB".to_string()],
false,
))
.unwrap();
crate::commands::uninstall::run(&eco, &target, &["Owner-ModB".to_string()], false).unwrap();
assert!(needs_migration(&target, &config));
let hint = unmigrated_hint(&target, &config);
assert!(
hint.contains("Owner-ModB"),
"the hint must name what is unadopted; got: {hint}"
);
assert!(
!hint.contains("Owner-ModA"),
"an already-recorded mod is not unadopted; got: {hint}"
);
assert!(
hint.contains("mod_list"),
"the hint must name the deprecated key to edit; got: {hint}"
);
assert!(
hint.contains("uninstalled"),
"the hint must cover the deliberate-removal reading; got: {hint}"
);
}
#[test]
fn hint_fires_only_for_an_unmigrated_config() {
let fixture = Fixture::new();
let target = fixture.target();
let eco = Ecosystem::bundled();
assert!(needs_migration(&target, &config_with(&["Owner-ModA"])));
assert!(!needs_migration(&target, &config_with(&[])));
Runtime::new()
.unwrap()
.block_on(crate::commands::install::run(
&fixture.client,
&eco,
&target,
&["Owner-ModA".to_string()],
false,
))
.unwrap();
assert!(!needs_migration(&target, &config_with(&["Owner-ModA"])));
assert!(needs_migration(
&target,
&config_with(&["Owner-ModA", "Owner-ModB"])
));
}
#[test]
fn the_hint_stays_quiet_when_there_is_nothing_left_to_adopt() {
let fixture = Fixture::new();
let target = fixture.target();
hint_if_unmigrated(&target, &config_with(&[]));
hint_if_unmigrated(&target, &config_with(&["Owner-ModA"]));
}
}