1use std::path::{Path, PathBuf};
17
18use anyhow::{Context, Result};
19use reqwest::Client;
20
21use modde_core::installer::{
22 self, DossierContext, InstallMethod, InstallPlan, InstallProbe, InstallerError, ProbeTrace,
23};
24use modde_core::paths;
25use modde_core::{NexusFileId, NexusModId};
26
27use super::api::NexusMod;
28use super::cdn::generate_download_link;
29
30pub enum InstallOutcome {
39 Installed(InstallPlan),
40 PendingUserInput {
41 method: &'static str,
42 },
43 Unknown {
44 dossier_path: PathBuf,
45 method: InstallMethod,
46 },
47 AlreadyStaged,
48}
49
50pub async fn install_single_mod(
58 client: &Client,
59 api_key: &str,
60 game_domain: &str,
61 mod_id: NexusModId,
62 file_id: NexusFileId,
63 mod_info: &NexusMod,
64 probe: &InstallProbe,
65) -> Result<InstallOutcome> {
66 let store = paths::store_dir();
67 let mod_store_dir = store.join(format!("{game_domain}_{mod_id}_{file_id}"));
68 if mod_store_dir.exists() {
69 return Ok(InstallOutcome::AlreadyStaged);
70 }
71
72 let staging_root =
78 paths::staging_dir().join(format!("install_{game_domain}_{mod_id}_{file_id}"));
79
80 std::fs::create_dir_all(store.as_path())?;
81 std::fs::create_dir_all(paths::staging_dir().as_path())?;
82
83 let download_url = generate_download_link(client, api_key, game_domain, mod_id, file_id)
86 .await
87 .context("failed to get Nexus download link")?;
88 let archive_path = store.join(format!("{mod_id}_{file_id}.zip"));
89 download_with_reqwest(client, &download_url, &archive_path)
90 .await
91 .context("failed to download mod archive")?;
92
93 if staging_root.exists() {
95 let _ = std::fs::remove_dir_all(&staging_root);
96 }
97 std::fs::create_dir_all(&staging_root)?;
98 installer::extract_archive(&archive_path, &staging_root)
99 .context("failed to extract mod archive")?;
100
101 let source_hash =
103 installer::xxh64_file_hex(&archive_path).context("failed to hash downloaded archive")?;
104 let _ = std::fs::remove_file(&archive_path);
105
106 let mut plan = installer::analyze(&staging_root, probe, source_hash)
108 .context("installer analyze failed")?;
109
110 let outcome = match &plan.method {
111 InstallMethod::Unknown { .. } => {
112 let dossier = write_dossier(
113 &staging_root,
114 game_domain,
115 mod_id,
116 file_id,
117 mod_info,
118 &plan.method,
119 )?;
120 InstallOutcome::Unknown {
121 dossier_path: dossier,
122 method: plan.method.clone(),
123 }
124 }
125 _ if !plan.method.is_ready() => {
126 std::fs::create_dir_all(&mod_store_dir)?;
131 copy_dir_tree(&staging_root, &mod_store_dir)
132 .context("failed to copy staging to store for pending install")?;
133 let label = match plan.method {
138 InstallMethod::Fomod { .. } => "fomod",
139 InstallMethod::Bain { .. } => "bain",
140 _ => "unknown",
141 };
142 InstallOutcome::PendingUserInput { method: label }
143 }
144 _ => {
145 std::fs::create_dir_all(&mod_store_dir)?;
146 match installer::execute(&mut plan, &staging_root, &mod_store_dir) {
147 Ok(_files) => InstallOutcome::Installed(plan),
148 Err(InstallerError::UnknownMethod { reason: _ }) => {
149 let dossier = write_dossier(
150 &staging_root,
151 game_domain,
152 mod_id,
153 file_id,
154 mod_info,
155 &plan.method,
156 )?;
157 InstallOutcome::Unknown {
158 dossier_path: dossier,
159 method: plan.method.clone(),
160 }
161 }
162 Err(InstallerError::RequiresUserInput { method }) => {
163 InstallOutcome::PendingUserInput { method }
164 }
165 Err(e) => return Err(e.into()),
166 }
167 }
168 };
169
170 let _ = std::fs::remove_dir_all(&staging_root);
173 Ok(outcome)
174}
175
176async fn download_with_reqwest(client: &Client, url: &str, dest: &Path) -> Result<()> {
177 use tokio::io::AsyncWriteExt as _;
178
179 let resp = crate::error::status_error(
180 client
181 .get(url)
182 .send()
183 .await
184 .context("download GET failed")?,
185 )
186 .context("download HTTP error")?;
187 let bytes = resp.bytes().await.context("failed to read download body")?;
188 if let Some(parent) = dest.parent() {
189 std::fs::create_dir_all(parent)?;
190 }
191 let mut file = tokio::fs::File::create(dest).await?;
192 file.write_all(&bytes).await?;
193 file.flush().await?;
194 Ok(())
195}
196
197fn write_dossier(
198 extracted_dir: &Path,
199 game_domain: &str,
200 mod_id: NexusModId,
201 file_id: NexusFileId,
202 mod_info: &NexusMod,
203 method: &InstallMethod,
204) -> Result<PathBuf> {
205 let ctx = DossierContext {
206 game_id: game_domain.to_string(),
207 game_domain: Some(game_domain.to_string()),
208 nexus_mod_id: Some(mod_id),
209 nexus_file_id: Some(file_id),
210 mod_name: mod_info.name.clone(),
211 mod_author: Some(mod_info.author.clone()),
212 mod_version: Some(mod_info.version.clone()),
213 mod_summary: mod_info.summary.clone(),
214 nexus_url: Some(format!(
215 "https://www.nexusmods.com/{game_domain}/mods/{mod_id}"
216 )),
217 source_archive_hash: String::new(),
221 };
222 let trace = vec![ProbeTrace {
223 probe: "generic+game".to_string(),
224 matched: false,
225 note: format!("verdict: {}", method.label()),
226 }];
227 installer::dump_dossier(extracted_dir, &ctx, method, trace)
228 .context("failed to write unknown-installer dossier")
229}
230
231fn copy_dir_tree(src: &Path, dst: &Path) -> std::io::Result<()> {
232 if !src.exists() {
233 return Ok(());
234 }
235 std::fs::create_dir_all(dst)?;
236 for entry in std::fs::read_dir(src)? {
237 let entry = entry?;
238 let src_path = entry.path();
239 let dst_path = dst.join(entry.file_name());
240 if src_path.is_dir() {
241 copy_dir_tree(&src_path, &dst_path)?;
242 } else if src_path.is_file() {
243 std::fs::copy(&src_path, &dst_path)?;
244 }
245 }
246 Ok(())
247}