use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use reqwest::Client;
use modde_core::installer::{
self, DossierContext, InstallMethod, InstallPlan, InstallProbe, InstallerError, ProbeTrace,
};
use modde_core::paths;
use modde_core::{NexusFileId, NexusModId};
use super::api::NexusMod;
use super::cdn::generate_download_link;
pub enum InstallOutcome {
Installed(InstallPlan),
PendingUserInput {
method: &'static str,
},
Unknown {
dossier_path: PathBuf,
method: InstallMethod,
},
AlreadyStaged,
}
pub async fn install_single_mod(
client: &Client,
api_key: &str,
game_domain: &str,
mod_id: NexusModId,
file_id: NexusFileId,
mod_info: &NexusMod,
probe: &InstallProbe,
) -> Result<InstallOutcome> {
let store = paths::store_dir();
let mod_store_dir = store.join(format!("{game_domain}_{mod_id}_{file_id}"));
if mod_store_dir.exists() {
return Ok(InstallOutcome::AlreadyStaged);
}
let staging_root =
paths::staging_dir().join(format!("install_{game_domain}_{mod_id}_{file_id}"));
std::fs::create_dir_all(store.as_path())?;
std::fs::create_dir_all(paths::staging_dir().as_path())?;
let download_url = generate_download_link(client, api_key, game_domain, mod_id, file_id)
.await
.context("failed to get Nexus download link")?;
let archive_path = store.join(format!("{mod_id}_{file_id}.zip"));
download_with_reqwest(client, &download_url, &archive_path)
.await
.context("failed to download mod archive")?;
if staging_root.exists() {
let _ = std::fs::remove_dir_all(&staging_root);
}
std::fs::create_dir_all(&staging_root)?;
installer::extract_archive(&archive_path, &staging_root)
.context("failed to extract mod archive")?;
let source_hash =
installer::xxh64_file_hex(&archive_path).context("failed to hash downloaded archive")?;
let _ = std::fs::remove_file(&archive_path);
let mut plan = installer::analyze(&staging_root, probe, source_hash)
.context("installer analyze failed")?;
let outcome = match &plan.method {
InstallMethod::Unknown { .. } => {
let dossier = write_dossier(
&staging_root,
game_domain,
mod_id,
file_id,
mod_info,
&plan.method,
)?;
InstallOutcome::Unknown {
dossier_path: dossier,
method: plan.method.clone(),
}
}
_ if !plan.method.is_ready() => {
std::fs::create_dir_all(&mod_store_dir)?;
copy_dir_tree(&staging_root, &mod_store_dir)
.context("failed to copy staging to store for pending install")?;
let label = match plan.method {
InstallMethod::Fomod { .. } => "fomod",
InstallMethod::Bain { .. } => "bain",
_ => "unknown",
};
InstallOutcome::PendingUserInput { method: label }
}
_ => {
std::fs::create_dir_all(&mod_store_dir)?;
match installer::execute(&mut plan, &staging_root, &mod_store_dir) {
Ok(_files) => InstallOutcome::Installed(plan),
Err(InstallerError::UnknownMethod { reason: _ }) => {
let dossier = write_dossier(
&staging_root,
game_domain,
mod_id,
file_id,
mod_info,
&plan.method,
)?;
InstallOutcome::Unknown {
dossier_path: dossier,
method: plan.method.clone(),
}
}
Err(InstallerError::RequiresUserInput { method }) => {
InstallOutcome::PendingUserInput { method }
}
Err(e) => return Err(e.into()),
}
}
};
let _ = std::fs::remove_dir_all(&staging_root);
Ok(outcome)
}
async fn download_with_reqwest(client: &Client, url: &str, dest: &Path) -> Result<()> {
use tokio::io::AsyncWriteExt as _;
let resp = crate::error::status_error(
client
.get(url)
.send()
.await
.context("download GET failed")?,
)
.context("download HTTP error")?;
let bytes = resp.bytes().await.context("failed to read download body")?;
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
let mut file = tokio::fs::File::create(dest).await?;
file.write_all(&bytes).await?;
file.flush().await?;
Ok(())
}
fn write_dossier(
extracted_dir: &Path,
game_domain: &str,
mod_id: NexusModId,
file_id: NexusFileId,
mod_info: &NexusMod,
method: &InstallMethod,
) -> Result<PathBuf> {
let ctx = DossierContext {
game_id: game_domain.to_string(),
game_domain: Some(game_domain.to_string()),
nexus_mod_id: Some(mod_id),
nexus_file_id: Some(file_id),
mod_name: mod_info.name.clone(),
mod_author: Some(mod_info.author.clone()),
mod_version: Some(mod_info.version.clone()),
mod_summary: mod_info.summary.clone(),
nexus_url: Some(format!(
"https://www.nexusmods.com/{game_domain}/mods/{mod_id}"
)),
source_archive_hash: String::new(),
};
let trace = vec![ProbeTrace {
probe: "generic+game".to_string(),
matched: false,
note: format!("verdict: {}", method.label()),
}];
installer::dump_dossier(extracted_dir, &ctx, method, trace)
.context("failed to write unknown-installer dossier")
}
fn copy_dir_tree(src: &Path, dst: &Path) -> std::io::Result<()> {
if !src.exists() {
return Ok(());
}
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
if src_path.is_dir() {
copy_dir_tree(&src_path, &dst_path)?;
} else if src_path.is_file() {
std::fs::copy(&src_path, &dst_path)?;
}
}
Ok(())
}