use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use anyhow::{Context, Result};
use tracing::{info, warn};
use modde_core::collision;
use modde_core::fs::{symlink_async, walk_files_relative};
use modde_core::installer::InstallMethod;
use modde_core::paths;
use modde_core::profile::{ProfileManager, ProfileSource};
use modde_core::resolver::{self, ConflictMap, ModId};
use modde_core::vfs::SymlinkFarm;
use super::load_profile_or_default;
pub async fn handle(profile_name: Option<String>, game_id: Option<String>) -> Result<()> {
let pm = ProfileManager::open().context("failed to open profile database")?;
let profile = load_profile_or_default(&pm, profile_name.as_deref(), game_id.as_deref())?;
let name = &profile.name;
info!(profile = %name, game = %profile.game_id, "deploying profile");
let game_plugin =
modde_games::resolve_game_plugin(profile.game_id.as_str()).ok_or_else(|| {
anyhow::anyhow!(
"unsupported game: '{}'\nSupported games: {}",
profile.game_id,
modde_games::supported_game_ids().join(", ")
)
})?;
let install_dir = game_plugin.detect_install().ok_or_else(|| {
anyhow::anyhow!(
"could not detect install directory for {}",
game_plugin.display_name()
)
})?;
let game_mod_dir = game_plugin
.mod_root(&install_dir)
.context("failed to resolve game mod root")?;
info!(
game = game_plugin.display_name(),
install_dir = %install_dir.display(),
mod_dir = %game_mod_dir.display(),
"resolved game paths"
);
if let ProfileSource::Wabbajack { .. } = &profile.source {
let staging = paths::staging_dir().join(&profile.name);
info!(staging = %staging.display(), "Wabbajack profile: deploying from staging");
deploy_mo2_to_game(&staging, &install_dir, false)
.await
.context("Wabbajack deploy from staging failed")?;
game_plugin
.post_deploy(&install_dir)
.context("post-deploy hook failed")?;
super::install::configure_wine_overrides(profile.game_id.as_str(), &install_dir, &staging)
.context("Wine DLL override configuration failed")?;
println!("Deployed Wabbajack profile: {name}");
println!(
" Game: {} ({})",
game_plugin.display_name(),
profile.game_id
);
println!(" Install dir: {}", install_dir.display());
return Ok(());
}
let resolved = resolver::resolve(&profile).context("failed to resolve load order")?;
println!("Load order: {} enabled mods", resolved.order.len());
let store = paths::store_dir();
let classifier = modde_games::resolve_collision_classifier(profile.game_id.as_str());
let conflict_map = if let Some(ref cls) = classifier {
collision::build_full_conflict_map(&store, &resolved.order, cls.as_ref())
.context("failed to build conflict map")?
.conflict_map
} else {
let mut cm = ConflictMap::default();
for mod_id in &resolved.order {
let mod_dir = store.join(mod_id.as_str());
if !mod_dir.exists() {
continue;
}
if let Ok(files) = walk_files_relative(&mod_dir) {
for (rel_path, _) in &files {
cm.register(rel_path.clone(), mod_id.clone());
}
}
}
cm
};
let mut mod_files: HashMap<ModId, Vec<(String, PathBuf)>> = HashMap::new();
let mut conflict_count: usize = 0;
let mut missing_mods = Vec::new();
for mod_id in &resolved.order {
let mod_dir_path = store.join(mod_id.as_str());
if !mod_dir_path.exists() {
missing_mods.push(mod_id.clone());
continue;
}
let files = walk_files_relative(&mod_dir_path)
.with_context(|| format!("failed to walk files for mod {mod_id}"))?;
for (rel_path, _) in &files {
if let Some(providers) = conflict_map.files.get(rel_path)
&& providers.len() > 1
{
conflict_count += 1;
}
}
mod_files.insert(mod_id.clone(), files);
}
if let Some(summary) = collision::summarize_missing_store_dirs(&missing_mods) {
warn!(
store = %store.display(),
missing_mod_count = summary.missing_mod_count,
missing_mod_sample = ?summary.missing_mod_sample,
omitted_missing_mod_count = summary.omitted_missing_mod_count,
"mod directories not found in store, skipping"
);
}
let conflicts = conflict_map.conflicts();
if !conflicts.is_empty() {
println!("Conflicts detected ({} files):", conflicts.len());
for (path, providers) in &conflicts {
let provider_list: Vec<&str> = providers.iter().map(ModId::as_str).collect();
info!(file = %path, providers = ?provider_list, "file conflict");
}
}
let overrides = if profile.overrides.exists() {
let files =
walk_files_relative(&profile.overrides).context("failed to walk override files")?;
for (rel_path, _) in &files {
info!(file = %rel_path, "override wins over mod file");
}
Some(files)
} else {
None
};
let hidden_set: Option<HashSet<(String, String)>> = profile.id.and_then(|pid| {
let hidden = pm.db().list_hidden_files(pid).ok()?;
if hidden.is_empty() {
None
} else {
let set: HashSet<(String, String)> =
hidden.into_iter().map(|h| (h.mod_id, h.rel_path)).collect();
info!(count = set.len(), "applying hidden file exclusions");
Some(set)
}
});
let farm = SymlinkFarm::build(
name,
&resolved,
&mod_files,
overrides.as_deref(),
hidden_set.as_ref(),
)
.context("failed to build symlink farm")?;
let total_files = farm.links.len();
let farm = farm
.materialize()
.await
.context("failed to materialize symlink farm")?;
game_plugin
.deploy_to_install(&farm.staging_dir, &install_dir)
.context("game plugin deploy failed")?;
game_plugin
.post_deploy(&install_dir)
.context("post-deploy hook failed")?;
let staging_dir = paths::staging_dir().join(name);
super::install::configure_wine_overrides(profile.game_id.as_str(), &install_dir, &staging_dir)
.context("Wine DLL override configuration failed")?;
if let Ok(db) = modde_core::db::ModdeDb::open() {
if let Err(e) = modde_games::launcher::generate_tool_configs(&profile.game_id, &db) {
warn!(error = %e, "failed to generate tool configs");
}
let launcher = modde_games::launcher::detect_launcher(&install_dir);
if let modde_games::launcher::Launcher::Heroic {
ref config_path,
ref game_id,
} = launcher
{
let env_vars = modde_games::launcher::collect_tool_env_vars(&profile.game_id, &db)
.unwrap_or_default();
let wrappers = modde_games::launcher::collect_tool_wrappers(&profile.game_id, &db)
.unwrap_or_default();
match modde_games::launcher::apply_tool_environment_heroic(
config_path,
game_id,
&env_vars,
&wrappers,
) {
Ok(report) => super::install::print_tool_environment_report(&report),
Err(e) => {
warn!(error = %e, "failed to apply tool environment to Heroic");
}
}
}
}
let alt_routed = deploy_alt_target_mods(&profile, game_plugin, &install_dir, &store).await?;
println!("Deployed profile: {name}");
println!(
" Game: {} ({})",
game_plugin.display_name(),
profile.game_id
);
println!(" Install dir: {}", install_dir.display());
println!(" Mod dir: {}", game_mod_dir.display());
println!(" Total files: {total_files}");
println!(" Conflicts resolved: {conflict_count}");
if alt_routed > 0 {
println!(" Alt-target files: {alt_routed}");
}
Ok(())
}
async fn deploy_alt_target_mods(
profile: &modde_core::profile::Profile,
plugin: &dyn modde_games::GamePlugin,
install_dir: &std::path::Path,
store: &std::path::Path,
) -> Result<usize> {
let mut linked = 0usize;
for em in &profile.mods {
if !em.enabled {
continue;
}
let Some(method) = em.install_method.as_ref() else {
continue;
};
let target_id = match method {
InstallMethod::UserConfigOverlay { target_id } => target_id.as_str(),
_ => continue,
};
let Some(target_root) = plugin.resolve_deploy_target(target_id, install_dir) else {
warn!(
mod_id = %em.mod_id,
%target_id,
"alt deploy target unresolved (Wine prefix may not exist yet); \
launch the game once via Steam/Proton, then re-deploy."
);
continue;
};
let mod_dir = store.join(&em.mod_id);
if !mod_dir.exists() {
warn!(mod_id = %em.mod_id, "store dir missing, skipping alt-target deploy");
continue;
}
tokio::fs::create_dir_all(&target_root).await?;
let files = walk_files_relative(&mod_dir)
.with_context(|| format!("failed to walk store dir for mod {}", em.mod_id))?;
for (rel_path, abs_src) in &files {
let dst = target_root.join(rel_path);
if let Some(parent) = dst.parent() {
tokio::fs::create_dir_all(parent).await?;
}
if dst.symlink_metadata().is_ok() {
tokio::fs::remove_file(&dst).await?;
}
symlink_async(abs_src, &dst).await?;
linked += 1;
}
info!(
mod_id = %em.mod_id,
target = %target_root.display(),
files = files.len(),
"deployed mod to alt target"
);
}
Ok(linked)
}
use super::install::deploy_mo2_to_game;