mod cli;
mod commands;
mod config;
mod error;
mod logs;
mod progress;
mod target;
#[cfg(test)]
mod test_support;
use crate::{
cli::AppCli,
error::{AppError, AppResult},
progress::IndicatifProgress,
target::Target,
};
use clap::Parser;
use cli::{Command, UpdatesCommand};
use config::{GameDirStatus, get_config};
use std::cell::LazyCell;
use std::sync::Arc;
use thunderstore_engine::client::ThunderstoreClient;
use thunderstore_engine::ecosystem::Ecosystem;
use thunderstore_engine::profile::layout;
const BASE_URL: &str = "https://thunderstore.io";
const COMMUNITY: &str = "valheim";
fn require_game_dir(config: &config::AppConfig) -> &str {
match config.game_dir_status() {
GameDirStatus::Set(dir) => dir,
GameDirStatus::NeedsMigration => {
report_and_exit(needs_migration_error(config.install_dir.as_deref()))
}
GameDirStatus::Unset => report_and_exit(unset_game_dir_error()),
}
}
fn needs_migration_error(install_dir: Option<&str>) -> AppError {
let suggested = install_dir.and_then(config::suggest_game_dir);
let detail = match &suggested {
Some(dir) => format!(
"`install_dir` is no longer used. Its meaning changed: vmm now installs into\n\
your game root using the loader's install rules (an r2modman-compatible\n\
layout) and manages BepInEx itself.\n\n\
Based on your old `install_dir`, your game root is probably:\n\n\
\x20 game_dir = \"{dir}\"\n\n\
Then remove the now-unused `install_dir`. Because the on-disk layout changed,\n\
start from a clean BepInEx setup (delete the existing `BepInEx/plugins`\n\
contents, or the whole `BepInEx` folder) so stale files from the old layout\n\
don't linger, then re-run `vmm update mods`.\n\n\
Nothing was changed."
),
None => format!(
"`install_dir` is no longer used. Its meaning changed: vmm now installs into\n\
your game root using the loader's install rules (an r2modman-compatible\n\
layout) and manages BepInEx itself.\n\n\
Set `game_dir` to your Valheim game folder (the directory containing the\n\
game executable, where BepInEx lives), for example:\n\n\
\x20 game_dir = \"{example}\"\n\n\
Then remove the now-unused `install_dir`. Because the on-disk layout changed,\n\
start from a clean BepInEx setup (delete the existing `BepInEx/plugins`\n\
contents, or the whole `BepInEx` folder) so stale files from the old layout\n\
don't linger, then re-run `vmm update mods`.\n\n\
Nothing was changed.",
example = config::example_game_dir(),
),
};
AppError::advice(
"your configuration needs migrating before mods can be installed.",
detail,
&[],
)
}
fn unset_game_dir_error() -> AppError {
AppError::advice(
"`game_dir` is not set, so there is nowhere to install mods.",
format!(
"Set `game_dir` to your Valheim game folder, for example:\n\n\
\x20 game_dir = \"{example}\"\n\n\
Nothing was changed.",
example = config::example_game_dir(),
),
&[],
)
}
fn missing_game_dir_error(dir: &std::path::Path) -> AppError {
AppError::advice(
"the configured `game_dir` is not a directory.",
format!(
"vmm looked for it at:\n\n\x20 {}\n\nNothing was changed. Check the path \
in your config against where the game is actually installed. `~` and \
environment variables like `$HOME` are both expanded, so either form \
works, but a name that is not set is left as written and would show up \
above exactly as you typed it.",
dir.display()
),
&[],
)
}
fn toggle_dispatch(
ecosystem: &Ecosystem,
target: &Target,
args: &cli::ToggleArgs,
enabled: bool,
) -> AppResult<()> {
match &args.name {
Some(name) => commands::toggle::run(ecosystem, target, name, enabled),
None => commands::toggle::run_all(ecosystem, target, enabled),
}
}
#[cfg(not(tarpaulin_include))]
fn report_and_exit(error: AppError) -> ! {
eprintln!("vmm: {error}");
std::process::exit(1)
}
#[tokio::main]
#[cfg(not(tarpaulin_include))]
async fn main() {
if let Err(error) = run().await {
report_and_exit(error);
}
}
#[cfg(not(tarpaulin_include))]
async fn run() -> AppResult<()> {
let app = AppCli::parse();
let config = get_config(app.config.as_deref())
.unwrap_or_else(|err| panic!("An error has occurred getting the config: '{err}'"));
logs::setup_logging(&config.log_level);
tracing::info!("Starting valheim mod manager");
let base = config.base_dir();
let client = ThunderstoreClient::builder()
.base_url(BASE_URL)
.community(COMMUNITY)
.cache_dir(&base)
.progress(Arc::new(IndicatifProgress::new()))
.build()?;
match &app.command {
Command::Search(args) => return commands::search::run(&client, &args.term).await,
Command::Update(sub) if matches!(sub.command, UpdatesCommand::Manifest) => {
return commands::update::run_manifest(&client).await;
}
Command::Profile(args) => return commands::profile::run(&base, &args.command),
_ => {}
}
let game_dir = config::expand_path(require_game_dir(&config));
if !game_dir.is_dir() {
return Err(missing_game_dir_error(&game_dir));
}
let persisted = layout::read_selection(&base, target::GAME);
let profile =
layout::select_profile(app.profile.as_deref(), app.no_profile, persisted.as_deref());
let target = target::resolve(base, game_dir, profile.as_deref())?;
if !matches!(app.command, Command::Migrate) {
commands::migrate::hint_if_unmigrated(&target, &config);
}
let ecosystem = LazyCell::new(Ecosystem::bundled);
match &app.command {
Command::Update(sub) => match &sub.command {
UpdatesCommand::Mods(args) => {
commands::update::run_mods(&client, &ecosystem, &target, args.force).await?
}
UpdatesCommand::Manifest => unreachable!("dispatched before target resolution"),
},
Command::List(list_args) => commands::list::run(&target, &list_args.format)?,
Command::Install(args) => {
commands::install::run(&client, &ecosystem, &target, &args.mods, args.force).await?
}
Command::Uninstall(args) => match args.all {
true => commands::uninstall::run_all(
&ecosystem,
&target,
args.force,
args.yes,
commands::uninstall::confirm_uninstall_all,
)?,
false => commands::uninstall::run(&ecosystem, &target, &args.mods, args.force)?,
},
Command::Enable(args) => toggle_dispatch(&ecosystem, &target, args, true)?,
Command::Disable(args) => toggle_dispatch(&ecosystem, &target, args, false)?,
Command::Migrate => commands::migrate::run(&client, &ecosystem, &target, &config).await?,
Command::Launch(args) => commands::launch::run(
&ecosystem,
&target,
&config.launch,
args.vanilla,
args.print_steam_options,
&args.args,
)?,
Command::Export(args) => match args.code {
true => commands::portability::export_code(&client, &target).await?,
false => commands::portability::export_file(&target)?,
},
Command::Import(args) => {
commands::portability::import(&client, &ecosystem, &target, &args.source).await?
}
Command::Search(_) | Command::Profile(_) => {
unreachable!("dispatched before target resolution")
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn a_game_dir_that_is_not_a_directory_names_the_path_that_was_looked_for() {
let message = missing_game_dir_error(Path::new("/games/Valhiem")).to_string();
assert!(
message.contains("/games/Valhiem"),
"the error must name the path it looked for; got: {message}"
);
assert!(
message.contains("game_dir"),
"the error must name the setting to fix; got: {message}"
);
assert!(message.contains("Nothing was changed"), "got: {message}");
}
#[test]
fn the_migration_refusal_offers_the_game_root_it_can_infer() {
let message = needs_migration_error(Some("/games/Valheim/BepInEx/plugins")).to_string();
assert!(
message.contains("game_dir = \"/games/Valheim\""),
"the refusal must offer the inferred game root; got: {message}"
);
assert!(
message.contains("install_dir"),
"the refusal must name the setting being retired; got: {message}"
);
assert!(message.contains("Nothing was changed"), "got: {message}");
}
#[test]
fn the_migration_refusal_falls_back_to_the_example_when_it_cannot_infer() {
let message = needs_migration_error(Some("/somewhere/else")).to_string();
assert!(
message.contains(config::example_game_dir()),
"the fallback must show the platform example; got: {message}"
);
assert!(
!message.contains("is probably"),
"an inference that was not made must not be claimed; got: {message}"
);
assert!(needs_migration_error(None).to_string().contains("game_dir"));
}
#[test]
fn the_unset_refusal_names_the_setting_and_shows_an_example() {
let message = unset_game_dir_error().to_string();
assert!(message.contains("game_dir"), "got: {message}");
assert!(
message.contains(config::example_game_dir()),
"the refusal must show a usable example; got: {message}"
);
assert!(
!message.contains("install_dir"),
"a fresh config must not be told about a deprecated key; got: {message}"
);
assert!(message.contains("Nothing was changed"), "got: {message}");
}
#[test]
fn toggle_dispatch_routes_by_whether_a_mod_was_named() {
let fixture = test_support::Fixture::new();
let target = fixture.target();
let eco = Ecosystem::bundled();
let named = cli::ToggleArgs {
name: Some("Owner-ModA".to_string()),
all: false,
};
let every = cli::ToggleArgs {
name: None,
all: true,
};
assert!(toggle_dispatch(&eco, &target, &named, false).is_err());
assert!(toggle_dispatch(&eco, &target, &every, false).is_ok());
}
}